mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-09 17:12:01 +00:00
Compare commits
122 Commits
v2.0-m1-rc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae9e8bc0bf | ||
|
|
16391e35ab | ||
|
|
18bbb82f07 | ||
|
|
b62c5a7b5b | ||
|
|
5b81588b87 | ||
|
|
63ce88f874 | ||
|
|
37337b77f9 | ||
|
|
8db16bb3d8 | ||
|
|
93e3281cbf | ||
|
|
0fb18e368c | ||
|
|
90e23bfd09 | ||
|
|
f92a26d56f | ||
|
|
3b6dd0a4e3 | ||
|
|
3c2b60aaae | ||
|
|
67ad6e232f | ||
|
|
cd5bedaa74 | ||
|
|
1651d1f1f5 | ||
|
|
799bef6d9d | ||
|
|
3b105d1e5f | ||
|
|
88759015e4 | ||
|
|
64d923b0fd | ||
|
|
519200728a | ||
|
|
40a371b88c | ||
|
|
f725a963d5 | ||
|
|
3b4c9ff733 | ||
|
|
10c1d9f417 | ||
|
|
7679f21edf | ||
|
|
8d2e55a05f | ||
|
|
d8b728f7cb | ||
|
|
befe334f10 | ||
|
|
d133b1119a | ||
|
|
88e36d9686 | ||
|
|
268fdd6968 | ||
|
|
9a5de8d6a5 | ||
|
|
1aac408dd0 | ||
|
|
dd8f9bf5f0 | ||
|
|
2bbc7879fa | ||
|
|
28b1da2172 | ||
|
|
3fddc24c5f | ||
|
|
0d0968a364 | ||
|
|
89ae74d4f4 | ||
|
|
9a53f9dfbb | ||
|
|
8fca56cf43 | ||
|
|
0ffa995fe9 | ||
|
|
f97b0c0f74 | ||
|
|
aca7acc105 | ||
|
|
3ae82dc663 | ||
|
|
5dc2d6cbf5 | ||
|
|
d9f4724950 | ||
|
|
74e3e80cf6 | ||
|
|
019bd16a06 | ||
|
|
031d6fbcbe | ||
|
|
d6a604d5a1 | ||
|
|
46ddc346ad | ||
|
|
79cc227917 | ||
|
|
9f3be2a9fa | ||
|
|
e8e9edcb6e | ||
|
|
4093c83383 | ||
|
|
052b1e2102 | ||
|
|
ca487578a4 | ||
|
|
e683ed6a76 | ||
|
|
872079b894 | ||
|
|
cbf8b194e8 | ||
|
|
d46a5779bc | ||
|
|
2ace78d1e5 | ||
|
|
8330b244a9 | ||
|
|
44677c5eb4 | ||
|
|
2fdfff0db3 | ||
|
|
737abc0e45 | ||
|
|
8decfd327e | ||
|
|
0287240728 | ||
|
|
37451500eb | ||
|
|
3cb75887c1 | ||
|
|
a5599c100c | ||
|
|
9e332c594a | ||
|
|
162fb2143e | ||
|
|
92905e9e3e | ||
|
|
da41701f87 | ||
|
|
e02801944a | ||
|
|
b00749a8a6 | ||
|
|
e344be8d94 | ||
|
|
f68bcb771c | ||
|
|
11dd5b0683 | ||
|
|
f9b7071304 | ||
|
|
e7967a7fc3 | ||
|
|
8785658a2e | ||
|
|
0fb05825a2 | ||
|
|
d0fa37e71d | ||
|
|
604fcbb9d2 | ||
|
|
a64a39dbc0 | ||
|
|
b103d1a7f5 | ||
|
|
66d6a6a4e8 | ||
|
|
f0bae28636 | ||
|
|
2eeb597985 | ||
|
|
914d6a4f1c | ||
|
|
be0eae9825 | ||
|
|
253542ea0d | ||
|
|
c881d95898 | ||
|
|
e93f658472 | ||
|
|
4cb2a22400 | ||
|
|
9c03a71a07 | ||
|
|
1c5c585741 | ||
|
|
df95154282 | ||
|
|
ca7042dec2 | ||
|
|
31513c2ccb | ||
|
|
923f516deb | ||
|
|
8b697245eb | ||
|
|
dcc6f1e678 | ||
|
|
7ec8d3a6e7 | ||
|
|
e19bec1422 | ||
|
|
9afeaf66bc | ||
|
|
b6b3650e50 | ||
|
|
9abe5a18e6 | ||
|
|
9b19cca91c | ||
|
|
6b922e4908 | ||
|
|
8cd4710b16 | ||
|
|
e37912e2c8 | ||
|
|
0c22349029 | ||
|
|
006948232c | ||
|
|
b1ec7e8111 | ||
|
|
b69ca7ad97 | ||
|
|
3599b570a9 |
@ -59,7 +59,7 @@ smoke-test/
|
||||
2. **Check pnpm** - Package manager
|
||||
3. **Check uv** - Python package manager
|
||||
4. **Check nginx** - Reverse proxy
|
||||
5. **Check required ports** - Confirm that ports 2026, 3000, 8001, and 2024 are not occupied
|
||||
5. **Check required ports** - Confirm that ports 2026, 3000, and 8001 are not occupied
|
||||
|
||||
**Docker mode environment check** (if Docker is selected):
|
||||
1. **Check whether Docker is installed** - Run `docker --version`
|
||||
@ -93,17 +93,17 @@ smoke-test/
|
||||
### Phase 5: Service Health Check
|
||||
|
||||
**Local mode health check**:
|
||||
1. **Check process status** - Confirm that LangGraph, Gateway, Frontend, and Nginx processes are all running
|
||||
1. **Check process status** - Confirm that Gateway, Frontend, and Nginx processes are all running
|
||||
2. **Check frontend service** - Visit `http://localhost:2026` and verify that the page loads
|
||||
3. **Check API Gateway** - Verify the `http://localhost:2026/health` endpoint
|
||||
4. **Check LangGraph service** - Verify the availability of relevant endpoints
|
||||
4. **Check LangGraph-compatible API** - Verify the `/api/langgraph/*` route exposed by Gateway
|
||||
5. **Frontend route smoke check** - Run `bash .agent/skills/smoke-test/scripts/frontend_check.sh` to verify key routes under `/workspace`
|
||||
|
||||
**Docker mode health check** (when using Docker):
|
||||
1. **Check container status** - Run `docker ps` and confirm that all containers are running
|
||||
2. **Check frontend service** - Visit `http://localhost:2026` and verify that the page loads
|
||||
3. **Check API Gateway** - Verify the `http://localhost:2026/health` endpoint
|
||||
4. **Check LangGraph service** - Verify the availability of relevant endpoints
|
||||
4. **Check LangGraph-compatible API** - Verify the `/api/langgraph/*` route exposed by Gateway
|
||||
5. **Frontend route smoke check** - Run `bash .agent/skills/smoke-test/scripts/frontend_check.sh` to verify key routes under `/workspace`
|
||||
|
||||
### Optional Functional Verification
|
||||
@ -135,7 +135,7 @@ smoke-test/
|
||||
|
||||
The following warnings can appear during smoke testing and do not block a successful result:
|
||||
- Feishu/Lark SSL errors in Gateway logs (certificate verification failure) can be ignored if that channel is not enabled
|
||||
- Warnings in LangGraph logs about missing methods in the custom checkpointer, such as `adelete_for_runs` or `aprune`, do not affect the core functionality
|
||||
- Warnings in Gateway logs about missing methods in the custom checkpointer, such as `adelete_for_runs` or `aprune`, do not affect the core functionality
|
||||
|
||||
## Key Tools
|
||||
|
||||
|
||||
@ -138,7 +138,6 @@ This document describes the detailed operating steps for each phase of the DeerF
|
||||
lsof -i :2026 # Main port
|
||||
lsof -i :3000 # Frontend
|
||||
lsof -i :8001 # Gateway
|
||||
lsof -i :2024 # LangGraph
|
||||
```
|
||||
|
||||
**Success Criteria**: All ports are free, or they are occupied only by DeerFlow-related processes.
|
||||
@ -258,7 +257,7 @@ This document describes the detailed operating steps for each phase of the DeerF
|
||||
**Steps**:
|
||||
1. Run `make dev-daemon` (background mode)
|
||||
|
||||
**Description**: This command starts all services (LangGraph, Gateway, Frontend, Nginx).
|
||||
**Description**: This command starts all services (Gateway embedded runtime, Frontend, Nginx).
|
||||
|
||||
**Notes**:
|
||||
- `make dev` runs in the foreground and stops with Ctrl+C
|
||||
@ -272,7 +271,6 @@ This document describes the detailed operating steps for each phase of the DeerF
|
||||
**Steps**:
|
||||
1. Wait 90-120 seconds for all services to start completely
|
||||
2. You can monitor startup progress by checking these log files:
|
||||
- `logs/langgraph.log`
|
||||
- `logs/gateway.log`
|
||||
- `logs/frontend.log`
|
||||
- `logs/nginx.log`
|
||||
@ -316,11 +314,10 @@ This document describes the detailed operating steps for each phase of the DeerF
|
||||
**Steps**:
|
||||
1. Run the following command to check processes:
|
||||
```bash
|
||||
ps aux | grep -E "(langgraph|uvicorn|next|nginx)" | grep -v grep
|
||||
ps aux | grep -E "(uvicorn|next|nginx)" | grep -v grep
|
||||
```
|
||||
|
||||
**Success Criteria**: Confirm that the following processes are running:
|
||||
- LangGraph (`langgraph dev`)
|
||||
- Gateway (`uvicorn app.gateway.app:app`)
|
||||
- Frontend (`next dev` or `next start`)
|
||||
- Nginx (`nginx`)
|
||||
@ -356,10 +353,11 @@ curl http://localhost:2026/health
|
||||
|
||||
---
|
||||
|
||||
#### 5.1.4 Check LangGraph Service
|
||||
#### 5.1.4 Check LangGraph-compatible API
|
||||
|
||||
**Steps**:
|
||||
1. Visit relevant LangGraph endpoints to verify availability
|
||||
1. Visit `http://localhost:2026/api/langgraph/assistants/lead_agent` to verify Gateway's LangGraph-compatible API route is reachable.
|
||||
2. A `401` response is acceptable when authentication is enabled and no session cookie is provided.
|
||||
|
||||
---
|
||||
|
||||
@ -373,7 +371,6 @@ curl http://localhost:2026/health
|
||||
- `deer-flow-nginx`
|
||||
- `deer-flow-frontend`
|
||||
- `deer-flow-gateway`
|
||||
- `deer-flow-langgraph` (if not in gateway mode)
|
||||
|
||||
---
|
||||
|
||||
@ -406,10 +403,11 @@ curl http://localhost:2026/health
|
||||
|
||||
---
|
||||
|
||||
#### 5.2.4 Check LangGraph Service
|
||||
#### 5.2.4 Check LangGraph-compatible API
|
||||
|
||||
**Steps**:
|
||||
1. Visit relevant LangGraph endpoints to verify availability
|
||||
1. Visit `http://localhost:2026/api/langgraph/assistants/lead_agent` to verify Gateway's LangGraph-compatible API route is reachable.
|
||||
2. A `401` response is acceptable when authentication is enabled and no session cookie is provided.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -254,7 +254,6 @@ Processes exit quickly after running `make dev-daemon`.
|
||||
**Solutions**:
|
||||
1. Check log files:
|
||||
```bash
|
||||
tail -f logs/langgraph.log
|
||||
tail -f logs/gateway.log
|
||||
tail -f logs/frontend.log
|
||||
tail -f logs/nginx.log
|
||||
@ -367,24 +366,7 @@ Errors appear in `gateway.log`.
|
||||
uv sync
|
||||
```
|
||||
|
||||
4. Confirm that the LangGraph service is running normally (if not in gateway mode)
|
||||
|
||||
---
|
||||
|
||||
### Issue: LangGraph Fails to Start
|
||||
|
||||
**Symptoms**:
|
||||
Errors appear in `langgraph.log`.
|
||||
|
||||
**Solutions**:
|
||||
1. Check LangGraph logs:
|
||||
```bash
|
||||
tail -f logs/langgraph.log
|
||||
```
|
||||
|
||||
2. Check config.yaml
|
||||
3. Check whether Python dependencies are complete
|
||||
4. Confirm that port 2024 is not occupied
|
||||
4. Confirm that the Gateway process is running normally.
|
||||
|
||||
---
|
||||
|
||||
@ -519,7 +501,7 @@ Accessing `/health` returns an error or times out.
|
||||
|
||||
2. Confirm that config.yaml exists and has valid formatting
|
||||
3. Check whether Python dependencies are complete
|
||||
4. Confirm that the LangGraph service is running normally
|
||||
4. Confirm that the Gateway process is running normally.
|
||||
|
||||
**Solutions** (Docker mode):
|
||||
1. Check gateway container logs:
|
||||
@ -529,7 +511,7 @@ Accessing `/health` returns an error or times out.
|
||||
|
||||
2. Confirm that config.yaml is mounted correctly
|
||||
3. Check whether Python dependencies are complete
|
||||
4. Confirm that the LangGraph service is running normally
|
||||
4. Confirm that the Gateway process is running normally.
|
||||
|
||||
---
|
||||
|
||||
@ -539,7 +521,7 @@ Accessing `/health` returns an error or times out.
|
||||
|
||||
#### View All Service Processes
|
||||
```bash
|
||||
ps aux | grep -E "(langgraph|uvicorn|next|nginx)" | grep -v grep
|
||||
ps aux | grep -E "(uvicorn|next|nginx)" | grep -v grep
|
||||
```
|
||||
|
||||
#### View Service Logs
|
||||
@ -548,7 +530,6 @@ ps aux | grep -E "(langgraph|uvicorn|next|nginx)" | grep -v grep
|
||||
tail -f logs/*.log
|
||||
|
||||
# View specific service logs
|
||||
tail -f logs/langgraph.log
|
||||
tail -f logs/gateway.log
|
||||
tail -f logs/frontend.log
|
||||
tail -f logs/nginx.log
|
||||
|
||||
@ -65,7 +65,7 @@ if ! command -v lsof >/dev/null 2>&1; then
|
||||
echo " Install lsof and rerun this check"
|
||||
all_passed=false
|
||||
else
|
||||
for port in 2026 3000 8001 2024; do
|
||||
for port in 2026 3000 8001; do
|
||||
if lsof -i :$port >/dev/null 2>&1; then
|
||||
echo "⚠ Port $port is already in use:"
|
||||
lsof -i :$port | head -2
|
||||
|
||||
@ -54,7 +54,6 @@ echo "=========================================="
|
||||
echo ""
|
||||
echo "🌐 Access URL: http://localhost:2026"
|
||||
echo "📋 View logs:"
|
||||
echo " - logs/langgraph.log"
|
||||
echo " - logs/gateway.log"
|
||||
echo " - logs/frontend.log"
|
||||
echo " - logs/nginx.log"
|
||||
|
||||
@ -76,12 +76,11 @@ if [ "$mode" = "docker" ]; then
|
||||
all_passed=false
|
||||
fi
|
||||
else
|
||||
summary_hint="logs/{langgraph,gateway,frontend,nginx}.log"
|
||||
summary_hint="logs/{gateway,frontend,nginx}.log"
|
||||
print_step "1. Checking local service ports..."
|
||||
check_listen_port "Nginx" 2026
|
||||
check_listen_port "Frontend" 3000
|
||||
check_listen_port "Gateway" 8001
|
||||
check_listen_port "LangGraph" 2024
|
||||
fi
|
||||
echo ""
|
||||
|
||||
@ -104,8 +103,8 @@ else
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "5. Checking LangGraph service..."
|
||||
check_http_status "LangGraph service" "http://localhost:2024/" "200|301|302|307|308|404"
|
||||
echo "5. Checking LangGraph-compatible Gateway API..."
|
||||
check_http_status "LangGraph-compatible Gateway API" "http://localhost:2026/api/langgraph/assistants/lead_agent" "200|401"
|
||||
echo ""
|
||||
|
||||
echo "=========================================="
|
||||
|
||||
@ -78,7 +78,7 @@
|
||||
- [x] Container status - {{status_containers}}
|
||||
- [x] Frontend service - {{status_frontend}}
|
||||
- [x] API Gateway - {{status_api_gateway}}
|
||||
- [x] LangGraph service - {{status_langgraph}}
|
||||
- [x] LangGraph-compatible Gateway API - {{status_langgraph}}
|
||||
|
||||
**Phase Status**: {{stage5_status}}
|
||||
|
||||
@ -147,7 +147,6 @@ Commit Message: {{git_commit_message}}
|
||||
| deer-flow-nginx | {{nginx_status}} | {{nginx_uptime}} |
|
||||
| deer-flow-frontend | {{frontend_status}} | {{frontend_uptime}} |
|
||||
| deer-flow-gateway | {{gateway_status}} | {{gateway_uptime}} |
|
||||
| deer-flow-langgraph | {{langgraph_status}} | {{langgraph_uptime}} |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -80,7 +80,7 @@
|
||||
- [x] Process status - {{status_processes}}
|
||||
- [x] Frontend service - {{status_frontend}}
|
||||
- [x] API Gateway - {{status_api_gateway}}
|
||||
- [x] LangGraph service - {{status_langgraph}}
|
||||
- [x] LangGraph-compatible Gateway API - {{status_langgraph}}
|
||||
|
||||
**Phase Status**: {{stage5_status}}
|
||||
|
||||
@ -152,7 +152,7 @@ Commit Message: {{git_commit_message}}
|
||||
| Nginx | {{nginx_status}} | {{nginx_endpoint}} |
|
||||
| Frontend | {{frontend_status}} | {{frontend_endpoint}} |
|
||||
| Gateway | {{gateway_status}} | {{gateway_endpoint}} |
|
||||
| LangGraph | {{langgraph_status}} | {{langgraph_endpoint}} |
|
||||
| Gateway LangGraph API | {{langgraph_status}} | {{langgraph_endpoint}} |
|
||||
|
||||
---
|
||||
|
||||
@ -166,7 +166,7 @@ Commit Message: {{git_commit_message}}
|
||||
|
||||
### If the Test Fails
|
||||
1. [ ] Review references/troubleshooting.md for common solutions
|
||||
2. [ ] Check local logs: `logs/{langgraph,gateway,frontend,nginx}.log`
|
||||
2. [ ] Check local logs: `logs/{gateway,frontend,nginx}.log`
|
||||
3. [ ] Verify configuration file format and content
|
||||
4. [ ] If needed, fully reset the environment: `make stop && make clean && make install && make dev-daemon`
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ INFOQUEST_API_KEY=your-infoquest-api-key
|
||||
# DEEPSEEK_API_KEY=your-deepseek-api-key
|
||||
# NOVITA_API_KEY=your-novita-api-key # OpenAI-compatible, see https://novita.ai
|
||||
# MINIMAX_API_KEY=your-minimax-api-key # OpenAI-compatible, see https://platform.minimax.io
|
||||
# STEPFUN_API_KEY=your-stepfun-api-key # OpenAI-compatible, see https://platform.stepfun.com
|
||||
# VLLM_API_KEY=your-vllm-api-key # OpenAI-compatible
|
||||
# FEISHU_APP_ID=your-feishu-app-id
|
||||
# FEISHU_APP_SECRET=your-feishu-app-secret
|
||||
@ -50,6 +51,11 @@ INFOQUEST_API_KEY=your-infoquest-api-key
|
||||
# Set to "false" to disable Swagger UI, ReDoc, and OpenAPI schema in production
|
||||
# GATEWAY_ENABLE_DOCS=false
|
||||
|
||||
# Shared internal Gateway auth token for multi-worker deployments.
|
||||
# `make up` generates and persists this automatically; set it manually only
|
||||
# when you run Gateway workers outside the bundled deploy script.
|
||||
# DEER_FLOW_INTERNAL_AUTH_TOKEN=your-shared-internal-token
|
||||
|
||||
# ── Frontend SSR → Gateway wiring ─────────────────────────────────────────────
|
||||
# The Next.js server uses these to reach the Gateway during SSR (auth checks,
|
||||
# /api/* rewrites). They default to localhost values that match `make dev` and
|
||||
|
||||
159
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
Normal file
159
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
Normal file
@ -0,0 +1,159 @@
|
||||
name: 🐛 Bug report
|
||||
description: Report something that isn't working so maintainers can reproduce and fix it.
|
||||
title: "[bug] "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to file a bug. A clear, reproducible report is the
|
||||
single biggest factor in how fast it gets fixed.
|
||||
|
||||
Please fill in every required field — especially **reproduction steps** and **logs**.
|
||||
|
||||
- type: checkboxes
|
||||
id: preflight
|
||||
attributes:
|
||||
label: Before you start
|
||||
options:
|
||||
- label: I searched [existing issues](https://github.com/bytedance/deer-flow/issues?q=is%3Aissue) and this is not a duplicate.
|
||||
required: true
|
||||
- label: I can reproduce this on the latest `main`.
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: Problem summary
|
||||
description: One sentence describing the bug.
|
||||
placeholder: e.g. make dev fails to start the gateway service
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: areas
|
||||
attributes:
|
||||
label: Affected area(s)
|
||||
description: Which part of DeerFlow does this touch? Select all that apply.
|
||||
multiple: true
|
||||
options:
|
||||
- Frontend (UI / Next.js)
|
||||
- Backend API (gateway / endpoints / SSE)
|
||||
- Agents / LangGraph (graph, prompts, langgraph.json)
|
||||
- Sandbox / Docker
|
||||
- Skills
|
||||
- MCP
|
||||
- Config / setup (make, config.yaml, env)
|
||||
- Docs
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: The actual behavior. Include the key error lines verbatim.
|
||||
placeholder: When I do X, I expected Y but I got Z.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
placeholder: What did you expect to happen instead?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Exact commands and sequence. Minimal steps that reliably reproduce the problem.
|
||||
placeholder: |
|
||||
1. make check
|
||||
2. make install
|
||||
3. make dev
|
||||
4. ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant logs
|
||||
description: Paste key lines from logs (for example `logs/gateway.log`, `logs/frontend.log`). Redact secrets.
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: run_mode
|
||||
attributes:
|
||||
label: How are you running DeerFlow?
|
||||
options:
|
||||
- Local (make dev)
|
||||
- Docker (make docker-start)
|
||||
- CI
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
options:
|
||||
- macOS
|
||||
- Linux
|
||||
- Windows
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: platform_details
|
||||
attributes:
|
||||
label: Platform details
|
||||
description: Architecture and shell, if relevant.
|
||||
placeholder: e.g. arm64, zsh
|
||||
|
||||
- type: input
|
||||
id: python_version
|
||||
attributes:
|
||||
label: Python version
|
||||
placeholder: e.g. Python 3.12.9
|
||||
|
||||
- type: input
|
||||
id: node_version
|
||||
attributes:
|
||||
label: Node.js version
|
||||
placeholder: e.g. v22.11.0
|
||||
|
||||
- type: input
|
||||
id: pnpm_version
|
||||
attributes:
|
||||
label: pnpm version
|
||||
placeholder: e.g. 10.26.2
|
||||
|
||||
- type: input
|
||||
id: uv_version
|
||||
attributes:
|
||||
label: uv version
|
||||
placeholder: e.g. 0.7.20
|
||||
|
||||
- type: textarea
|
||||
id: git_info
|
||||
attributes:
|
||||
label: Git state
|
||||
description: Output of `git branch --show-current` and the latest commit SHA.
|
||||
placeholder: |
|
||||
branch: feature/my-branch
|
||||
commit: abcdef1
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Screenshots, related issues, config snippets (redacted), or anything else that helps triage.
|
||||
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 Questions & usage help
|
||||
url: https://github.com/bytedance/deer-flow/discussions/categories/q-a
|
||||
about: "How do I use X? Why does Y behave like that? Ask in Discussions — it gets answered faster and stays searchable."
|
||||
- name: 💡 Ideas & proposals
|
||||
url: https://github.com/bytedance/deer-flow/discussions/categories/ideas
|
||||
about: Have a half-formed idea? Float it in Discussions before opening a formal feature request.
|
||||
- name: 🔒 Report a security vulnerability
|
||||
url: https://github.com/bytedance/deer-flow/security/policy
|
||||
about: Do not open a public issue for security problems. Follow the security policy instead.
|
||||
67
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
67
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
@ -0,0 +1,67 @@
|
||||
name: 💡 Feature request
|
||||
description: Propose a new capability or an improvement to an existing one.
|
||||
title: "[feat] "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for the suggestion. For non-trivial features, please open a
|
||||
[Discussion](https://github.com/bytedance/deer-flow/discussions/categories/ideas)
|
||||
first to align on scope before writing code.
|
||||
|
||||
- type: checkboxes
|
||||
id: preflight
|
||||
attributes:
|
||||
label: Before you start
|
||||
options:
|
||||
- label: I searched [existing issues](https://github.com/bytedance/deer-flow/issues?q=is%3Aissue) and this is not a duplicate.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem / motivation
|
||||
description: What problem does this solve? What is painful today, or what does it unblock?
|
||||
placeholder: "I'm always frustrated when ..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Describe the change from a user's / caller's perspective.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: areas
|
||||
attributes:
|
||||
label: Affected area(s)
|
||||
description: Which part of DeerFlow would this touch? Select all that apply.
|
||||
multiple: true
|
||||
options:
|
||||
- Frontend (UI / Next.js)
|
||||
- Backend API (gateway / endpoints / SSE)
|
||||
- Agents / LangGraph (graph, prompts, langgraph.json)
|
||||
- Sandbox / Docker
|
||||
- Skills
|
||||
- MCP
|
||||
- Config / setup
|
||||
- Docs
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Other approaches you weighed and why you discarded them.
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Mockups, links, related issues, or anything else that helps.
|
||||
128
.github/ISSUE_TEMPLATE/runtime-information.yml
vendored
128
.github/ISSUE_TEMPLATE/runtime-information.yml
vendored
@ -1,128 +0,0 @@
|
||||
name: Runtime Information
|
||||
description: Report runtime/environment details to help reproduce an issue.
|
||||
title: "[runtime] "
|
||||
labels:
|
||||
- needs-triage
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for sharing runtime details.
|
||||
Complete this form so maintainers can quickly reproduce and diagnose the problem.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: Problem summary
|
||||
description: Short summary of the issue.
|
||||
placeholder: e.g. make dev fails to start gateway service
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
placeholder: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
placeholder: What happened instead? Include key error lines.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
options:
|
||||
- macOS
|
||||
- Linux
|
||||
- Windows
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: platform_details
|
||||
attributes:
|
||||
label: Platform details
|
||||
description: Add architecture and shell if relevant.
|
||||
placeholder: e.g. arm64, zsh
|
||||
|
||||
- type: input
|
||||
id: python_version
|
||||
attributes:
|
||||
label: Python version
|
||||
placeholder: e.g. Python 3.12.9
|
||||
|
||||
- type: input
|
||||
id: node_version
|
||||
attributes:
|
||||
label: Node.js version
|
||||
placeholder: e.g. v23.11.0
|
||||
|
||||
- type: input
|
||||
id: pnpm_version
|
||||
attributes:
|
||||
label: pnpm version
|
||||
placeholder: e.g. 10.26.2
|
||||
|
||||
- type: input
|
||||
id: uv_version
|
||||
attributes:
|
||||
label: uv version
|
||||
placeholder: e.g. 0.7.20
|
||||
|
||||
- type: dropdown
|
||||
id: run_mode
|
||||
attributes:
|
||||
label: How are you running DeerFlow?
|
||||
options:
|
||||
- Local (make dev)
|
||||
- Docker (make docker-dev)
|
||||
- CI
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: Reproduction steps
|
||||
description: Provide exact commands and sequence.
|
||||
placeholder: |
|
||||
1. make check
|
||||
2. make install
|
||||
3. make dev
|
||||
4. ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant logs
|
||||
description: Paste key lines from logs (for example logs/gateway.log, logs/frontend.log).
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: git_info
|
||||
attributes:
|
||||
label: Git state
|
||||
description: Share output of git branch and latest commit SHA.
|
||||
placeholder: |
|
||||
branch: feature/my-branch
|
||||
commit: abcdef1
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add anything else that might help triage.
|
||||
119
.github/labels.yml
vendored
Normal file
119
.github/labels.yml
vendored
Normal file
@ -0,0 +1,119 @@
|
||||
# Declarative label source of truth for DeerFlow.
|
||||
#
|
||||
# This file is the single source of truth for repository labels used by the
|
||||
# auto-labeling workflows (.github/workflows/pr-labeler.yml, pr-triage.yml,
|
||||
# issue-triage.yml). Auto-labelers can only apply labels that already exist,
|
||||
# so every label referenced by a workflow MUST be declared here.
|
||||
#
|
||||
# Apply with: uv run --with pyyaml python scripts/sync_labels.py [--repo OWNER/NAME]
|
||||
# CI keeps it in sync via .github/workflows/label-sync.yml (runs on changes here).
|
||||
#
|
||||
# Sync is additive/update-only: it creates or updates the labels listed below
|
||||
# and never deletes labels that are not listed.
|
||||
#
|
||||
# Color = 6-digit hex without the leading '#'.
|
||||
|
||||
labels:
|
||||
# ── Type ─────────────────────────────────────────────────────────────────
|
||||
# Mostly GitHub defaults; declared here so colors/descriptions stay stable
|
||||
# and so issue templates can rely on them existing.
|
||||
- name: bug
|
||||
color: d73a4a
|
||||
description: Something isn't working
|
||||
- name: enhancement
|
||||
color: a2eeef
|
||||
description: New feature or request
|
||||
- name: documentation
|
||||
color: 0075ca
|
||||
description: Improvements or additions to documentation
|
||||
- name: question
|
||||
color: d876e3
|
||||
description: Further information is requested
|
||||
|
||||
# ── Area (auto, by changed paths — see .github/labeler.yml) ───────────────
|
||||
# Mirrors the "Surface area" section of the pull request template.
|
||||
- name: "area:frontend"
|
||||
color: c5def5
|
||||
description: Next.js frontend under frontend/
|
||||
- name: "area:backend"
|
||||
color: c5def5
|
||||
description: Gateway / runtime / core backend under backend/
|
||||
- name: "area:agents"
|
||||
color: c5def5
|
||||
description: Agents, subagents, graph wiring, prompts, langgraph.json
|
||||
- name: "area:sandbox"
|
||||
color: c5def5
|
||||
description: Sandboxed execution and docker/
|
||||
- name: "area:skills"
|
||||
color: c5def5
|
||||
description: Skills under skills/ or the skills harness
|
||||
- name: "area:mcp"
|
||||
color: c5def5
|
||||
description: Model Context Protocol integration
|
||||
- name: "area:ci"
|
||||
color: c5def5
|
||||
description: GitHub Actions, CI config, repo tooling
|
||||
- name: "area:docs"
|
||||
color: c5def5
|
||||
description: Documentation and Markdown only
|
||||
- name: "area:deps"
|
||||
color: c5def5
|
||||
description: Dependency manifests / lockfiles
|
||||
|
||||
# ── Size (auto, by additions + deletions — see pr-triage.yml) ─────────────
|
||||
- name: "size/XS"
|
||||
color: "009900"
|
||||
description: PR changes < 20 lines
|
||||
- name: "size/S"
|
||||
color: 77bb00
|
||||
description: PR changes 20-100 lines
|
||||
- name: "size/M"
|
||||
color: eebb00
|
||||
description: PR changes 100-300 lines
|
||||
- name: "size/L"
|
||||
color: ee9900
|
||||
description: PR changes 300-700 lines
|
||||
- name: "size/XL"
|
||||
color: ee5500
|
||||
description: PR changes 700+ lines
|
||||
|
||||
# ── Risk (auto, by changed paths — see pr-triage.yml) ─────────────────────
|
||||
- name: "risk:low"
|
||||
color: 0e8a16
|
||||
description: "Low risk: docs / i18n / assets only"
|
||||
- name: "risk:medium"
|
||||
color: fbca04
|
||||
description: "Medium risk: regular code changes"
|
||||
- name: "risk:high"
|
||||
color: b60205
|
||||
description: "High risk: backend API, agents, sandbox, auth, deps, CI"
|
||||
|
||||
# ── Priority (manual) ─────────────────────────────────────────────────────
|
||||
- name: P0
|
||||
color: b60205
|
||||
description: Critical priority
|
||||
- name: P1
|
||||
color: d93f0b
|
||||
description: Major priority
|
||||
- name: P2
|
||||
color: e99695
|
||||
description: Normal priority
|
||||
|
||||
# ── Status (auto + manual) ────────────────────────────────────────────────
|
||||
- name: needs-triage
|
||||
color: fef2c0
|
||||
description: Awaiting maintainer triage
|
||||
- name: needs-validation
|
||||
color: d4c5f9
|
||||
description: Touches front/back contract surface; needs real-path validation
|
||||
- name: skip-validation
|
||||
color: cccccc
|
||||
description: "Maintainer override: do not auto-add needs-validation on this PR"
|
||||
- name: reviewing
|
||||
color: 5319e7
|
||||
description: A maintainer is reviewing this PR
|
||||
|
||||
# ── Contributor ───────────────────────────────────────────────────────────
|
||||
- name: first-time-contributor
|
||||
color: c2e0c6
|
||||
description: First contribution to this repository — be welcoming
|
||||
75
.github/pull_request_template.md
vendored
Normal file
75
.github/pull_request_template.md
vendored
Normal file
@ -0,0 +1,75 @@
|
||||
<!-- Reference a related issue with #123. Use Fixes / Closes / Resolves to
|
||||
auto-close it on merge. Delete this line if the PR doesn't reference an issue. -->
|
||||
Fixes #
|
||||
|
||||
## Why
|
||||
|
||||
<!-- Why are you opening this PR? Cover two things:
|
||||
- The trigger — what made you write this? A bug you hit, a feature you need,
|
||||
tech debt, or a prod issue?
|
||||
- The pain being addressed — user-facing problem, or what it unblocks.
|
||||
For non-trivial features, please open an issue/discussion first to align on
|
||||
scope before writing code. -->
|
||||
|
||||
|
||||
## What changed
|
||||
|
||||
<!-- Describe the change from a user's / caller's perspective, not as a code diff. e.g.:
|
||||
- "Settings now has a 'Custom endpoint' field, off by default"
|
||||
- "Backend /api/chat gains a `stream` flag, defaults to false"
|
||||
- "Default model changed from X to Y — existing users notice on first run" -->
|
||||
|
||||
|
||||
## Surface area
|
||||
|
||||
<!-- Check every box that applies. Reviewers use this to scope the review. -->
|
||||
|
||||
- [ ] **Frontend UI** — page / component / setting / interaction under `frontend/`
|
||||
- [ ] **Backend API** — endpoint / SSE event / request-response shape under `backend/app`
|
||||
- [ ] **Agents / LangGraph** — agent node, graph wiring, `langgraph.json`, or prompt change
|
||||
- [ ] **Sandbox** — `docker/` or sandboxed execution
|
||||
- [ ] **Skills** — change under `skills/`
|
||||
- [ ] **Dependencies** — new/upgraded entry in `backend/pyproject.toml` or `frontend/package.json` (say what it buys us)
|
||||
- [ ] **Default behavior change** — changes existing behavior without the user opting in (default model, default setting, data shape)
|
||||
- [ ] **Docs / tests / CI only** — no runtime behavior change
|
||||
|
||||
|
||||
## Screenshots / Recording
|
||||
|
||||
<!-- If you checked "Frontend UI", attach screenshots showing the entry point —
|
||||
where users discover the change — not just the feature in isolation.
|
||||
Before/after is best for behavior changes. Short GIFs welcome. -->
|
||||
|
||||
|
||||
## Bug fix verification
|
||||
|
||||
<!-- Skip (delete) this section if this PR is not a bug fix.
|
||||
|
||||
Bugs should be encoded as a failing test that goes red before the fix.
|
||||
Confirm:
|
||||
- Test path that reproduces the bug:
|
||||
- Did it go red on `main` and green on this branch? (yes / no)
|
||||
- If a red test wasn't cheap to write, explain why and what you did instead. -->
|
||||
|
||||
|
||||
## Validation
|
||||
|
||||
<!-- What you actually ran. Run at least the checks for the area you changed:
|
||||
Backend: cd backend && make lint && make test
|
||||
Frontend: cd frontend && pnpm format && pnpm lint && pnpm typecheck && BETTER_AUTH_SECRET=local-dev-secret pnpm build && make test
|
||||
Frontend E2E (if you touched frontend/): cd frontend && make test-e2e -->
|
||||
|
||||
|
||||
## AI assistance
|
||||
|
||||
<!-- DeerFlow is an AI project — most PRs here use AI coding tools, and that's
|
||||
welcome. Disclosing it just helps reviewers calibrate how closely to read the
|
||||
diff. Please fill all three; don't delete the section. -->
|
||||
|
||||
**Tool(s) used:** <!-- e.g. Claude Code, Cursor, GitHub Copilot, Codex, Windsurf, or "none" -->
|
||||
|
||||
**How you used it:** <!-- e.g. "generated the module from a spec", "autocomplete only",
|
||||
"AI wrote tests, I wrote the impl". A prompt or conversation link is great too. -->
|
||||
|
||||
- [ ] I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.
|
||||
|
||||
46
.github/workflows/backend-blocking-io-tests.yml
vendored
Normal file
46
.github/workflows/backend-blocking-io-tests.yml
vendored
Normal file
@ -0,0 +1,46 @@
|
||||
name: Backend Blocking IO
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- "backend/**"
|
||||
- ".github/workflows/backend-blocking-io-tests.yml"
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- "backend/**"
|
||||
- ".github/workflows/backend-blocking-io-tests.yml"
|
||||
|
||||
concurrency:
|
||||
group: blocking-io-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
backend-blocking-io:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
|
||||
- name: Install backend dependencies
|
||||
working-directory: backend
|
||||
run: uv sync --group dev
|
||||
|
||||
- name: Run blocking IO regression tests
|
||||
working-directory: backend
|
||||
run: make test-blocking-io
|
||||
38
.github/workflows/label-sync.yml
vendored
Normal file
38
.github/workflows/label-sync.yml
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
name: Label Sync
|
||||
|
||||
# Keeps repository labels in sync with the declarative source of truth
|
||||
# (.github/labels.yml). Runs whenever that file changes on main, and can be
|
||||
# triggered manually. Additive/update-only — never deletes labels.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/labels.yml"
|
||||
- "scripts/sync_labels.py"
|
||||
- ".github/workflows/label-sync.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: label-sync
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Sync labels
|
||||
run: uv run --with pyyaml python scripts/sync_labels.py
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
108
.github/workflows/replay-e2e.yml
vendored
Normal file
108
.github/workflows/replay-e2e.yml
vendored
Normal file
@ -0,0 +1,108 @@
|
||||
name: Replay E2E (front-back contract)
|
||||
|
||||
# Guards the front-back contract via record/replay (no API key in CI):
|
||||
# Layer 1 — backend golden: replay a recorded trace through the real gateway,
|
||||
# assert the SSE event sequence matches the committed golden.
|
||||
# Layer 2 — full-stack render: real Next.js frontend + real gateway (replay
|
||||
# model) + Chromium; assert the replayed turns render in the browser.
|
||||
# Triggered by changes on EITHER side of the contract so a backend change can no
|
||||
# longer pass without the frontend-facing checks running.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- "frontend/**"
|
||||
- "backend/app/gateway/**"
|
||||
- "backend/packages/harness/**"
|
||||
- "backend/tests/fixtures/replay/**"
|
||||
- "backend/tests/replay_provider.py"
|
||||
- "backend/tests/_replay_fixture.py"
|
||||
- "backend/tests/seed_runs_router.py"
|
||||
- "backend/tests/test_replay_golden.py"
|
||||
- "backend/scripts/run_replay_gateway.py"
|
||||
- ".github/workflows/replay-e2e.yml"
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- "frontend/**"
|
||||
- "backend/app/gateway/**"
|
||||
- "backend/packages/harness/**"
|
||||
- "backend/tests/fixtures/replay/**"
|
||||
- "backend/tests/replay_provider.py"
|
||||
- "backend/tests/_replay_fixture.py"
|
||||
- "backend/tests/seed_runs_router.py"
|
||||
- "backend/tests/test_replay_golden.py"
|
||||
- "backend/scripts/run_replay_gateway.py"
|
||||
- ".github/workflows/replay-e2e.yml"
|
||||
|
||||
concurrency:
|
||||
group: replay-e2e-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
backend-replay-golden:
|
||||
name: Layer 1 — backend golden (no API key)
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
- name: Install backend dependencies
|
||||
working-directory: backend
|
||||
run: uv sync --group dev
|
||||
- name: Replay golden (backend SSE contract)
|
||||
working-directory: backend
|
||||
run: PYTHONPATH=. uv run pytest tests/test_replay_golden.py -v
|
||||
|
||||
fullstack-replay-render:
|
||||
name: Layer 2 — full-stack render (no API key)
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
- name: Install backend dependencies (replay gateway)
|
||||
working-directory: backend
|
||||
run: uv sync --group dev
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Use pinned pnpm version
|
||||
run: corepack prepare pnpm@10.26.2 --activate
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Install Playwright Chromium
|
||||
working-directory: frontend
|
||||
run: npx playwright install chromium --with-deps
|
||||
- name: Full-stack replay render (DOM assertions are the gate)
|
||||
working-directory: frontend
|
||||
run: pnpm exec playwright test -c playwright.real-backend.config.ts
|
||||
- name: Upload report + render artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: replay-render
|
||||
path: |
|
||||
frontend/playwright-report/
|
||||
frontend/test-results/
|
||||
retention-days: 7
|
||||
223
.github/workflows/triage.yml
vendored
Normal file
223
.github/workflows/triage.yml
vendored
Normal file
@ -0,0 +1,223 @@
|
||||
name: Triage
|
||||
|
||||
# One workflow for all event-driven PR/issue labeling. Replaces the former
|
||||
# pr-labeler / pr-triage / issue-triage workflows (and drops actions/labeler).
|
||||
#
|
||||
# Design notes:
|
||||
# * All jobs are pure-metadata: they read changed-file lists / PR fields / the
|
||||
# review payload via the API and write labels. PR code is NEVER checked out
|
||||
# or executed, so pull_request_target is safe here.
|
||||
# * Each job only reconciles labels in namespaces IT owns
|
||||
# (area:* / size/* / risk:* / needs-validation). It never touches labels
|
||||
# applied by maintainers or other tools (bug, priority, etc.). first-time-
|
||||
# contributor and reviewing are add-only.
|
||||
# * State is read LIVE (listFiles + listLabelsOnIssue) at run time, not from
|
||||
# the (stale) event payload, so rapid synchronize events converge instead
|
||||
# of thrashing.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
# ── PR: area / size / risk / needs-validation / first-time ─────────────────
|
||||
pr-labels:
|
||||
if: github.event_name == 'pull_request_target' && github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: triage-pr-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Apply PR labels from live state
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const { owner, repo } = context.repo;
|
||||
const num = pr.number;
|
||||
|
||||
// ---- live changed files ----
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner, repo, pull_number: num, per_page: 100,
|
||||
});
|
||||
const paths = files.map(f => f.filename);
|
||||
const m = (re) => paths.some(p => re.test(p));
|
||||
|
||||
// ---- area: replaces .github/labeler.yml (path -> area) ----
|
||||
const AREA_RULES = [
|
||||
['area:frontend', [/^frontend\//]],
|
||||
['area:backend', [/^backend\/app\//, /^backend\/packages\/harness\/deerflow\/(runtime|persistence|config|tools|guardrails|tracing|models|utils|uploads)\//]],
|
||||
['area:agents', [/^backend\/packages\/harness\/deerflow\/(agents|subagents|reflection)\//, /(^|\/)langgraph\.json$/, /^backend\/.*\/prompts\//]],
|
||||
['area:sandbox', [/^docker\//, /^backend\/packages\/harness\/deerflow\/sandbox\//, /(^|\/)Dockerfile$/]],
|
||||
['area:skills', [/^skills\//, /^backend\/packages\/harness\/deerflow\/skills\//, /^frontend\/src\/core\/skills\//]],
|
||||
['area:mcp', [/^backend\/packages\/harness\/deerflow\/mcp\//, /^frontend\/src\/core\/mcp\//]],
|
||||
['area:ci', [/^\.github\//, /^scripts\//]],
|
||||
['area:docs', [/^docs\//, /\.mdx?$/]],
|
||||
['area:deps', [/(^|\/)(pyproject\.toml|uv\.lock|package\.json|pnpm-lock\.yaml)$/]],
|
||||
];
|
||||
const areaLabels = AREA_RULES
|
||||
.filter(([, res]) => res.some(re => m(re)))
|
||||
.map(([label]) => label);
|
||||
|
||||
// ---- size: additions+deletions, excluding lockfiles/snapshots ----
|
||||
const EXCLUDE_SIZE = /(^|\/)(uv\.lock|pnpm-lock\.yaml|package-lock\.json)$|\.snap$/;
|
||||
const churn = files
|
||||
.filter(f => !EXCLUDE_SIZE.test(f.filename))
|
||||
.reduce((s, f) => s + (f.additions || 0) + (f.deletions || 0), 0);
|
||||
const sizeLabel =
|
||||
churn < 20 ? 'size/XS' :
|
||||
churn < 100 ? 'size/S' :
|
||||
churn < 300 ? 'size/M' :
|
||||
churn < 700 ? 'size/L' : 'size/XL';
|
||||
|
||||
// ---- risk ----
|
||||
const docsOnly = paths.length > 0 && paths.every(p =>
|
||||
/\.(md|mdx|txt)$/i.test(p) || p.startsWith('docs/') ||
|
||||
/\.(png|jpe?g|gif|svg|webp|ico)$/i.test(p));
|
||||
const highRisk =
|
||||
m(/^backend\/app\/gateway\//) ||
|
||||
m(/^backend\/packages\/harness\/deerflow\/(agents|subagents|sandbox)\//) ||
|
||||
m(/(^|\/)langgraph\.json$/) ||
|
||||
m(/(^|\/)(auth|authz|security)/i) ||
|
||||
m(/(pyproject\.toml|uv\.lock|package\.json|pnpm-lock\.yaml)$/) ||
|
||||
m(/^docker\//) ||
|
||||
m(/^\.github\/workflows\//);
|
||||
const riskLabel = docsOnly ? 'risk:low' : (highRisk ? 'risk:high' : 'risk:medium');
|
||||
|
||||
// ---- needs-validation: front/back contract surface ----
|
||||
const contract =
|
||||
m(/^backend\/app\/gateway\//) ||
|
||||
m(/^backend\/packages\/harness\/deerflow\/(agents|subagents)\//) ||
|
||||
m(/(^|\/)langgraph\.json$/) ||
|
||||
m(/^frontend\/src\/core\/(api|threads|messages)\//);
|
||||
|
||||
// ---- live current labels (NOT the stale event payload) ----
|
||||
const current = (await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner, repo, issue_number: num, per_page: 100,
|
||||
})).map(l => l.name);
|
||||
const hasSkip = current.includes('skip-validation');
|
||||
|
||||
// Reconcile ONLY namespaces we own; never touch others.
|
||||
const owned = (n) =>
|
||||
n.startsWith('area:') || n.startsWith('size/') ||
|
||||
n.startsWith('risk:') || n === 'needs-validation';
|
||||
const desired = new Set([...areaLabels, sizeLabel, riskLabel]);
|
||||
if (contract && !hasSkip) desired.add('needs-validation');
|
||||
|
||||
const toRemove = current.filter(n => owned(n) && !desired.has(n));
|
||||
const toAdd = [...desired].filter(n => !current.includes(n));
|
||||
|
||||
// first-time-contributor: add-only, on opened, real users only.
|
||||
if (context.payload.action === 'opened' &&
|
||||
pr.user.type === 'User' &&
|
||||
['FIRST_TIME_CONTRIBUTOR', 'FIRST_TIMER'].includes(pr.author_association) &&
|
||||
!current.includes('first-time-contributor')) {
|
||||
toAdd.push('first-time-contributor');
|
||||
}
|
||||
|
||||
for (const name of toRemove) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: num, name });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
}
|
||||
if (toAdd.length) {
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: num, labels: toAdd });
|
||||
}
|
||||
core.info(`area=[${areaLabels.join(',')}] ${sizeLabel} ${riskLabel} churn=${churn} ` +
|
||||
`validation=${desired.has('needs-validation')} ` +
|
||||
`(+${toAdd.join(',') || '-'} / -${toRemove.join(',') || '-'})`);
|
||||
|
||||
# ── PR: reviewing label on a maintainer's human review ─────────────────────
|
||||
reviewing:
|
||||
if: github.event_name == 'pull_request_review'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: triage-review-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Add reviewing label for maintainer reviews
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const num = context.payload.pull_request.number;
|
||||
const review = context.payload.review;
|
||||
const assoc = review.author_association; // payload field; no API call
|
||||
const type = review.user && review.user.type;
|
||||
|
||||
// author_association is NONE for every automated reviewer
|
||||
// (Copilot, CodeRabbit, Codex, Sourcery, ...), so this allowlist
|
||||
// drops them all without a denylist — and never calls the
|
||||
// collaborators API that 404s on "Copilot is not a user".
|
||||
// user.type === 'User' guards the rare bot-added-as-collaborator case.
|
||||
if (!['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc) || type !== 'User') {
|
||||
core.info(`reviewer ${review.user && review.user.login} assoc=${assoc} type=${type}; skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = (await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner, repo, issue_number: num, per_page: 100,
|
||||
})).map(l => l.name);
|
||||
if (labels.includes('reviewing')) {
|
||||
core.info('Already labeled reviewing; skipping.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: num, labels: ['reviewing'],
|
||||
});
|
||||
core.info('Added "reviewing".');
|
||||
} catch (e) {
|
||||
if (e.status === 403) core.info('No permission to label (expected on some fork PRs).');
|
||||
else throw e;
|
||||
}
|
||||
|
||||
# ── Issue: needs-triage on every new issue ────────────────────────────────
|
||||
issue-triage:
|
||||
if: github.event_name == 'issues'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: triage-issue-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Add needs-triage label
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const issue_number = context.payload.issue.number;
|
||||
|
||||
// Read live labels (not the event payload) so labels added at creation
|
||||
// time via the API or by another automation are seen — consistent with
|
||||
// the live-state reads in the PR jobs above.
|
||||
const current = (await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner, repo, issue_number, per_page: 100,
|
||||
})).map(l => l.name);
|
||||
if (current.includes('needs-triage')) {
|
||||
core.info('Issue already has needs-triage; nothing to do.');
|
||||
return;
|
||||
}
|
||||
// Self-heal: create the label if it does not exist yet.
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner, repo, name: 'needs-triage', color: 'fef2c0',
|
||||
description: 'Awaiting maintainer triage',
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.status !== 422) throw e; // 422 = already exists
|
||||
}
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number, labels: ['needs-triage'],
|
||||
});
|
||||
core.info(`Added needs-triage to #${issue_number}.`);
|
||||
@ -287,6 +287,21 @@ Nginx (port 2026) ← Unified entry point
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
## AI assistance disclosure
|
||||
|
||||
DeerFlow is an AI project and we welcome AI-assisted contributions. To help
|
||||
reviewers calibrate how closely to read a change, **every pull request must
|
||||
complete the "AI assistance" section of the
|
||||
[PR template](.github/pull_request_template.md)**:
|
||||
|
||||
- which tool(s) you used (or `none`),
|
||||
- how you used them, and
|
||||
- a confirmation that a human has read, understands, and takes responsibility
|
||||
for the change.
|
||||
|
||||
Please don't delete the section. PRs that ignore it may be asked to fill it in
|
||||
before review.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
|
||||
42
Makefile
42
Makefile
@ -1,6 +1,6 @@
|
||||
# DeerFlow - Unified Development Environment
|
||||
|
||||
.PHONY: help config config-upgrade check install setup doctor dev dev-daemon start start-daemon stop up down clean docker-init docker-start docker-stop docker-logs docker-logs-frontend docker-logs-gateway
|
||||
.PHONY: help config config-upgrade check install setup doctor detect-thread-boundaries detect-blocking-io dev dev-daemon start start-daemon stop up down clean docker-init docker-start docker-stop docker-logs docker-logs-frontend docker-logs-gateway
|
||||
|
||||
BASH ?= bash
|
||||
BACKEND_UV_RUN = cd backend && uv run
|
||||
@ -23,6 +23,8 @@ help:
|
||||
@echo " make config - Generate local config files (aborts if config already exists)"
|
||||
@echo " make config-upgrade - Merge new fields from config.example.yaml into config.yaml"
|
||||
@echo " make check - Check if all required tools are installed"
|
||||
@echo " make detect-thread-boundaries - Inventory async/thread boundary points"
|
||||
@echo " make detect-blocking-io - Inventory blocking IO that may block the backend event loop"
|
||||
@echo " make install - Install all dependencies (frontend + backend + pre-commit hooks)"
|
||||
@echo " make setup-sandbox - Pre-pull sandbox container image (recommended)"
|
||||
@echo " make dev - Start all services in development mode (with hot-reloading)"
|
||||
@ -51,6 +53,12 @@ setup:
|
||||
doctor:
|
||||
@$(BACKEND_UV_RUN) python ../scripts/doctor.py
|
||||
|
||||
detect-thread-boundaries:
|
||||
@$(PYTHON) ./scripts/detect_thread_boundaries.py
|
||||
|
||||
detect-blocking-io:
|
||||
@$(MAKE) -C backend detect-blocking-io
|
||||
|
||||
config:
|
||||
@$(PYTHON) ./scripts/configure.py
|
||||
|
||||
@ -81,36 +89,7 @@ install:
|
||||
|
||||
# Pre-pull sandbox Docker image (optional but recommended)
|
||||
setup-sandbox:
|
||||
@echo "=========================================="
|
||||
@echo " Pre-pulling Sandbox Container Image"
|
||||
@echo "=========================================="
|
||||
@echo ""
|
||||
@IMAGE=$$(grep -A 20 "# sandbox:" config.yaml 2>/dev/null | grep "image:" | awk '{print $$2}' | head -1); \
|
||||
if [ -z "$$IMAGE" ]; then \
|
||||
IMAGE="enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest"; \
|
||||
echo "Using default image: $$IMAGE"; \
|
||||
else \
|
||||
echo "Using configured image: $$IMAGE"; \
|
||||
fi; \
|
||||
echo ""; \
|
||||
if command -v container >/dev/null 2>&1 && [ "$$(uname)" = "Darwin" ]; then \
|
||||
echo "Detected Apple Container on macOS, pulling image..."; \
|
||||
container image pull "$$IMAGE" || echo "⚠ Apple Container pull failed, will try Docker"; \
|
||||
fi; \
|
||||
if command -v docker >/dev/null 2>&1; then \
|
||||
echo "Pulling image using Docker..."; \
|
||||
if docker pull "$$IMAGE"; then \
|
||||
echo ""; \
|
||||
echo "✓ Sandbox image pulled successfully"; \
|
||||
else \
|
||||
echo ""; \
|
||||
echo "⚠ Failed to pull sandbox image (this is OK for local sandbox mode)"; \
|
||||
fi; \
|
||||
else \
|
||||
echo "✗ Neither Docker nor Apple Container is available"; \
|
||||
echo " Please install Docker: https://docs.docker.com/get-docker/"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@$(RUN_WITH_GIT_BASH) ./scripts/setup-sandbox.sh
|
||||
|
||||
# Start all services in development mode (with hot-reloading)
|
||||
dev:
|
||||
@ -140,7 +119,6 @@ stop:
|
||||
clean: stop
|
||||
@echo "Cleaning up..."
|
||||
@-rm -rf backend/.deer-flow 2>/dev/null || true
|
||||
@-rm -rf backend/.langgraph_api 2>/dev/null || true
|
||||
@-rm -rf logs/*.log 2>/dev/null || true
|
||||
@echo "✓ Cleanup complete"
|
||||
|
||||
|
||||
17
README.md
17
README.md
@ -546,6 +546,15 @@ LANGFUSE_BASE_URL=https://cloud.langfuse.com
|
||||
|
||||
If you are using a self-hosted Langfuse instance, set `LANGFUSE_BASE_URL` to your deployment URL.
|
||||
|
||||
**Trace correlation fields.** Every agent run is annotated with Langfuse's reserved trace attributes so the Sessions and Users pages light up automatically:
|
||||
|
||||
- `session_id` = LangGraph `thread_id` — groups every trace of the same conversation
|
||||
- `user_id` = effective user from `get_effective_user_id()` (falls back to `default` in no-auth mode)
|
||||
- `trace_name` = assistant id (defaults to `lead-agent`)
|
||||
- `tags` = `[env:<DEER_FLOW_ENV>, model:<model_name>]` (omitted when not set)
|
||||
|
||||
These are injected into `RunnableConfig.metadata` at the graph invocation root for both the gateway path (`runtime/runs/worker.py::run_agent`) and the embedded path (`client.py::DeerFlowClient.stream`), so any LangChain-compatible callback can read them. Set `DEER_FLOW_ENV` (or `ENVIRONMENT`) to tag traces by deployment environment.
|
||||
|
||||
#### Using Both Providers
|
||||
|
||||
If both LangSmith and Langfuse are enabled, DeerFlow attaches both tracing callbacks and reports the same model activity to both systems.
|
||||
@ -576,6 +585,8 @@ A standard Agent Skill is a structured capability module — a Markdown file tha
|
||||
|
||||
Skills are loaded progressively — only when the task needs them, not all at once. This keeps the context window lean and makes DeerFlow work well even with token-sensitive models.
|
||||
|
||||
Users can explicitly activate an enabled skill for a single turn by starting the request with `/skill-name`, for example `/data-analysis analyze uploads/foo.csv`. DeerFlow loads that skill's `SKILL.md` as hidden current-turn context while leaving the base prompt limited to skill metadata. Slash activation respects disabled skills, custom-agent skill whitelists, and existing channel commands such as `/new` and `/help`.
|
||||
|
||||
When you install `.skill` archives through the Gateway, DeerFlow accepts standard optional frontmatter metadata such as `version`, `author`, and `compatibility` instead of rejecting otherwise valid external skills.
|
||||
|
||||
Tools follow the same philosophy. DeerFlow comes with a core toolset — web search, web fetch, file operations, bash execution — and supports custom tools via MCP servers and Python functions. Swap anything. Add anything.
|
||||
@ -731,6 +742,12 @@ DeerFlow has key high-privilege capabilities including **system command executio
|
||||
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, workflow, and guidelines.
|
||||
|
||||
Regression coverage includes Docker sandbox mode detection and provisioner kubeconfig-path handling tests in `backend/tests/`.
|
||||
Backend blocking-IO diagnostics are available from the repository root with
|
||||
`make detect-blocking-io`: it statically scans backend business code for
|
||||
blocking IO that may run on the backend event loop, prints a concise summary,
|
||||
and writes complete JSON findings to `.deer-flow/blocking-io-findings.json`.
|
||||
The JSON includes compact review records with `priority`, `location`,
|
||||
`blocking_call`, `event_loop_exposure`, `reason`, and `code`.
|
||||
Gateway artifact serving now forces active web content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) to download as attachments instead of inline rendering, reducing XSS risk for generated artifacts.
|
||||
|
||||
## License
|
||||
|
||||
5
backend/.gitignore
vendored
5
backend/.gitignore
vendored
@ -24,5 +24,10 @@ config.yaml
|
||||
# Langgraph
|
||||
.langgraph_api
|
||||
|
||||
# Sandbox runtime working dir — pre-created and excluded from uvicorn reload
|
||||
# (scripts/serve.sh, docker/dev-entrypoint.sh). Anchored so it does not match
|
||||
# the source package backend/packages/harness/deerflow/sandbox/.
|
||||
/sandbox/
|
||||
|
||||
# Claude Code settings
|
||||
.claude/settings.local.json
|
||||
|
||||
@ -88,18 +88,57 @@ make stop # Stop all services
|
||||
|
||||
**Backend directory** (for backend development only):
|
||||
```bash
|
||||
make install # Install backend dependencies
|
||||
make dev # Run Gateway API with reload (port 8001)
|
||||
make gateway # Run Gateway API only (port 8001)
|
||||
make test # Run all backend tests
|
||||
make lint # Lint with ruff
|
||||
make format # Format code with ruff
|
||||
make install # Install backend dependencies
|
||||
make dev # Run Gateway API with reload (port 8001)
|
||||
make gateway # Run Gateway API only (port 8001)
|
||||
make test # Run all backend tests
|
||||
make test-blocking-io # Run strict Blockbuster runtime gate on tests/blocking_io/
|
||||
make lint # Lint with ruff
|
||||
make format # Format code with ruff
|
||||
```
|
||||
|
||||
The `detect-blocking-io` target parses `app/`, `packages/harness/deerflow/`,
|
||||
and `scripts/` with AST. By default it reports only blocking IO candidates that
|
||||
are inside async code, reachable from async code in the same file, or reachable
|
||||
from sync-only `AgentMiddleware` before/after hooks that LangGraph can execute
|
||||
on the async graph path. It prints a concise summary and writes complete JSON
|
||||
findings to `.deer-flow/blocking-io-findings.json` at the repository root
|
||||
(both `make detect-blocking-io` from the repo root and `cd backend && make
|
||||
detect-blocking-io` resolve to the same repo-root path). JSON findings include
|
||||
`priority`, `location`, `blocking_call`, `event_loop_exposure`, `reason`, and
|
||||
`code` for model-assisted or manual review. `priority` is a deterministic
|
||||
review ordering from operation type, not proof of a bug. Bare-name same-file
|
||||
calls are resolved by function name, so duplicate helper names in one file can
|
||||
conservatively over-report async reachability. It is intentionally
|
||||
informational and is not run from CI in this round.
|
||||
|
||||
Regression tests related to Docker/provisioner behavior:
|
||||
- `tests/test_docker_sandbox_mode_detection.py` (mode detection from `config.yaml`)
|
||||
- `tests/test_provisioner_kubeconfig.py` (kubeconfig file/directory handling)
|
||||
|
||||
Blocking-IO runtime gate (`tests/blocking_io/`):
|
||||
- Wraps every item under `tests/blocking_io/` with a strict Blockbuster
|
||||
context scoped to `app.*` and `deerflow.*` (see
|
||||
`tests/support/detectors/blocking_io_runtime.py`). Any sync blocking IO
|
||||
call whose stack passes through DeerFlow business code while running on
|
||||
the asyncio event loop raises `BlockingError` and fails the test.
|
||||
- Regression anchors live there: `test_skills_load.py` (locks the
|
||||
`asyncio.to_thread` offload around `LocalSkillStorage.load_skills`, fix
|
||||
for #1917); `test_sqlite_lifespan.py` (locks the offload around
|
||||
SQLite path resolution plus `ensure_sqlite_parent_dir`, fix for #1912);
|
||||
`test_jsonl_run_event_store.py` (locks `JsonlRunEventStore`'s async
|
||||
API offloading its file IO via `asyncio.to_thread`, fix #3084); and
|
||||
`test_uploads_middleware.py` (locks `UploadsMiddleware.abefore_agent`
|
||||
offloading the uploads-directory scan off the event loop).
|
||||
- `test_gate_smoke.py` is a meta-test asserting the gate actually catches
|
||||
unoffloaded blocking IO and that the `@pytest.mark.allow_blocking_io`
|
||||
opt-out works.
|
||||
- Coverage boundary: the gate only sees code that test execution actually
|
||||
touches. Static AST coverage is a separate concern (out of scope for
|
||||
this PR).
|
||||
- CI: runs on every PR via `.github/workflows/backend-blocking-io-tests.yml`,
|
||||
hard-fail.
|
||||
|
||||
Boundary check (harness → app import firewall):
|
||||
- `tests/test_harness_boundary.py` — ensures `packages/harness/deerflow/` never imports from `app.*`
|
||||
|
||||
@ -153,7 +192,7 @@ from deerflow.config import get_app_config
|
||||
|
||||
### Middleware Chain
|
||||
|
||||
Lead-agent middlewares are assembled in strict append order across `packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py` (`build_lead_runtime_middlewares`) and `packages/harness/deerflow/agents/lead_agent/agent.py` (`_build_middlewares`):
|
||||
Lead-agent middlewares are assembled in strict append order across `packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py` (`build_lead_runtime_middlewares`) and `packages/harness/deerflow/agents/lead_agent/agent.py` (`build_middlewares`):
|
||||
|
||||
1. **ThreadDataMiddleware** - Creates per-thread directories under the user's isolation scope (`backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/{workspace,uploads,outputs}`); resolves `user_id` via `get_effective_user_id()` (falls back to `"default"` in no-auth mode); Web UI thread deletion now follows LangGraph thread removal with Gateway cleanup of the local thread directory
|
||||
2. **UploadsMiddleware** - Tracks and injects newly uploaded files into conversation
|
||||
@ -163,16 +202,17 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
|
||||
6. **GuardrailMiddleware** - Pre-tool-call authorization via pluggable `GuardrailProvider` protocol (optional, if `guardrails.enabled` in config). Evaluates each tool call and returns error ToolMessage on deny. Three provider options: built-in `AllowlistProvider` (zero deps), OAP policy providers (e.g. `aport-agent-guardrails`), or custom providers. See [docs/GUARDRAILS.md](docs/GUARDRAILS.md) for setup, usage, and how to implement a provider.
|
||||
7. **SandboxAuditMiddleware** - Audits sandboxed shell/file operations for security logging before tool execution continues
|
||||
8. **ToolErrorHandlingMiddleware** - Converts tool exceptions into error `ToolMessage`s so the run can continue instead of aborting
|
||||
9. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
|
||||
10. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
||||
11. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional); subagent usage is cached by `tool_call_id` only while token usage is enabled and merged back into the dispatching AIMessage by message position rather than message id
|
||||
12. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model
|
||||
13. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
|
||||
14. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
|
||||
15. **DeferredToolFilterMiddleware** - Hides deferred tool schemas from the bound model until tool search is enabled (optional)
|
||||
16. **SubagentLimitMiddleware** - Truncates excess `task` tool calls from model response to enforce `MAX_CONCURRENT_SUBAGENTS` limit (optional, if `subagent_enabled`)
|
||||
17. **LoopDetectionMiddleware** - Detects repeated tool-call loops; hard-stop responses clear both structured `tool_calls` and raw provider tool-call metadata before forcing a final text answer
|
||||
18. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
|
||||
9. **SkillActivationMiddleware** - Detects strict `/skill-name task` syntax on the latest real user message, resolves only enabled and runtime-allowed skills, reads `SKILL.md` from trusted skill storage, injects the skill body as hidden current-turn model context, and records a `middleware:skill_activation` audit event with skill name, category, path, and content hash
|
||||
10. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
|
||||
11. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
||||
12. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional); subagent usage is cached by `tool_call_id` only while token usage is enabled and merged back into the dispatching AIMessage by message position rather than message id
|
||||
13. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model
|
||||
14. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
|
||||
15. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
|
||||
16. **DeferredToolFilterMiddleware** - Hides deferred (MCP) tool schemas from the bound model using a build-time deferred-name set + catalog hash, reading per-thread promotions from `ThreadState.promoted` (hash-scoped, no ContextVar); a tool becomes bound on subsequent turns after `tool_search` returns its schema (optional, if `tool_search.enabled`)
|
||||
17. **SubagentLimitMiddleware** - Truncates excess `task` tool calls from model response to enforce `MAX_CONCURRENT_SUBAGENTS` limit (optional, if `subagent_enabled`)
|
||||
18. **LoopDetectionMiddleware** - Detects repeated tool-call loops; hard-stop responses clear both structured `tool_calls` and raw provider tool-call metadata before forcing a final text answer
|
||||
19. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
|
||||
|
||||
### Configuration System
|
||||
|
||||
@ -184,6 +224,10 @@ Setup: Copy `config.example.yaml` to `config.yaml` in the **project root** direc
|
||||
|
||||
**Config Caching**: `get_app_config()` caches the parsed config, but automatically reloads it when the resolved config path changes or the file's mtime increases. This keeps Gateway and LangGraph reads aligned with `config.yaml` edits without requiring a manual process restart.
|
||||
|
||||
**Config Hot-Reload Boundary**: Gateway dependencies route through `get_app_config()` on every request, so per-run fields like `models[*].max_tokens`, `summarization.*`, `title.*`, `memory.*`, `subagents.*`, `tools[*]`, and the agent system prompt pick up `config.yaml` edits on the next message. `AppConfig` is intentionally **not** cached on `app.state` — `lifespan()` keeps a local `startup_config` variable for one-shot bootstrap work and passes it to `langgraph_runtime(app, startup_config)`.
|
||||
|
||||
Infrastructure fields are **restart-required**. The authoritative list lives in `packages/harness/deerflow/config/reload_boundary.py::STARTUP_ONLY_FIELDS` and is mirrored by the standardised `"startup-only:"` prefix on the corresponding `Field(description=...)` in `AppConfig`, so IDE hover on those fields surfaces the reason inline (no need to context-switch into this table). Currently registered: `database`, `checkpointer`, `run_events`, `stream_bridge`, `sandbox`, `log_level`, `channels`. Adding a new restart-required field requires updating the registry; drift is pinned by `tests/test_reload_boundary.py`.
|
||||
|
||||
Configuration priority:
|
||||
1. Explicit `config_path` argument
|
||||
2. `DEER_FLOW_CONFIG_PATH` environment variable
|
||||
@ -220,7 +264,7 @@ CORS is same-origin by default when requests enter through nginx on port 2026. S
|
||||
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
|
||||
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
|
||||
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types |
|
||||
| **Suggestions** (`/api/threads/{id}/suggestions`) | `POST /` - generate follow-up questions; rich list/block model content is normalized before JSON parsing |
|
||||
| **Suggestions** (`/api/threads/{id}/suggestions`) | `POST /` - generate follow-up questions; rich list/block model content is normalized and inline reasoning (`<think>...</think>`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing |
|
||||
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /../messages` - thread messages with feedback; `GET /../token-usage` - aggregate tokens |
|
||||
| **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific |
|
||||
| **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |
|
||||
@ -230,13 +274,14 @@ CORS is same-origin by default when requests enter through nginx on port 2026. S
|
||||
- When a persistent `RunStore` is configured, `get()` and `list_by_thread()` hydrate historical runs from the store. In-memory records win for the same `run_id` so task, abort, and stream-control state stays attached to active local runs.
|
||||
- `cancel()` and `create_or_reject(..., multitask_strategy="interrupt"|"rollback")` persist interrupted status through `RunStore.update_status()`, matching normal `set_status()` transitions.
|
||||
- Store-only hydrated runs are readable history. If the current worker has no in-memory task/control state for that run, cancellation APIs can return 409 because this worker cannot stop the task.
|
||||
- `POST /wait` (both thread-scoped and `/api/runs/wait`) drains the stream bridge via `wait_for_run_completion()` instead of bare `await record.task`, so it honours the run's `on_disconnect` setting and cancels the background run on real client disconnect rather than returning a stale checkpoint (issue #3265).
|
||||
|
||||
Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runtime, all other `/api/*` → Gateway REST APIs.
|
||||
|
||||
### Sandbox System (`packages/harness/deerflow/sandbox/`)
|
||||
|
||||
**Interface**: Abstract `Sandbox` with `execute_command`, `read_file`, `write_file`, `list_dir`
|
||||
**Provider Pattern**: `SandboxProvider` with `acquire`, `get`, `release` lifecycle
|
||||
**Provider Pattern**: `SandboxProvider` with `acquire`, `acquire_async`, `get`, `release` lifecycle. Async agent/tool paths call async sandbox lifecycle hooks so Docker sandbox creation, discovery, cross-process locking, readiness polling, and release stay off the event loop.
|
||||
**Implementations**:
|
||||
- `LocalSandboxProvider` - Local filesystem execution. `acquire(thread_id)` returns a per-thread `LocalSandbox` (id `local:{thread_id}`) whose `path_mappings` resolve `/mnt/user-data/{workspace,uploads,outputs}` and `/mnt/acp-workspace` to that thread's host directories, so the public `Sandbox` API honours the `/mnt/user-data` contract uniformly with AIO. `acquire()` / `acquire(None)` keeps the legacy generic singleton (id `local`) for callers without a thread context. Per-thread sandboxes are held in an LRU cache (default 256 entries) guarded by a `threading.Lock`.
|
||||
- `AioSandboxProvider` (`packages/harness/deerflow/community/`) - Docker-based isolation
|
||||
@ -261,6 +306,7 @@ Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runti
|
||||
**Concurrency**: `MAX_CONCURRENT_SUBAGENTS = 3` enforced by `SubagentLimitMiddleware` (truncates excess tool calls in `after_model`), 15-minute timeout
|
||||
**Flow**: `task()` tool → `SubagentExecutor` → background thread → poll 5s → SSE events → result
|
||||
**Events**: `task_started`, `task_running`, `task_completed`/`task_failed`/`task_timed_out`
|
||||
**Deferred MCP tools** (if `tool_search.enabled`): `SubagentExecutor._build_initial_state` assembles deferral after policy filtering via the shared `assemble_deferred_tools` (fail-closed), appends the `tool_search` tool, injects the `<available-deferred-tools>` section into the subagent's `SystemMessage`, and threads the setup to `_create_agent`, which attaches `DeferredToolFilterMiddleware` through `build_subagent_runtime_middlewares(deferred_setup=...)`. Subagents thus withhold full MCP schemas until promotion, same as the lead agent; each task run gets a fresh `ThreadState` so promotion is isolated per run
|
||||
|
||||
### Tool System (`packages/harness/deerflow/tools/`)
|
||||
|
||||
@ -295,7 +341,7 @@ Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runti
|
||||
- **Cache invalidation**: Detects config file changes via mtime comparison
|
||||
- **Transports**: stdio (command-based), SSE, HTTP
|
||||
- **OAuth (HTTP/SSE)**: Supports token endpoint flows (`client_credentials`, `refresh_token`) with automatic token refresh + Authorization header injection
|
||||
- **Runtime updates**: Gateway API saves to extensions_config.json; LangGraph detects via mtime
|
||||
- **Runtime updates**: Gateway API saves to extensions_config.json; the Gateway-embedded runtime detects changes via mtime
|
||||
|
||||
### Skills System (`packages/harness/deerflow/skills/`)
|
||||
|
||||
@ -303,6 +349,7 @@ Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runti
|
||||
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools)
|
||||
- **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json
|
||||
- **Injection**: Enabled skills listed in agent system prompt with container paths
|
||||
- **Slash activation**: `/skill-name task` loads that enabled skill's `SKILL.md` for the current model call only. The resolver rejects leading whitespace, missing separators, reserved channel commands (`/new`, `/help`, `/bootstrap`, `/status`, `/models`, `/memory`), disabled skills, and skills outside a custom agent's whitelist.
|
||||
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
|
||||
|
||||
### Model Factory (`packages/harness/deerflow/models/factory.py`)
|
||||
@ -322,7 +369,7 @@ Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runti
|
||||
|
||||
### IM Channels System (`app/channels/`)
|
||||
|
||||
Bridges external messaging platforms (Feishu, Slack, Telegram, DingTalk) to the DeerFlow agent via the LangGraph Server.
|
||||
Bridges external messaging platforms (Feishu, Slack, Telegram, DingTalk) to the DeerFlow agent via Gateway's LangGraph-compatible API.
|
||||
|
||||
|
||||
**Architecture**: Channels communicate with Gateway through the `langgraph-sdk` HTTP client (same as the frontend), ensuring threads are created and managed server-side. The internal SDK client injects process-local internal auth plus a matching CSRF cookie/header pair so Gateway accepts state-changing thread/run requests from channel workers without relying on browser session cookies.
|
||||
@ -397,6 +444,24 @@ Focused regression coverage for the updater lives in `backend/tests/test_memory_
|
||||
- `resolve_variable(path)` - Import module and return variable (e.g., `module.path:variable_name`)
|
||||
- `resolve_class(path, base_class)` - Import and validate class against base class
|
||||
|
||||
### Tracing System (`packages/harness/deerflow/tracing/`)
|
||||
|
||||
LangSmith and Langfuse are both supported. The wiring lives in two layers:
|
||||
|
||||
- `factory.py::build_tracing_callbacks()` — returns the LangChain `CallbackHandler` list for the providers currently enabled via env vars (`LANGSMITH_TRACING`, `LANGFUSE_TRACING`, etc.). The handlers are attached at the **graph invocation root** for in-graph runs (`make_lead_agent` and `DeerFlowClient.stream` both append them to `config["callbacks"]` before invoking the graph) so a single run produces one trace with all node / LLM / tool calls as child spans. Standalone callers — anything that invokes a model outside such a graph (e.g. `MemoryUpdater`) — keep `create_chat_model`'s default `attach_tracing=True`, which falls back to model-level callback attachment.
|
||||
- `metadata.py::build_langfuse_trace_metadata()` — builds the Langfuse-reserved trace attributes for `RunnableConfig.metadata`. The Langfuse v4 `langchain.CallbackHandler` lifts these onto the root trace (see its `_parse_langfuse_trace_attributes`), but only when it sees `on_chain_start(parent_run_id=None)` — which is why the callbacks have to live at the graph root, not the model.
|
||||
|
||||
**Trace-attribute injection points**: both `runtime/runs/worker.py::run_agent` (gateway path) and `client.py::DeerFlowClient.stream` (embedded path) merge the metadata into `config["metadata"]` right before constructing the graph. Caller-supplied keys win via `setdefault`, so an external `session_id` override is preserved. Field mapping:
|
||||
|
||||
| Langfuse field | Source |
|
||||
|-----------------------|----------------------------------------------|
|
||||
| `langfuse_session_id` | LangGraph `thread_id` |
|
||||
| `langfuse_user_id` | `get_effective_user_id()` (`default` in no-auth) |
|
||||
| `langfuse_trace_name` | `RunRecord.assistant_id` / client `agent_name` (defaults to `lead-agent`) |
|
||||
| `langfuse_tags` | `env:<DEER_FLOW_ENV>` + `model:<model_name>` |
|
||||
|
||||
Returns `{}` when Langfuse is not in the enabled providers — LangSmith-only deployments are unaffected. Set `DEER_FLOW_ENV` (or `ENVIRONMENT`) to tag traces by deployment environment. Tests live in `tests/test_tracing_factory.py`, `tests/test_tracing_metadata.py`, `tests/test_worker_langfuse_metadata.py`, and `tests/test_client_langfuse_metadata.py`.
|
||||
|
||||
### Config Schema
|
||||
|
||||
**`config.yaml`** key sections:
|
||||
@ -430,7 +495,7 @@ Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` me
|
||||
- `"messages-tuple"` — per-chunk update: for AI text this is a **delta** (concat per `id` to rebuild the full message); tool calls and tool results are emitted once each
|
||||
- `"custom"` — forwarded from `StreamWriter`
|
||||
- `"end"` — stream finished (carries cumulative `usage` counted once per message id)
|
||||
- Agent created lazily via `create_agent()` + `_build_middlewares()`, same as `make_lead_agent`
|
||||
- Agent created lazily via `create_agent()` + `build_middlewares()`, same as `make_lead_agent`
|
||||
- Supports `checkpointer` parameter for state persistence across turns
|
||||
- `reset_agent()` forces agent recreation (e.g. after memory or skill changes)
|
||||
- See [docs/STREAMING.md](docs/STREAMING.md) for the full design: why Gateway and DeerFlowClient are parallel paths, LangGraph's `stream_mode` semantics, the per-id dedup invariants, and regression testing strategy
|
||||
|
||||
@ -64,7 +64,7 @@ FROM builder AS dev
|
||||
# Install Docker CLI (for DooD: allows starting sandbox containers via host Docker socket)
|
||||
COPY --from=docker:cli /usr/local/bin/docker /usr/local/bin/docker
|
||||
|
||||
EXPOSE 8001 2024
|
||||
EXPOSE 8001
|
||||
|
||||
CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
|
||||
|
||||
@ -94,8 +94,8 @@ WORKDIR /app
|
||||
# Copy backend with pre-built virtualenv from builder
|
||||
COPY --from=builder /app/backend ./backend
|
||||
|
||||
# Expose ports (gateway: 8001, langgraph: 2024)
|
||||
EXPOSE 8001 2024
|
||||
# Expose Gateway API port.
|
||||
EXPOSE 8001
|
||||
|
||||
# Default command (can be overridden in docker-compose)
|
||||
CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
|
||||
|
||||
@ -2,13 +2,16 @@ install:
|
||||
uv sync
|
||||
|
||||
dev:
|
||||
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload
|
||||
PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload
|
||||
|
||||
gateway:
|
||||
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001
|
||||
PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001
|
||||
|
||||
test:
|
||||
PYTHONPATH=. uv run pytest tests/ -v
|
||||
PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run pytest tests/ -v
|
||||
|
||||
test-blocking-io:
|
||||
PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run pytest tests/blocking_io -q --tb=short
|
||||
|
||||
lint:
|
||||
uvx ruff check .
|
||||
@ -16,3 +19,6 @@ lint:
|
||||
|
||||
format:
|
||||
uvx ruff check . --fix && uvx ruff format .
|
||||
|
||||
detect-blocking-io:
|
||||
@PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run python ../scripts/detect_blocking_io_static.py --output ../.deer-flow/blocking-io-findings.json
|
||||
|
||||
@ -69,7 +69,7 @@ Middlewares execute in strict order, each handling a specific concern:
|
||||
Per-thread isolated execution with virtual path translation:
|
||||
|
||||
- **Abstract interface**: `execute_command`, `read_file`, `write_file`, `list_dir`
|
||||
- **Providers**: `LocalSandboxProvider` (filesystem) and `AioSandboxProvider` (Docker, in community/)
|
||||
- **Providers**: `LocalSandboxProvider` (filesystem) and `AioSandboxProvider` (Docker, in community/). Async runtime paths use async sandbox lifecycle hooks so startup, readiness polling, and release do not block the event loop.
|
||||
- **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories
|
||||
- **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory
|
||||
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
|
||||
@ -362,6 +362,7 @@ make dev # Run Gateway API + embedded agent runtime (port 8001)
|
||||
make gateway # Run Gateway API without reload (port 8001)
|
||||
make lint # Run linter (ruff)
|
||||
make format # Format code (ruff)
|
||||
make detect-blocking-io # Inventory blocking IO that may block the backend event loop
|
||||
```
|
||||
|
||||
### Code Style
|
||||
@ -378,6 +379,18 @@ make format # Format code (ruff)
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
`make detect-blocking-io` statically scans backend business code for blocking
|
||||
IO that may run on the backend event loop and is not test-coverage-bound. It
|
||||
prints a concise summary for human review and writes complete JSON findings to
|
||||
`.deer-flow/blocking-io-findings.json` at the repository root (regardless of
|
||||
whether the target is invoked from the repo root or from `backend/`). JSON
|
||||
findings include both broad IO category and review-oriented fields such as
|
||||
`priority`, `location`, `blocking_call`, `event_loop_exposure`, `reason`, and
|
||||
`code`. `priority` is a deterministic review ordering from the operation type,
|
||||
not proof of a bug. Bare-name same-file calls are resolved by function name,
|
||||
so duplicate helper names in one file can conservatively over-report async
|
||||
reachability.
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack
|
||||
|
||||
@ -18,3 +18,10 @@ KNOWN_CHANNEL_COMMANDS: frozenset[str] = frozenset(
|
||||
"/help",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_known_channel_command(text: str) -> bool:
|
||||
"""Return whether text starts with a registered channel control command."""
|
||||
if not text.startswith("/"):
|
||||
return False
|
||||
return text.split(maxsplit=1)[0].lower() in KNOWN_CHANNEL_COMMANDS
|
||||
|
||||
@ -14,7 +14,7 @@ from typing import Any
|
||||
import httpx
|
||||
|
||||
from app.channels.base import Channel
|
||||
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
|
||||
from app.channels.commands import is_known_channel_command
|
||||
from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -59,9 +59,7 @@ def _normalize_allowed_users(allowed_users: Any) -> set[str]:
|
||||
|
||||
|
||||
def _is_dingtalk_command(text: str) -> bool:
|
||||
if not text.startswith("/"):
|
||||
return False
|
||||
return text.split(maxsplit=1)[0].lower() in KNOWN_CHANNEL_COMMANDS
|
||||
return is_known_channel_command(text)
|
||||
|
||||
|
||||
def _extract_text_from_rich_text(rich_text_list: list) -> str:
|
||||
|
||||
@ -10,6 +10,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.channels.base import Channel
|
||||
from app.channels.commands import is_known_channel_command
|
||||
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -300,7 +301,7 @@ class DiscordChannel(Channel):
|
||||
|
||||
# If this is a known active thread, process normally
|
||||
if thread_id in self._active_thread_ids:
|
||||
msg_type = InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT
|
||||
msg_type = InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT
|
||||
inbound = self._make_inbound(
|
||||
chat_id=chat_id,
|
||||
user_id=str(message.author.id),
|
||||
@ -407,7 +408,7 @@ class DiscordChannel(Channel):
|
||||
chat_id = channel_id
|
||||
typing_target = message.channel # Type into the channel
|
||||
|
||||
msg_type = InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT
|
||||
msg_type = InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT
|
||||
inbound = self._make_inbound(
|
||||
chat_id=chat_id,
|
||||
user_id=str(message.author.id),
|
||||
|
||||
@ -7,22 +7,30 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Literal
|
||||
|
||||
from app.channels.base import Channel
|
||||
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
|
||||
from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
|
||||
from app.channels.commands import is_known_channel_command
|
||||
from app.channels.message_bus import (
|
||||
PENDING_CLARIFICATION_METADATA_KEY,
|
||||
RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY,
|
||||
InboundMessage,
|
||||
InboundMessageType,
|
||||
MessageBus,
|
||||
OutboundMessage,
|
||||
ResolvedAttachment,
|
||||
)
|
||||
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
PENDING_CLARIFICATION_TTL_SECONDS = 30 * 60
|
||||
|
||||
|
||||
def _is_feishu_command(text: str) -> bool:
|
||||
if not text.startswith("/"):
|
||||
return False
|
||||
return text.split(maxsplit=1)[0].lower() in KNOWN_CHANNEL_COMMANDS
|
||||
return is_known_channel_command(text)
|
||||
|
||||
|
||||
class FeishuChannel(Channel):
|
||||
@ -56,6 +64,7 @@ class FeishuChannel(Channel):
|
||||
self._background_tasks: set[asyncio.Task] = set()
|
||||
self._running_card_ids: dict[str, str] = {}
|
||||
self._running_card_tasks: dict[str, asyncio.Task] = {}
|
||||
self._pending_clarifications: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||||
self._CreateFileRequest = None
|
||||
self._CreateFileRequestBody = None
|
||||
self._CreateImageRequest = None
|
||||
@ -63,6 +72,16 @@ class FeishuChannel(Channel):
|
||||
self._GetMessageResourceRequest = None
|
||||
self._thread_lock = threading.Lock()
|
||||
|
||||
@staticmethod
|
||||
def _non_empty_str(value: Any) -> str | None:
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _pending_key(chat_id: str, user_id: str) -> tuple[str, str]:
|
||||
return (chat_id, user_id)
|
||||
|
||||
@property
|
||||
def supports_streaming(self) -> bool:
|
||||
return True
|
||||
@ -531,18 +550,25 @@ class FeishuChannel(Channel):
|
||||
"[Feishu] failed to patch running card %s, falling back to final reply",
|
||||
running_card_id,
|
||||
)
|
||||
await self._reply_card(source_message_id, msg.text)
|
||||
fallback_card_id = await self._reply_card(source_message_id, msg.text)
|
||||
self._remember_thread_mapping(msg, source_message_id, fallback_card_id)
|
||||
self._remember_pending_clarification(msg, fallback_card_id)
|
||||
else:
|
||||
self._remember_thread_mapping(msg, source_message_id, running_card_id)
|
||||
self._remember_pending_clarification(msg, running_card_id)
|
||||
logger.info("[Feishu] running card updated: source=%s card=%s", source_message_id, running_card_id)
|
||||
elif msg.is_final:
|
||||
await self._reply_card(source_message_id, msg.text)
|
||||
final_card_id = await self._reply_card(source_message_id, msg.text)
|
||||
self._remember_thread_mapping(msg, source_message_id, final_card_id)
|
||||
self._remember_pending_clarification(msg, final_card_id)
|
||||
elif awaited_running_card_task:
|
||||
logger.warning(
|
||||
"[Feishu] running card task finished without message_id for source=%s, skipping duplicate non-final creation",
|
||||
source_message_id,
|
||||
)
|
||||
else:
|
||||
await self._ensure_running_card(source_message_id, msg.text)
|
||||
created_card_id = await self._ensure_running_card(source_message_id, msg.text)
|
||||
self._remember_thread_mapping(msg, source_message_id, created_card_id)
|
||||
|
||||
if msg.is_final:
|
||||
self._running_card_ids.pop(source_message_id, None)
|
||||
@ -553,6 +579,129 @@ class FeishuChannel(Channel):
|
||||
|
||||
# -- internal ----------------------------------------------------------
|
||||
|
||||
def _remember_thread_mapping(self, msg: OutboundMessage, *topic_ids: str | None) -> None:
|
||||
store = self.config.get("channel_store")
|
||||
if store is None or not msg.thread_id:
|
||||
return
|
||||
|
||||
metadata_topic_ids = [
|
||||
msg.metadata.get("message_id"),
|
||||
msg.metadata.get("root_id"),
|
||||
msg.metadata.get("parent_id"),
|
||||
msg.metadata.get("thread_id"),
|
||||
msg.metadata.get("topic_id"),
|
||||
]
|
||||
user_id = ""
|
||||
raw_user_id = msg.metadata.get("user_id")
|
||||
if isinstance(raw_user_id, str):
|
||||
user_id = raw_user_id
|
||||
|
||||
seen: set[str] = set()
|
||||
for topic_id in [*topic_ids, *metadata_topic_ids]:
|
||||
topic_id = self._non_empty_str(topic_id)
|
||||
if not topic_id or topic_id in seen:
|
||||
continue
|
||||
seen.add(topic_id)
|
||||
try:
|
||||
store.set_thread_id(
|
||||
self.name,
|
||||
msg.chat_id,
|
||||
msg.thread_id,
|
||||
topic_id=topic_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("[Feishu] failed to remember thread mapping for topic_id=%s", topic_id)
|
||||
|
||||
def _remember_pending_clarification(self, msg: OutboundMessage, card_message_id: str | None) -> None:
|
||||
if not msg.is_final or msg.metadata.get(PENDING_CLARIFICATION_METADATA_KEY) is not True:
|
||||
return
|
||||
|
||||
user_id = self._non_empty_str(msg.metadata.get("user_id"))
|
||||
topic_id = self._non_empty_str(msg.metadata.get("topic_id"))
|
||||
source_message_id = self._non_empty_str(msg.thread_ts) or self._non_empty_str(msg.metadata.get("message_id"))
|
||||
if not (user_id and topic_id and msg.thread_id and source_message_id and card_message_id):
|
||||
return
|
||||
|
||||
key = self._pending_key(msg.chat_id, user_id)
|
||||
pending = {
|
||||
"thread_id": msg.thread_id,
|
||||
"topic_id": topic_id,
|
||||
"source_message_id": source_message_id,
|
||||
"card_message_id": card_message_id,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
with self._thread_lock:
|
||||
# Plain-message clarification continuity is a short-lived in-memory
|
||||
# hint; explicit Feishu replies are still covered by persisted
|
||||
# message-id mappings.
|
||||
self._pending_clarifications.setdefault(key, []).append(pending)
|
||||
logger.info(
|
||||
"[Feishu] pending clarification remembered: chat_id=%s user_id=%s topic_id=%s thread_id=%s",
|
||||
msg.chat_id,
|
||||
user_id,
|
||||
topic_id,
|
||||
msg.thread_id,
|
||||
)
|
||||
|
||||
def _consume_pending_clarification(self, chat_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
key = self._pending_key(chat_id, user_id)
|
||||
with self._thread_lock:
|
||||
pending_items = self._pending_clarifications.get(key)
|
||||
if not pending_items:
|
||||
return None
|
||||
|
||||
now = time.time()
|
||||
while pending_items:
|
||||
pending = pending_items.pop(0)
|
||||
created_at = pending.get("created_at")
|
||||
if isinstance(created_at, (int, float)) and now - created_at <= PENDING_CLARIFICATION_TTL_SECONDS:
|
||||
if pending_items:
|
||||
self._pending_clarifications[key] = pending_items
|
||||
else:
|
||||
self._pending_clarifications.pop(key, None)
|
||||
return pending
|
||||
logger.info("[Feishu] pending clarification expired: chat_id=%s user_id=%s", chat_id, user_id)
|
||||
|
||||
self._pending_clarifications.pop(key, None)
|
||||
return None
|
||||
|
||||
def _ensure_pending_thread_mapping(self, chat_id: str, user_id: str, pending: dict[str, Any]) -> None:
|
||||
store = self.config.get("channel_store")
|
||||
topic_id = self._non_empty_str(pending.get("topic_id"))
|
||||
thread_id = self._non_empty_str(pending.get("thread_id"))
|
||||
if store is None or not topic_id or not thread_id:
|
||||
return
|
||||
try:
|
||||
store.set_thread_id(self.name, chat_id, thread_id, topic_id=topic_id, user_id=user_id)
|
||||
except Exception:
|
||||
logger.exception("[Feishu] failed to restore pending clarification mapping for topic_id=%s", topic_id)
|
||||
|
||||
def _resolve_topic_id(
|
||||
self,
|
||||
chat_id: str,
|
||||
msg_id: str,
|
||||
*,
|
||||
root_id: str | None,
|
||||
parent_id: str | None,
|
||||
thread_id: str | None,
|
||||
) -> tuple[str, bool]:
|
||||
store = self.config.get("channel_store")
|
||||
candidates = [root_id, parent_id, thread_id]
|
||||
|
||||
if store is not None:
|
||||
for candidate in candidates:
|
||||
candidate = self._non_empty_str(candidate)
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
if store.get_thread_id(self.name, chat_id, topic_id=candidate):
|
||||
return candidate, True
|
||||
except Exception:
|
||||
logger.exception("[Feishu] failed to resolve stored topic mapping for topic_id=%s", candidate)
|
||||
|
||||
return root_id or msg_id, False
|
||||
|
||||
@staticmethod
|
||||
def _log_future_error(fut, name: str, msg_id: str) -> None:
|
||||
"""Callback for run_coroutine_threadsafe futures to surface errors."""
|
||||
@ -593,7 +742,9 @@ class FeishuChannel(Channel):
|
||||
|
||||
# root_id is set when the message is a reply within a Feishu thread.
|
||||
# Use it as topic_id so all replies share the same DeerFlow thread.
|
||||
root_id = getattr(message, "root_id", None) or None
|
||||
root_id = self._non_empty_str(getattr(message, "root_id", None))
|
||||
parent_id = self._non_empty_str(getattr(message, "parent_id", None))
|
||||
feishu_thread_id = self._non_empty_str(getattr(message, "thread_id", None))
|
||||
|
||||
# Parse message content
|
||||
content = json.loads(message.content)
|
||||
@ -654,10 +805,12 @@ class FeishuChannel(Channel):
|
||||
text = text.strip()
|
||||
|
||||
logger.info(
|
||||
"[Feishu] parsed message: chat_id=%s, msg_id=%s, root_id=%s, sender=%s, text=%r",
|
||||
"[Feishu] parsed message: chat_id=%s, msg_id=%s, root_id=%s, parent_id=%s, thread_id=%s, sender=%s, text=%r",
|
||||
chat_id,
|
||||
msg_id,
|
||||
root_id,
|
||||
parent_id,
|
||||
feishu_thread_id,
|
||||
sender_id,
|
||||
text[:100] if text else "",
|
||||
)
|
||||
@ -673,8 +826,24 @@ class FeishuChannel(Channel):
|
||||
else:
|
||||
msg_type = InboundMessageType.CHAT
|
||||
|
||||
# topic_id: use root_id for replies (same topic), msg_id for new messages (new topic)
|
||||
topic_id = root_id or msg_id
|
||||
# Prefer any platform message id that already maps to a DeerFlow
|
||||
# thread. This keeps replies to bot clarification cards in the
|
||||
# original conversation even when Feishu reports the card as root.
|
||||
topic_id, resolved_from_stored_mapping = self._resolve_topic_id(
|
||||
chat_id,
|
||||
msg_id,
|
||||
root_id=root_id,
|
||||
parent_id=parent_id,
|
||||
thread_id=feishu_thread_id,
|
||||
)
|
||||
resolved_from_pending = False
|
||||
if msg_type == InboundMessageType.CHAT and not resolved_from_stored_mapping:
|
||||
pending = self._consume_pending_clarification(chat_id, sender_id)
|
||||
pending_topic_id = self._non_empty_str(pending.get("topic_id")) if pending else None
|
||||
if pending_topic_id:
|
||||
topic_id = pending_topic_id
|
||||
self._ensure_pending_thread_mapping(chat_id, sender_id, pending)
|
||||
resolved_from_pending = True
|
||||
|
||||
inbound = self._make_inbound(
|
||||
chat_id=chat_id,
|
||||
@ -683,7 +852,15 @@ class FeishuChannel(Channel):
|
||||
msg_type=msg_type,
|
||||
thread_ts=msg_id,
|
||||
files=files_list,
|
||||
metadata={"message_id": msg_id, "root_id": root_id},
|
||||
metadata={
|
||||
"message_id": msg_id,
|
||||
"root_id": root_id,
|
||||
"parent_id": parent_id,
|
||||
"thread_id": feishu_thread_id,
|
||||
"topic_id": topic_id,
|
||||
"user_id": sender_id,
|
||||
RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY: resolved_from_pending,
|
||||
},
|
||||
)
|
||||
inbound.topic_id = topic_id
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import mimetypes
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@ -15,11 +16,24 @@ import httpx
|
||||
from langgraph_sdk.errors import ConflictError
|
||||
|
||||
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
|
||||
from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
|
||||
from app.channels.message_bus import (
|
||||
PENDING_CLARIFICATION_METADATA_KEY,
|
||||
InboundMessage,
|
||||
InboundMessageType,
|
||||
MessageBus,
|
||||
OutboundMessage,
|
||||
ResolvedAttachment,
|
||||
)
|
||||
from app.channels.store import ChannelStore
|
||||
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
|
||||
from app.gateway.internal_auth import create_internal_auth_headers
|
||||
from deerflow.config.agents_config import load_agent_config
|
||||
from deerflow.config.paths import make_safe_user_id
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
from deerflow.skills.slash import parse_slash_skill_reference
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
from deerflow.skills.storage.skill_storage import SkillStorage
|
||||
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -116,6 +130,16 @@ class InvalidChannelSessionConfigError(ValueError):
|
||||
"""Raised when IM channel session overrides contain invalid agent config."""
|
||||
|
||||
|
||||
class SlashSkillCommandResolutionError(RuntimeError):
|
||||
"""Raised when IM slash-skill command resolution cannot complete safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SlashSkillCommandResolution:
|
||||
route_to_chat: bool = False
|
||||
failure_message: str | None = None
|
||||
|
||||
|
||||
def _is_thread_busy_error(exc: BaseException | None) -> bool:
|
||||
if exc is None:
|
||||
return False
|
||||
@ -146,13 +170,6 @@ def _normalize_custom_agent_name(raw_value: str) -> str:
|
||||
return normalized
|
||||
|
||||
|
||||
def _strip_loop_warning_text(text: str) -> str:
|
||||
"""Remove middleware-authored loop warning lines from display text."""
|
||||
if "[LOOP DETECTED]" not in text:
|
||||
return text
|
||||
return "\n".join(line for line in text.splitlines() if "[LOOP DETECTED]" not in line).strip()
|
||||
|
||||
|
||||
def _extract_response_text(result: dict | list) -> str:
|
||||
"""Extract the last AI message text from a LangGraph runs.wait result.
|
||||
|
||||
@ -162,7 +179,6 @@ def _extract_response_text(result: dict | list) -> str:
|
||||
Handles special cases:
|
||||
- Regular AI text responses
|
||||
- Clarification interrupts (``ask_clarification`` tool messages)
|
||||
- Strips loop-detection warnings attached to tool-call AI messages
|
||||
"""
|
||||
if isinstance(result, list):
|
||||
messages = result
|
||||
@ -181,6 +197,8 @@ def _extract_response_text(result: dict | list) -> str:
|
||||
|
||||
# Stop at the last human message — anything before it is a previous turn
|
||||
if msg_type == "human":
|
||||
if _is_hidden_human_control_message(msg):
|
||||
continue
|
||||
break
|
||||
|
||||
# Check for tool messages from ask_clarification (interrupt case)
|
||||
@ -192,12 +210,7 @@ def _extract_response_text(result: dict | list) -> str:
|
||||
# Regular AI message with text content
|
||||
if msg_type == "ai":
|
||||
content = msg.get("content", "")
|
||||
has_tool_calls = bool(msg.get("tool_calls"))
|
||||
if isinstance(content, str) and content:
|
||||
if has_tool_calls:
|
||||
content = _strip_loop_warning_text(content)
|
||||
if not content:
|
||||
continue
|
||||
return content
|
||||
# content can be a list of content blocks
|
||||
if isinstance(content, list):
|
||||
@ -208,13 +221,59 @@ def _extract_response_text(result: dict | list) -> str:
|
||||
elif isinstance(block, str):
|
||||
parts.append(block)
|
||||
text = "".join(parts)
|
||||
if has_tool_calls:
|
||||
text = _strip_loop_warning_text(text)
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def _messages_from_result(result: dict | list) -> list[Any]:
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
if isinstance(result, dict):
|
||||
messages = result.get("messages", [])
|
||||
if isinstance(messages, list):
|
||||
return messages
|
||||
return []
|
||||
|
||||
|
||||
def _current_turn_messages(result: dict | list) -> list[dict[str, Any]]:
|
||||
messages = _messages_from_result(result)
|
||||
current_turn: list[dict[str, Any]] = []
|
||||
for msg in reversed(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if msg.get("type") == "human":
|
||||
break
|
||||
current_turn.append(msg)
|
||||
current_turn.reverse()
|
||||
return current_turn
|
||||
|
||||
|
||||
def _has_current_turn_clarification(result: dict | list) -> bool:
|
||||
"""Return True only when the current turn's final result is clarification."""
|
||||
for msg in reversed(_current_turn_messages(result)):
|
||||
msg_type = msg.get("type")
|
||||
if msg_type == "tool":
|
||||
return msg.get("name") == "ask_clarification"
|
||||
if msg_type == "ai":
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
if content:
|
||||
return False
|
||||
elif content:
|
||||
return False
|
||||
if msg.get("tool_calls"):
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _response_metadata(base_metadata: dict[str, Any], *, pending_clarification: bool = False) -> dict[str, Any]:
|
||||
metadata = _slim_metadata(base_metadata)
|
||||
if pending_clarification:
|
||||
metadata[PENDING_CLARIFICATION_METADATA_KEY] = True
|
||||
return metadata
|
||||
|
||||
|
||||
def _extract_text_content(content: Any) -> str:
|
||||
"""Extract text from a streaming payload content field."""
|
||||
if isinstance(content, str):
|
||||
@ -328,6 +387,8 @@ def _extract_artifacts(result: dict | list) -> list[str]:
|
||||
continue
|
||||
# Stop at the last human message — anything before it is a previous turn
|
||||
if msg.get("type") == "human":
|
||||
if _is_hidden_human_control_message(msg):
|
||||
continue
|
||||
break
|
||||
# Look for AI messages with present_files tool calls
|
||||
if msg.get("type") == "ai":
|
||||
@ -340,6 +401,18 @@ def _extract_artifacts(result: dict | list) -> list[str]:
|
||||
return artifacts
|
||||
|
||||
|
||||
def _is_hidden_human_control_message(msg: Mapping[str, Any]) -> bool:
|
||||
"""Return whether a human message is an internal control message hidden from UI."""
|
||||
if msg.get("type") != "human":
|
||||
return False
|
||||
|
||||
additional_kwargs = msg.get("additional_kwargs")
|
||||
if not isinstance(additional_kwargs, Mapping):
|
||||
return False
|
||||
|
||||
return additional_kwargs.get("hide_from_ui") is True
|
||||
|
||||
|
||||
def _format_artifact_text(artifacts: list[str]) -> str:
|
||||
"""Format artifact paths into a human-readable text block listing filenames."""
|
||||
import posixpath
|
||||
@ -353,6 +426,46 @@ def _format_artifact_text(artifacts: list[str]) -> str:
|
||||
_OUTPUTS_VIRTUAL_PREFIX = "/mnt/user-data/outputs/"
|
||||
|
||||
|
||||
def _unknown_command_reply(command: str | None = None) -> str:
|
||||
available = " | ".join(sorted(KNOWN_CHANNEL_COMMANDS))
|
||||
if command:
|
||||
return f"Unknown command: /{command}. Available commands: {available}"
|
||||
return f"Unknown command. Available commands: {available}"
|
||||
|
||||
|
||||
def _human_input_message(content: str, *, original_content: str | None = None) -> dict[str, Any]:
|
||||
message: dict[str, Any] = {"role": "human", "content": content}
|
||||
if original_content is not None and original_content != content:
|
||||
message["additional_kwargs"] = {ORIGINAL_USER_CONTENT_KEY: original_content}
|
||||
return message
|
||||
|
||||
|
||||
def _resolve_slash_skill_command(
|
||||
text: str,
|
||||
available_skills: set[str] | None = None,
|
||||
storage: SkillStorage | Callable[[], SkillStorage] | None = None,
|
||||
) -> _SlashSkillCommandResolution | None:
|
||||
reference = parse_slash_skill_reference(text)
|
||||
if reference is None:
|
||||
return None
|
||||
try:
|
||||
resolved_storage = storage() if callable(storage) else storage or get_or_new_skill_storage()
|
||||
skills = resolved_storage.load_skills(enabled_only=False)
|
||||
|
||||
skill = next((candidate for candidate in skills if candidate.name == reference.name), None)
|
||||
if skill is None:
|
||||
return None
|
||||
if not skill.enabled:
|
||||
return _SlashSkillCommandResolution(failure_message=f"Skill `/{reference.name}` is installed but disabled. Enable it before using slash activation.")
|
||||
if available_skills is not None and reference.name not in available_skills:
|
||||
return _SlashSkillCommandResolution(failure_message=f"Skill `/{reference.name}` is not available for this agent.")
|
||||
|
||||
return _SlashSkillCommandResolution(route_to_chat=True)
|
||||
except Exception as exc:
|
||||
logger.exception("[Manager] failed to resolve slash skill command")
|
||||
raise SlashSkillCommandResolutionError("Failed to resolve slash skill command. Please check the skill configuration.") from exc
|
||||
|
||||
|
||||
def _resolve_attachments(thread_id: str, artifacts: list[str]) -> list[ResolvedAttachment]:
|
||||
"""Resolve virtual artifact paths to host filesystem paths with metadata.
|
||||
|
||||
@ -567,6 +680,7 @@ class ChannelManager:
|
||||
self._default_session = _as_dict(default_session)
|
||||
self._channel_sessions = dict(channel_sessions or {})
|
||||
self._client = None # lazy init — langgraph_sdk async client
|
||||
self._skill_storage: SkillStorage | None = None
|
||||
self._csrf_token = generate_csrf_token()
|
||||
self._semaphore: asyncio.Semaphore | None = None
|
||||
self._running = False
|
||||
@ -614,12 +728,20 @@ class ChannelManager:
|
||||
configurable["checkpoint_ns"] = ""
|
||||
configurable["thread_id"] = thread_id
|
||||
|
||||
# ``user_id`` drives user-scoped filesystem buckets that only accept
|
||||
# ``[A-Za-z0-9_-]``, so normalize the channel id and keep the raw value
|
||||
# under ``channel_user_id`` for platform-facing lookups.
|
||||
run_context_identity: dict[str, Any] = {"thread_id": thread_id}
|
||||
if msg.user_id:
|
||||
run_context_identity["user_id"] = make_safe_user_id(msg.user_id)
|
||||
run_context_identity["channel_user_id"] = msg.user_id
|
||||
|
||||
run_context = _merge_dicts(
|
||||
DEFAULT_RUN_CONTEXT,
|
||||
self._default_session.get("context"),
|
||||
channel_layer.get("context"),
|
||||
user_layer.get("context"),
|
||||
{"thread_id": thread_id},
|
||||
run_context_identity,
|
||||
)
|
||||
|
||||
# Custom agents are implemented as lead_agent + agent_name context.
|
||||
@ -631,6 +753,21 @@ class ChannelManager:
|
||||
|
||||
return assistant_id, run_config, run_context
|
||||
|
||||
def _resolve_available_skill_names(self, msg: InboundMessage) -> set[str] | None:
|
||||
thread_id = self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id) or ""
|
||||
_, _, run_context = self._resolve_run_params(msg, thread_id)
|
||||
if run_context.get("is_bootstrap"):
|
||||
return {"bootstrap"}
|
||||
|
||||
agent_name = run_context.get("agent_name")
|
||||
if not isinstance(agent_name, str) or not agent_name.strip():
|
||||
return None
|
||||
|
||||
agent_config = load_agent_config(_normalize_custom_agent_name(agent_name))
|
||||
if agent_config and agent_config.skills is not None:
|
||||
return set(agent_config.skills)
|
||||
return None
|
||||
|
||||
# -- LangGraph SDK client (lazy) ----------------------------------------
|
||||
|
||||
def _get_client(self):
|
||||
@ -648,6 +785,11 @@ class ChannelManager:
|
||||
)
|
||||
return self._client
|
||||
|
||||
def _get_skill_storage(self) -> SkillStorage:
|
||||
if self._skill_storage is None:
|
||||
self._skill_storage = get_or_new_skill_storage()
|
||||
return self._skill_storage
|
||||
|
||||
# -- lifecycle ---------------------------------------------------------
|
||||
|
||||
async def start(self) -> None:
|
||||
@ -717,6 +859,14 @@ class ChannelManager:
|
||||
exc,
|
||||
)
|
||||
await self._send_error(msg, str(exc))
|
||||
except SlashSkillCommandResolutionError as exc:
|
||||
logger.warning(
|
||||
"Slash skill command resolution failed for %s (chat=%s): %s",
|
||||
msg.channel_name,
|
||||
msg.chat_id,
|
||||
exc,
|
||||
)
|
||||
await self._send_error(msg, str(exc))
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error handling message from %s (chat=%s)",
|
||||
@ -771,9 +921,11 @@ class ChannelManager:
|
||||
if extra_context:
|
||||
run_context.update(extra_context)
|
||||
|
||||
original_text = msg.text
|
||||
uploaded = await _ingest_inbound_files(thread_id, msg)
|
||||
if uploaded:
|
||||
msg.text = f"{_format_uploaded_files_block(uploaded)}\n\n{msg.text}".strip()
|
||||
human_message = _human_input_message(msg.text, original_content=original_text)
|
||||
|
||||
if self._channel_supports_streaming(msg.channel_name):
|
||||
await self._handle_streaming_chat(
|
||||
@ -783,6 +935,7 @@ class ChannelManager:
|
||||
assistant_id,
|
||||
run_config,
|
||||
run_context,
|
||||
human_message,
|
||||
)
|
||||
return
|
||||
|
||||
@ -791,7 +944,7 @@ class ChannelManager:
|
||||
result = await client.runs.wait(
|
||||
thread_id,
|
||||
assistant_id,
|
||||
input={"messages": [{"role": "human", "content": msg.text}]},
|
||||
input={"messages": [human_message]},
|
||||
config=run_config,
|
||||
context=run_context,
|
||||
multitask_strategy="reject",
|
||||
@ -805,6 +958,7 @@ class ChannelManager:
|
||||
raise
|
||||
|
||||
response_text = _extract_response_text(result)
|
||||
pending_clarification = _has_current_turn_clarification(result)
|
||||
artifacts = _extract_artifacts(result)
|
||||
|
||||
logger.info(
|
||||
@ -830,7 +984,7 @@ class ChannelManager:
|
||||
artifacts=artifacts,
|
||||
attachments=attachments,
|
||||
thread_ts=msg.thread_ts,
|
||||
metadata=_slim_metadata(msg.metadata),
|
||||
metadata=_response_metadata(msg.metadata, pending_clarification=pending_clarification),
|
||||
)
|
||||
logger.info("[Manager] publishing outbound message to bus: channel=%s, chat_id=%s", msg.channel_name, msg.chat_id)
|
||||
await self.bus.publish_outbound(outbound)
|
||||
@ -843,6 +997,7 @@ class ChannelManager:
|
||||
assistant_id: str,
|
||||
run_config: dict[str, Any],
|
||||
run_context: dict[str, Any],
|
||||
human_message: dict[str, Any],
|
||||
) -> None:
|
||||
logger.info("[Manager] invoking runs.stream(thread_id=%s, text=%r)", thread_id, msg.text[:100])
|
||||
|
||||
@ -858,7 +1013,7 @@ class ChannelManager:
|
||||
async for chunk in client.runs.stream(
|
||||
thread_id,
|
||||
assistant_id,
|
||||
input={"messages": [{"role": "human", "content": msg.text}]},
|
||||
input={"messages": [human_message]},
|
||||
config=run_config,
|
||||
context=run_context,
|
||||
stream_mode=["messages-tuple", "values"],
|
||||
@ -892,7 +1047,7 @@ class ChannelManager:
|
||||
text=latest_text,
|
||||
is_final=False,
|
||||
thread_ts=msg.thread_ts,
|
||||
metadata=_slim_metadata(msg.metadata),
|
||||
metadata=_response_metadata(msg.metadata),
|
||||
)
|
||||
)
|
||||
last_published_text = latest_text
|
||||
@ -906,6 +1061,7 @@ class ChannelManager:
|
||||
finally:
|
||||
result = last_values if last_values is not None else {"messages": [{"type": "ai", "content": latest_text}]}
|
||||
response_text = _extract_response_text(result)
|
||||
pending_clarification = _has_current_turn_clarification(result)
|
||||
artifacts = _extract_artifacts(result)
|
||||
response_text, attachments = _prepare_artifact_delivery(thread_id, response_text, artifacts)
|
||||
|
||||
@ -937,18 +1093,27 @@ class ChannelManager:
|
||||
attachments=attachments,
|
||||
is_final=True,
|
||||
thread_ts=msg.thread_ts,
|
||||
metadata=_slim_metadata(msg.metadata),
|
||||
metadata=_response_metadata(msg.metadata, pending_clarification=pending_clarification),
|
||||
)
|
||||
)
|
||||
|
||||
# -- command handling --------------------------------------------------
|
||||
|
||||
async def _handle_command(self, msg: InboundMessage) -> None:
|
||||
text = msg.text.strip()
|
||||
raw_text = msg.text
|
||||
text = raw_text.strip()
|
||||
parts = text.split(maxsplit=1)
|
||||
command = parts[0].lower().lstrip("/")
|
||||
reply: str | None = None
|
||||
if not parts:
|
||||
command = None
|
||||
reply = _unknown_command_reply()
|
||||
else:
|
||||
command = parts[0].lower().removeprefix("/")
|
||||
|
||||
if command == "bootstrap":
|
||||
if reply is None and not raw_text.startswith("/"):
|
||||
reply = _unknown_command_reply(command)
|
||||
|
||||
if reply is None and command == "bootstrap":
|
||||
from dataclasses import replace as _dc_replace
|
||||
|
||||
chat_text = parts[1] if len(parts) > 1 else "Initialize workspace"
|
||||
@ -956,7 +1121,7 @@ class ChannelManager:
|
||||
await self._handle_chat(chat_msg, extra_context={"is_bootstrap": True})
|
||||
return
|
||||
|
||||
if command == "new":
|
||||
if reply is None and command == "new":
|
||||
# Create a new thread through Gateway
|
||||
client = self._get_client()
|
||||
thread = await client.threads.create()
|
||||
@ -969,14 +1134,14 @@ class ChannelManager:
|
||||
user_id=msg.user_id,
|
||||
)
|
||||
reply = "New conversation started."
|
||||
elif command == "status":
|
||||
elif reply is None and command == "status":
|
||||
thread_id = self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id)
|
||||
reply = f"Active thread: {thread_id}" if thread_id else "No active conversation."
|
||||
elif command == "models":
|
||||
elif reply is None and command == "models":
|
||||
reply = await self._fetch_gateway("/api/models", "models")
|
||||
elif command == "memory":
|
||||
elif reply is None and command == "memory":
|
||||
reply = await self._fetch_gateway("/api/memory", "memory")
|
||||
elif command == "help":
|
||||
elif reply is None and command == "help":
|
||||
reply = (
|
||||
"Available commands:\n"
|
||||
"/bootstrap — Start a bootstrap session (enables agent setup)\n"
|
||||
@ -984,16 +1149,32 @@ class ChannelManager:
|
||||
"/status — Show current thread info\n"
|
||||
"/models — List available models\n"
|
||||
"/memory — Show memory status\n"
|
||||
"/<skill-name> <task> — Activate an enabled skill for one turn\n"
|
||||
"/help — Show this help"
|
||||
)
|
||||
else:
|
||||
available = " | ".join(sorted(KNOWN_CHANNEL_COMMANDS))
|
||||
reply = f"Unknown command: /{command}. Available commands: {available}"
|
||||
elif reply is None:
|
||||
slash_resolution = await asyncio.to_thread(
|
||||
lambda: _resolve_slash_skill_command(
|
||||
raw_text,
|
||||
self._resolve_available_skill_names(msg),
|
||||
self._get_skill_storage,
|
||||
)
|
||||
)
|
||||
if slash_resolution and slash_resolution.failure_message:
|
||||
reply = slash_resolution.failure_message
|
||||
elif slash_resolution and slash_resolution.route_to_chat:
|
||||
from dataclasses import replace as _dc_replace
|
||||
|
||||
chat_msg = _dc_replace(msg, msg_type=InboundMessageType.CHAT)
|
||||
await self._handle_chat(chat_msg)
|
||||
return
|
||||
else:
|
||||
reply = _unknown_command_reply(command)
|
||||
|
||||
outbound = OutboundMessage(
|
||||
channel_name=msg.channel_name,
|
||||
chat_id=msg.chat_id,
|
||||
thread_id=self.store.get_thread_id(msg.channel_name, msg.chat_id) or "",
|
||||
thread_id=self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id) or "",
|
||||
text=reply,
|
||||
thread_ts=msg.thread_ts,
|
||||
metadata=_slim_metadata(msg.metadata),
|
||||
@ -1031,7 +1212,7 @@ class ChannelManager:
|
||||
outbound = OutboundMessage(
|
||||
channel_name=msg.channel_name,
|
||||
chat_id=msg.chat_id,
|
||||
thread_id=self.store.get_thread_id(msg.channel_name, msg.chat_id) or "",
|
||||
thread_id=self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id) or "",
|
||||
text=error_text,
|
||||
thread_ts=msg.thread_ts,
|
||||
metadata=_slim_metadata(msg.metadata),
|
||||
|
||||
@ -13,6 +13,9 @@ from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PENDING_CLARIFICATION_METADATA_KEY = "pending_clarification"
|
||||
RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY = "resolved_from_pending_clarification"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Message types
|
||||
|
||||
@ -9,6 +9,7 @@ from typing import Any
|
||||
from markdown_to_mrkdwn import SlackMarkdownConverter
|
||||
|
||||
from app.channels.base import Channel
|
||||
from app.channels.commands import is_known_channel_command
|
||||
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -32,6 +33,20 @@ def _normalize_allowed_users(allowed_users: Any) -> set[str]:
|
||||
return {str(user_id) for user_id in values if str(user_id)}
|
||||
|
||||
|
||||
def _strip_leading_slack_bot_mention(text: str, bot_user_id: str | None) -> str:
|
||||
if not bot_user_id:
|
||||
return text
|
||||
if not text.startswith("<@"):
|
||||
return text
|
||||
end = text.find(">")
|
||||
if end <= 2:
|
||||
return text
|
||||
mentioned_user_id = text[2:end].split("|", 1)[0].lstrip("!")
|
||||
if mentioned_user_id != bot_user_id:
|
||||
return text
|
||||
return text[end + 1 :].lstrip()
|
||||
|
||||
|
||||
class SlackChannel(Channel):
|
||||
"""Slack IM channel using Socket Mode (WebSocket, no public IP).
|
||||
|
||||
@ -49,6 +64,8 @@ class SlackChannel(Channel):
|
||||
self._web_client = None
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._allowed_users = _normalize_allowed_users(config.get("allowed_users", []))
|
||||
configured_bot_user_id = config.get("bot_user_id")
|
||||
self._bot_user_id = str(configured_bot_user_id).lstrip("@") if configured_bot_user_id else None
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._running:
|
||||
@ -72,6 +89,17 @@ class SlackChannel(Channel):
|
||||
return
|
||||
|
||||
self._web_client = WebClient(token=bot_token)
|
||||
if self._bot_user_id is None:
|
||||
try:
|
||||
auth_info = await asyncio.to_thread(self._web_client.auth_test)
|
||||
user_id = auth_info.get("user_id") if isinstance(auth_info, dict) else None
|
||||
if user_id is None:
|
||||
auth_get = getattr(auth_info, "get", None)
|
||||
user_id = auth_get("user_id") if callable(auth_get) else None
|
||||
if isinstance(user_id, str) and user_id:
|
||||
self._bot_user_id = user_id
|
||||
except Exception:
|
||||
logger.warning("[Slack] failed to resolve bot user id; app mention text may include the bot mention", exc_info=True)
|
||||
self._socket_client = SocketModeClient(
|
||||
app_token=app_token,
|
||||
web_client=self._web_client,
|
||||
@ -210,6 +238,12 @@ class SlackChannel(Channel):
|
||||
if event_type != "events_api":
|
||||
return
|
||||
|
||||
if self._bot_user_id is None:
|
||||
authorization = next((item for item in req.payload.get("authorizations", []) if isinstance(item, dict)), None)
|
||||
user_id = authorization.get("user_id") if authorization else None
|
||||
if isinstance(user_id, str) and user_id:
|
||||
self._bot_user_id = user_id
|
||||
|
||||
event = req.payload.get("event", {})
|
||||
etype = event.get("type", "")
|
||||
|
||||
@ -233,13 +267,15 @@ class SlackChannel(Channel):
|
||||
return
|
||||
|
||||
text = event.get("text", "").strip()
|
||||
if event.get("type") == "app_mention":
|
||||
text = _strip_leading_slack_bot_mention(text, self._bot_user_id)
|
||||
if not text:
|
||||
return
|
||||
|
||||
channel_id = event.get("channel", "")
|
||||
thread_ts = event.get("thread_ts") or event.get("ts", "")
|
||||
|
||||
if text.startswith("/"):
|
||||
if is_known_channel_command(text):
|
||||
msg_type = InboundMessageType.COMMAND
|
||||
else:
|
||||
msg_type = InboundMessageType.CHAT
|
||||
|
||||
@ -60,12 +60,17 @@ class TelegramChannel(Channel):
|
||||
|
||||
# Command handlers
|
||||
app.add_handler(CommandHandler("start", self._cmd_start))
|
||||
app.add_handler(CommandHandler("bootstrap", self._cmd_generic))
|
||||
app.add_handler(CommandHandler("new", self._cmd_generic))
|
||||
app.add_handler(CommandHandler("status", self._cmd_generic))
|
||||
app.add_handler(CommandHandler("models", self._cmd_generic))
|
||||
app.add_handler(CommandHandler("memory", self._cmd_generic))
|
||||
app.add_handler(CommandHandler("help", self._cmd_generic))
|
||||
|
||||
# Slash skill commands are dynamic and cannot all be pre-registered
|
||||
# with Telegram, so route unknown slash commands through chat handling.
|
||||
app.add_handler(MessageHandler(filters.TEXT & filters.COMMAND, self._on_text))
|
||||
|
||||
# General message handler
|
||||
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._on_text))
|
||||
|
||||
@ -228,6 +233,33 @@ class TelegramChannel(Channel):
|
||||
return True
|
||||
return user_id in self._allowed_users
|
||||
|
||||
def _get_bot_username(self, context) -> str | None:
|
||||
bot = getattr(context, "bot", None)
|
||||
username = getattr(bot, "username", None)
|
||||
if not username and self._application is not None:
|
||||
username = getattr(getattr(self._application, "bot", None), "username", None)
|
||||
return str(username) if username else None
|
||||
|
||||
@staticmethod
|
||||
def _strip_bot_username_from_leading_command(text: str, bot_username: str | None) -> str:
|
||||
username = (bot_username or "").lstrip("@").lower()
|
||||
if not username or not text.startswith("/"):
|
||||
return text
|
||||
|
||||
parts = text.split(maxsplit=1)
|
||||
command_token = parts[0]
|
||||
if "@" not in command_token:
|
||||
return text
|
||||
|
||||
command_name, addressed_username = command_token[1:].rsplit("@", 1)
|
||||
if not command_name or addressed_username.lower() != username:
|
||||
return text
|
||||
|
||||
normalized = f"/{command_name}"
|
||||
if len(parts) > 1:
|
||||
normalized = f"{normalized} {parts[1]}"
|
||||
return normalized
|
||||
|
||||
async def _cmd_start(self, update, context) -> None:
|
||||
"""Handle /start command."""
|
||||
if not self._check_user(update.effective_user.id):
|
||||
@ -243,7 +275,7 @@ class TelegramChannel(Channel):
|
||||
if not self._check_user(update.effective_user.id):
|
||||
return
|
||||
|
||||
text = update.message.text
|
||||
text = self._strip_bot_username_from_leading_command(update.message.text.strip(), self._get_bot_username(context))
|
||||
chat_id = str(update.effective_chat.id)
|
||||
user_id = str(update.effective_user.id)
|
||||
msg_id = str(update.message.message_id)
|
||||
@ -279,7 +311,7 @@ class TelegramChannel(Channel):
|
||||
if not self._check_user(update.effective_user.id):
|
||||
return
|
||||
|
||||
text = update.message.text.strip()
|
||||
text = self._strip_bot_username_from_leading_command(update.message.text.strip(), self._get_bot_username(context))
|
||||
if not text:
|
||||
return
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@ from cryptography.hazmat.primitives import padding
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
from app.channels.base import Channel
|
||||
from app.channels.commands import is_known_channel_command
|
||||
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -620,7 +621,7 @@ class WechatChannel(Channel):
|
||||
chat_id=chat_id,
|
||||
user_id=chat_id,
|
||||
text=text,
|
||||
msg_type=InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT,
|
||||
msg_type=InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT,
|
||||
thread_ts=thread_ts,
|
||||
files=files,
|
||||
metadata={
|
||||
|
||||
@ -8,6 +8,7 @@ from collections.abc import Awaitable, Callable
|
||||
from typing import Any, cast
|
||||
|
||||
from app.channels.base import Channel
|
||||
from app.channels.commands import is_known_channel_command
|
||||
from app.channels.message_bus import (
|
||||
InboundMessageType,
|
||||
MessageBus,
|
||||
@ -270,7 +271,7 @@ class WeComChannel(Channel):
|
||||
|
||||
user_id = (body.get("from") or {}).get("userid")
|
||||
|
||||
inbound_type = InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT
|
||||
inbound_type = InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT
|
||||
inbound = self._make_inbound(
|
||||
chat_id=user_id, # keep user's conversation in memory
|
||||
user_id=user_id,
|
||||
|
||||
@ -161,10 +161,16 @@ async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan handler."""
|
||||
|
||||
# Load config and check necessary environment variables at startup
|
||||
# Load config and check necessary environment variables at startup.
|
||||
# `startup_config` is a local snapshot used only for one-shot bootstrap
|
||||
# work (logging level, langgraph_runtime engines, channels). Request-time
|
||||
# config resolution always routes through `get_app_config()` in
|
||||
# `app/gateway/deps.py::get_config()` so `config.yaml` edits become
|
||||
# visible without a process restart. We deliberately do NOT cache this
|
||||
# snapshot on `app.state` to keep that contract enforceable.
|
||||
try:
|
||||
app.state.config = get_app_config()
|
||||
apply_logging_level(app.state.config.log_level)
|
||||
startup_config = get_app_config()
|
||||
apply_logging_level(startup_config.log_level)
|
||||
logger.info("Configuration loaded successfully")
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to load configuration during gateway startup: {e}"
|
||||
@ -173,8 +179,27 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
config = get_gateway_config()
|
||||
logger.info(f"Starting API Gateway on {config.host}:{config.port}")
|
||||
|
||||
# Pre-warm tiktoken encoding cache so the first memory-injection request
|
||||
# never blocks on the BPE data download (which hits an OpenAI/Azure URL
|
||||
# that may be unreachable in restricted networks — see issue #3402).
|
||||
try:
|
||||
from deerflow.agents.memory.prompt import warm_tiktoken_cache
|
||||
|
||||
warmed = await asyncio.wait_for(
|
||||
asyncio.to_thread(warm_tiktoken_cache),
|
||||
timeout=5,
|
||||
)
|
||||
if warmed:
|
||||
logger.info("tiktoken encoding cache warmed successfully")
|
||||
else:
|
||||
logger.warning("tiktoken encoding cache warm-up failed; token counting will use character-based fallback")
|
||||
except TimeoutError:
|
||||
logger.warning("tiktoken encoding cache warm-up timed out; token counting will use character-based fallback")
|
||||
except Exception:
|
||||
logger.warning("tiktoken warm-up skipped", exc_info=True)
|
||||
|
||||
# Initialize LangGraph runtime components (StreamBridge, RunManager, checkpointer, store)
|
||||
async with langgraph_runtime(app):
|
||||
async with langgraph_runtime(app, startup_config):
|
||||
logger.info("LangGraph runtime initialised")
|
||||
|
||||
# Check admin bootstrap state and migrate orphan threads after admin exists.
|
||||
@ -185,7 +210,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
try:
|
||||
from app.channels.service import start_channel_service
|
||||
|
||||
channel_service = await start_channel_service(app.state.config)
|
||||
channel_service = await start_channel_service(startup_config)
|
||||
logger.info("Channel service started: %s", channel_service.get_status())
|
||||
except Exception:
|
||||
logger.exception("No IM channels configured or channel service failed to start")
|
||||
|
||||
@ -3,11 +3,22 @@
|
||||
**Getters** (used by routers): raise 503 when a required dependency is
|
||||
missing, except ``get_store`` which returns ``None``.
|
||||
|
||||
``AppConfig`` is intentionally *not* cached on ``app.state``. Routers and the
|
||||
run path resolve it through :func:`deerflow.config.app_config.get_app_config`,
|
||||
which performs mtime-based hot reload, so edits to ``config.yaml`` take
|
||||
effect on the next request without a process restart. The engines created in
|
||||
:func:`langgraph_runtime` (stream bridge, persistence, checkpointer, store,
|
||||
run-event store) accept a ``startup_config`` snapshot — they are
|
||||
restart-required by design and stay bound to that snapshot to keep the live
|
||||
process consistent with itself.
|
||||
|
||||
Initialization is handled directly in ``app.py`` via :class:`AsyncExitStack`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from typing import TYPE_CHECKING, TypeVar, cast
|
||||
@ -15,36 +26,144 @@ from typing import TYPE_CHECKING, TypeVar, cast
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from langgraph.types import Checkpointer
|
||||
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.config.app_config import AppConfig, get_app_config
|
||||
from deerflow.persistence.feedback import FeedbackRepository
|
||||
from deerflow.runtime import RunContext, RunManager, StreamBridge
|
||||
from deerflow.runtime.events.store.base import RunEventStore
|
||||
from deerflow.runtime.runs.store.base import RunStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Upper bound (seconds) for draining in-flight runs during shutdown, before the
|
||||
# AsyncExitStack tears down the checkpointer (and its connection pool). Kept
|
||||
# local to avoid an app -> deps -> app import cycle. This is a *separate* budget
|
||||
# from ``app.gateway.app._SHUTDOWN_HOOK_TIMEOUT_SECONDS`` (currently also 5.0s,
|
||||
# which bounds channel-service stop): the two govern independent teardown steps
|
||||
# and may diverge, but both count toward the lifespan shutdown window — revisit
|
||||
# them together if their sum must stay within the server's graceful-shutdown
|
||||
# timeout.
|
||||
_RUN_DRAIN_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
async def _drain_inflight_runs(run_manager: RunManager) -> None:
|
||||
"""Drain in-flight runs before the checkpointer is torn down (issue #3373).
|
||||
|
||||
Shields the (internally-bounded) drain so that even if the lifespan
|
||||
coroutine is itself cancelled mid-shutdown — a second SIGINT or the server's
|
||||
graceful-shutdown timeout, i.e. the same signal storm behind #3373 — the
|
||||
checkpointer pool is not closed while run tasks are still writing
|
||||
checkpoints. On such a cancellation we let the already-running drain finish
|
||||
(it is bounded by ``RunManager.shutdown``'s own timeout) and then propagate
|
||||
the cancellation.
|
||||
"""
|
||||
drain = asyncio.create_task(run_manager.shutdown(timeout=_RUN_DRAIN_TIMEOUT_SECONDS))
|
||||
try:
|
||||
await asyncio.shield(drain)
|
||||
except asyncio.CancelledError:
|
||||
# Re-shield so this second wait does not abandon the in-flight drain;
|
||||
# it is bounded, so this cannot hang. Then re-raise to honour shutdown.
|
||||
try:
|
||||
await asyncio.shield(drain)
|
||||
except Exception:
|
||||
logger.exception("In-flight run drain failed after shutdown cancellation")
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Failed to drain in-flight runs during shutdown")
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.gateway.auth.local_provider import LocalAuthProvider
|
||||
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
|
||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
||||
from deerflow.runtime import RunRecord
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def get_config(request: Request) -> AppConfig:
|
||||
"""Return the app-scoped ``AppConfig`` stored on ``app.state``."""
|
||||
config = getattr(request.app.state, "config", None)
|
||||
if config is None:
|
||||
raise HTTPException(status_code=503, detail="Configuration not available")
|
||||
return config
|
||||
async def _mark_latest_recovered_threads_error(
|
||||
run_manager: RunManager,
|
||||
thread_store: ThreadMetaStore,
|
||||
recovered_runs: list[RunRecord],
|
||||
) -> None:
|
||||
"""Mark thread status as error only when its newest run was recovered."""
|
||||
recovered_by_thread: dict[str, set[str]] = {}
|
||||
for record in recovered_runs:
|
||||
recovered_by_thread.setdefault(record.thread_id, set()).add(record.run_id)
|
||||
|
||||
for thread_id, recovered_run_ids in recovered_by_thread.items():
|
||||
try:
|
||||
latest_runs = await run_manager.list_by_thread(thread_id, user_id=None, limit=1)
|
||||
except Exception:
|
||||
logger.warning("Failed to find latest run for thread %s during run reconciliation", thread_id, exc_info=True)
|
||||
continue
|
||||
if not latest_runs or latest_runs[0].run_id not in recovered_run_ids:
|
||||
continue
|
||||
try:
|
||||
await thread_store.update_status(thread_id, "error", user_id=None)
|
||||
except Exception:
|
||||
logger.warning("Failed to mark thread %s as error during run reconciliation", thread_id, exc_info=True)
|
||||
|
||||
|
||||
def get_config() -> AppConfig:
|
||||
"""Return the freshest ``AppConfig`` for the current request.
|
||||
|
||||
Routes through :func:`deerflow.config.app_config.get_app_config`, which
|
||||
honours runtime ``ContextVar`` overrides and reloads ``config.yaml`` from
|
||||
disk when its mtime changes. ``AppConfig`` is not cached on ``app.state``
|
||||
at all — the only startup-time snapshot lives as a local
|
||||
``startup_config`` variable inside ``lifespan()`` and is passed
|
||||
explicitly into :func:`langgraph_runtime` for the engines that are
|
||||
restart-required by design. Routing every request through
|
||||
:func:`get_app_config` closes the bytedance/deer-flow issue #3107 BUG-001
|
||||
split-brain where the worker / lead-agent thread saw a stale startup
|
||||
snapshot.
|
||||
|
||||
Hot-reload boundary: fields backed by startup-time singletons
|
||||
(engines, sandbox provider, IM channels, logging handler) require a
|
||||
process restart to change at runtime. The authoritative list lives in
|
||||
:mod:`deerflow.config.reload_boundary` and is mirrored by the
|
||||
standardised ``"startup-only:"`` prefix on the matching
|
||||
``Field(description=...)`` in :class:`AppConfig` — IDE hover on those
|
||||
fields will surface the boundary inline. See
|
||||
``backend/CLAUDE.md`` "Config Hot-Reload Boundary" for the operator
|
||||
summary.
|
||||
|
||||
Any failure to materialise the config (missing file, permission denied,
|
||||
YAML parse error, validation error) is reported as 503 — semantically
|
||||
"the gateway cannot serve requests without a usable configuration" — and
|
||||
logged with the original exception so operators have something to debug.
|
||||
"""
|
||||
try:
|
||||
return get_app_config()
|
||||
except Exception as exc: # noqa: BLE001 - request boundary: log and degrade gracefully
|
||||
logger.exception("Failed to load AppConfig at request time")
|
||||
raise HTTPException(status_code=503, detail="Configuration not available") from exc
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGenerator[None, None]:
|
||||
"""Bootstrap and tear down all LangGraph runtime singletons.
|
||||
|
||||
``startup_config`` is the ``AppConfig`` snapshot taken once during
|
||||
``lifespan()`` for one-shot infrastructure bootstrap. The engines and
|
||||
stores constructed here (stream bridge, persistence engine, checkpointer,
|
||||
store, run-event store) are restart-required by design — they hold live
|
||||
connections, file handles, or singleton providers — so they bind to this
|
||||
snapshot and survive across `config.yaml` edits. Request-time consumers
|
||||
must still go through :func:`get_config` for any field that should be
|
||||
hot-reloadable. See ``backend/CLAUDE.md`` "Config Hot-Reload Boundary".
|
||||
|
||||
The matching ``run_events_config`` is frozen onto ``app.state`` so
|
||||
:func:`get_run_context` pairs a freshly-loaded ``AppConfig`` with the
|
||||
*startup-time* run-events configuration the underlying ``event_store``
|
||||
was built from — otherwise the runtime could end up combining a live
|
||||
new ``run_events_config`` with an event store still bound to the
|
||||
previous backend.
|
||||
|
||||
Usage in ``app.py``::
|
||||
|
||||
async with langgraph_runtime(app):
|
||||
async with langgraph_runtime(app, startup_config):
|
||||
yield
|
||||
"""
|
||||
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
|
||||
@ -53,9 +172,7 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
from deerflow.runtime.events.store import make_run_event_store
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
config = getattr(app.state, "config", None)
|
||||
if config is None:
|
||||
raise RuntimeError("langgraph_runtime() requires app.state.config to be initialized")
|
||||
config = startup_config
|
||||
|
||||
app.state.stream_bridge = await stack.enter_async_context(make_stream_bridge(config))
|
||||
|
||||
@ -84,16 +201,38 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.thread_store = make_thread_store(sf, app.state.store)
|
||||
|
||||
# Run event store (has its own factory with config-driven backend selection)
|
||||
# Run event store. The store and the matching ``run_events_config`` are
|
||||
# both frozen at startup so ``get_run_context`` does not combine a
|
||||
# freshly-reloaded ``AppConfig.run_events`` with a store still bound to
|
||||
# the previous backend.
|
||||
run_events_config = getattr(config, "run_events", None)
|
||||
app.state.run_events_config = run_events_config
|
||||
app.state.run_event_store = make_run_event_store(run_events_config)
|
||||
|
||||
# RunManager with store backing for persistence
|
||||
app.state.run_manager = RunManager(store=app.state.run_store)
|
||||
if getattr(config.database, "backend", None) == "sqlite":
|
||||
from deerflow.utils.time import now_iso
|
||||
|
||||
# Startup-only recovery: clean shutdowns return no active rows and
|
||||
# the thread-status update below becomes a no-op.
|
||||
recovered_runs = await app.state.run_manager.reconcile_orphaned_inflight_runs(
|
||||
error="Gateway restarted before this run reached a durable final state.",
|
||||
before=now_iso(),
|
||||
)
|
||||
await _mark_latest_recovered_threads_error(app.state.run_manager, app.state.thread_store, recovered_runs)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Drain in-flight run tasks BEFORE the AsyncExitStack tears down the
|
||||
# checkpointer (and its connection pool). A run still mid-graph would
|
||||
# otherwise leak into asyncio.run() shutdown, where langgraph's
|
||||
# _checkpointer_put_after_previous aput races the closed pool and
|
||||
# raises PoolClosed (issue #3373).
|
||||
run_manager = getattr(app.state, "run_manager", None)
|
||||
if run_manager is not None:
|
||||
await _drain_inflight_runs(run_manager)
|
||||
await close_engine()
|
||||
|
||||
|
||||
@ -139,16 +278,20 @@ def get_thread_store(request: Request) -> ThreadMetaStore:
|
||||
def get_run_context(request: Request) -> RunContext:
|
||||
"""Build a :class:`RunContext` from ``app.state`` singletons.
|
||||
|
||||
Returns a *base* context with infrastructure dependencies.
|
||||
Returns a *base* context with infrastructure dependencies. The
|
||||
``app_config`` field is resolved live so per-run fields (e.g.
|
||||
``models[*].max_tokens``) follow ``config.yaml`` edits; the
|
||||
``event_store`` / ``run_events_config`` pair stays frozen to the snapshot
|
||||
captured in :func:`langgraph_runtime` so callers never see a store bound
|
||||
to one backend paired with a config pointing at another.
|
||||
"""
|
||||
config = get_config(request)
|
||||
return RunContext(
|
||||
checkpointer=get_checkpointer(request),
|
||||
store=get_store(request),
|
||||
event_store=get_run_event_store(request),
|
||||
run_events_config=getattr(config, "run_events", None),
|
||||
run_events_config=getattr(request.app.state, "run_events_config", None),
|
||||
thread_store=get_thread_store(request),
|
||||
app_config=config,
|
||||
app_config=get_config(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -1,26 +1,38 @@
|
||||
"""Process-local authentication for Gateway internal callers."""
|
||||
"""Authentication for trusted Gateway internal callers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from types import SimpleNamespace
|
||||
|
||||
from deerflow.runtime.user_context import DEFAULT_USER_ID
|
||||
|
||||
INTERNAL_AUTH_HEADER_NAME = "X-DeerFlow-Internal-Token"
|
||||
_INTERNAL_AUTH_TOKEN = secrets.token_urlsafe(32)
|
||||
INTERNAL_AUTH_ENV_VAR = "DEER_FLOW_INTERNAL_AUTH_TOKEN"
|
||||
INTERNAL_SYSTEM_ROLE = "internal"
|
||||
|
||||
|
||||
def _load_internal_auth_token() -> str:
|
||||
token = os.environ.get(INTERNAL_AUTH_ENV_VAR)
|
||||
if token:
|
||||
return token
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
_INTERNAL_AUTH_TOKEN = _load_internal_auth_token()
|
||||
|
||||
|
||||
def create_internal_auth_headers() -> dict[str, str]:
|
||||
"""Return headers that authenticate same-process Gateway internal calls."""
|
||||
"""Return headers that authenticate trusted Gateway internal calls."""
|
||||
return {INTERNAL_AUTH_HEADER_NAME: _INTERNAL_AUTH_TOKEN}
|
||||
|
||||
|
||||
def is_valid_internal_auth_token(token: str | None) -> bool:
|
||||
"""Return True when *token* matches the process-local internal token."""
|
||||
"""Return True when *token* matches this Gateway worker's internal token."""
|
||||
return bool(token) and secrets.compare_digest(token, _INTERNAL_AUTH_TOKEN)
|
||||
|
||||
|
||||
def get_internal_user():
|
||||
"""Return the synthetic user used for trusted internal channel calls."""
|
||||
return SimpleNamespace(id=DEFAULT_USER_ID, system_role="internal")
|
||||
return SimpleNamespace(id=DEFAULT_USER_ID, system_role=INTERNAL_SYSTEM_ROLE)
|
||||
|
||||
15
backend/app/gateway/pagination.py
Normal file
15
backend/app/gateway/pagination.py
Normal file
@ -0,0 +1,15 @@
|
||||
"""Shared pagination helpers for gateway routers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def trim_run_message_page(rows: list[dict], *, limit: int, after_seq: int | None) -> tuple[list[dict], bool]:
|
||||
"""Trim a ``limit + 1`` run-message page while preserving page boundaries."""
|
||||
has_more = len(rows) > limit
|
||||
if not has_more:
|
||||
return rows, False
|
||||
|
||||
if after_seq is not None:
|
||||
return rows[:limit], True
|
||||
|
||||
return rows[-limit:], True
|
||||
@ -1,5 +1,6 @@
|
||||
"""CRUD API for custom agents."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
@ -213,48 +214,61 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
|
||||
user_id = get_effective_user_id()
|
||||
paths = get_paths()
|
||||
|
||||
agent_dir = paths.user_agent_dir(user_id, normalized_name)
|
||||
legacy_dir = paths.agent_dir(normalized_name)
|
||||
def _create_agent() -> AgentResponse | None:
|
||||
# Worker thread: base-dir resolution, existence checks, directory/file
|
||||
# creation, read-back, and failure cleanup are all blocking filesystem
|
||||
# IO that must stay off the event loop.
|
||||
agent_dir = paths.user_agent_dir(user_id, normalized_name)
|
||||
legacy_dir = paths.agent_dir(normalized_name)
|
||||
|
||||
if agent_dir.exists() or legacy_dir.exists():
|
||||
raise HTTPException(status_code=409, detail=f"Agent '{normalized_name}' already exists")
|
||||
if legacy_dir.exists():
|
||||
return None # signals 409 to the caller
|
||||
|
||||
try:
|
||||
try:
|
||||
agent_dir.mkdir(parents=True, exist_ok=False)
|
||||
except FileExistsError:
|
||||
return None # signals 409 to the caller
|
||||
# Write config.yaml
|
||||
config_data: dict = {"name": normalized_name}
|
||||
if request.description:
|
||||
config_data["description"] = request.description
|
||||
if request.model is not None:
|
||||
config_data["model"] = request.model
|
||||
if request.tool_groups is not None:
|
||||
config_data["tool_groups"] = request.tool_groups
|
||||
if request.skills is not None:
|
||||
config_data["skills"] = request.skills
|
||||
|
||||
config_file = agent_dir / "config.yaml"
|
||||
with open(config_file, "w", encoding="utf-8") as f:
|
||||
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
# Write SOUL.md
|
||||
soul_file = agent_dir / "SOUL.md"
|
||||
soul_file.write_text(request.soul, encoding="utf-8")
|
||||
|
||||
logger.info(f"Created agent '{normalized_name}' at {agent_dir}")
|
||||
|
||||
agent_cfg = load_agent_config(normalized_name, user_id=user_id)
|
||||
return _agent_config_to_response(agent_cfg, include_soul=True, user_id=user_id)
|
||||
except Exception:
|
||||
# Clean up partial state on failure before surfacing the error.
|
||||
if agent_dir.exists():
|
||||
shutil.rmtree(agent_dir)
|
||||
raise
|
||||
|
||||
try:
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write config.yaml
|
||||
config_data: dict = {"name": normalized_name}
|
||||
if request.description:
|
||||
config_data["description"] = request.description
|
||||
if request.model is not None:
|
||||
config_data["model"] = request.model
|
||||
if request.tool_groups is not None:
|
||||
config_data["tool_groups"] = request.tool_groups
|
||||
if request.skills is not None:
|
||||
config_data["skills"] = request.skills
|
||||
|
||||
config_file = agent_dir / "config.yaml"
|
||||
with open(config_file, "w", encoding="utf-8") as f:
|
||||
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
# Write SOUL.md
|
||||
soul_file = agent_dir / "SOUL.md"
|
||||
soul_file.write_text(request.soul, encoding="utf-8")
|
||||
|
||||
logger.info(f"Created agent '{normalized_name}' at {agent_dir}")
|
||||
|
||||
agent_cfg = load_agent_config(normalized_name, user_id=user_id)
|
||||
return _agent_config_to_response(agent_cfg, include_soul=True, user_id=user_id)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
response = await asyncio.to_thread(_create_agent)
|
||||
except Exception as e:
|
||||
# Clean up on failure
|
||||
if agent_dir.exists():
|
||||
shutil.rmtree(agent_dir)
|
||||
logger.error(f"Failed to create agent '{request.name}': {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create agent: {str(e)}")
|
||||
|
||||
if response is None:
|
||||
raise HTTPException(status_code=409, detail=f"Agent '{normalized_name}' already exists")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.put(
|
||||
"/agents/{name}",
|
||||
@ -428,19 +442,30 @@ async def delete_agent(name: str) -> None:
|
||||
name = _normalize_agent_name(name)
|
||||
user_id = get_effective_user_id()
|
||||
paths = get_paths()
|
||||
agent_dir = paths.user_agent_dir(user_id, name)
|
||||
|
||||
if not agent_dir.exists():
|
||||
if paths.agent_dir(name).exists():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(f"Agent '{name}' only exists in the legacy shared layout and is not scoped to a user. Run scripts/migrate_user_isolation.py to move legacy agents into the per-user layout before deleting."),
|
||||
)
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
||||
def _remove_agent_dir() -> tuple[str, str]:
|
||||
# Runs in a worker thread: resolving the base dir, probing the directory
|
||||
# (`exists`), and removing it (`rmtree`) are all blocking filesystem IO
|
||||
# that must stay off the event loop.
|
||||
agent_dir = paths.user_agent_dir(user_id, name)
|
||||
if not agent_dir.exists():
|
||||
outcome = "legacy" if paths.agent_dir(name).exists() else "missing"
|
||||
return outcome, str(agent_dir)
|
||||
shutil.rmtree(agent_dir)
|
||||
return "deleted", str(agent_dir)
|
||||
|
||||
try:
|
||||
shutil.rmtree(agent_dir)
|
||||
logger.info(f"Deleted agent '{name}' from {agent_dir}")
|
||||
outcome, agent_dir = await asyncio.to_thread(_remove_agent_dir)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete agent '{name}': {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}")
|
||||
|
||||
if outcome == "legacy":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(f"Agent '{name}' only exists in the legacy shared layout and is not scoped to a user. Run scripts/migrate_user_isolation.py to move legacy agents into the per-user layout before deleting."),
|
||||
)
|
||||
if outcome == "missing":
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
||||
|
||||
logger.info(f"Deleted agent '{name}' from {agent_dir}")
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from deerflow.config.extensions_config import ExtensionsConfig, get_extensions_config, reload_extensions_config
|
||||
@ -12,6 +13,11 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api", tags=["mcp"])
|
||||
|
||||
|
||||
_MCP_STDIO_COMMAND_ALLOWLIST_ENV = "DEER_FLOW_MCP_STDIO_COMMAND_ALLOWLIST"
|
||||
_DEFAULT_MCP_STDIO_COMMAND_ALLOWLIST = frozenset({"npx", "uvx"})
|
||||
_SHELL_METACHARS = frozenset(";|&`$<>\n\r")
|
||||
|
||||
|
||||
class McpOAuthConfigResponse(BaseModel):
|
||||
"""OAuth configuration for an MCP server."""
|
||||
|
||||
@ -63,13 +69,178 @@ class McpConfigUpdateRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
_MASKED_VALUE = "***"
|
||||
|
||||
|
||||
async def _require_admin_user(request: Request) -> None:
|
||||
"""Require the authenticated caller to be an admin user.
|
||||
|
||||
``AuthMiddleware`` normally stamps ``request.state.user`` before the
|
||||
request reaches this router. Falling back to the strict dependency keeps
|
||||
this route safe even in tests or alternative ASGI compositions that mount
|
||||
the router without the global middleware.
|
||||
"""
|
||||
user = getattr(request.state, "user", None)
|
||||
if user is None:
|
||||
from app.gateway.deps import get_current_user_from_request
|
||||
|
||||
user = await get_current_user_from_request(request)
|
||||
|
||||
if getattr(user, "system_role", None) != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required to manage MCP configuration.",
|
||||
)
|
||||
|
||||
|
||||
def _allowed_stdio_commands() -> set[str]:
|
||||
"""Return executable names allowed for API-managed stdio MCP servers."""
|
||||
raw = os.environ.get(_MCP_STDIO_COMMAND_ALLOWLIST_ENV)
|
||||
base = set(_DEFAULT_MCP_STDIO_COMMAND_ALLOWLIST)
|
||||
if raw is None:
|
||||
return base
|
||||
extra = {item.strip() for item in raw.split(",") if item.strip()}
|
||||
return base | extra
|
||||
|
||||
|
||||
def _stdio_command_name(command: str | None, *, server_name: str) -> str:
|
||||
"""Normalize and validate a stdio command field from the API boundary."""
|
||||
if command is None or not command.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"MCP server '{server_name}' with stdio transport requires a command.",
|
||||
)
|
||||
|
||||
stripped = command.strip()
|
||||
has_path_separator = "/" in stripped or "\\" in stripped
|
||||
if stripped != command or has_path_separator or any(ch.isspace() for ch in stripped) or any(ch in stripped for ch in _SHELL_METACHARS):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(f"MCP server '{server_name}' command must be a single executable name; put parameters in args instead."),
|
||||
)
|
||||
|
||||
return stripped
|
||||
|
||||
|
||||
def _validate_mcp_update_request(request: McpConfigUpdateRequest) -> None:
|
||||
"""Validate API-submitted MCP config before it is persisted.
|
||||
|
||||
Local config files can still express arbitrary advanced setups, but the
|
||||
HTTP API is an untrusted boundary. Restricting stdio commands here reduces
|
||||
the blast radius of a compromised authenticated browser session.
|
||||
"""
|
||||
allowed_commands = _allowed_stdio_commands()
|
||||
for name, server in request.mcp_servers.items():
|
||||
transport_type = (server.type or "stdio").lower()
|
||||
if transport_type != "stdio":
|
||||
continue
|
||||
|
||||
command_name = _stdio_command_name(server.command, server_name=name)
|
||||
if command_name not in allowed_commands:
|
||||
allowed = ", ".join(sorted(allowed_commands)) or "<none>"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(f"MCP server '{name}' uses disallowed stdio command '{command_name}'. Allowed commands: {allowed}. Configure {_MCP_STDIO_COMMAND_ALLOWLIST_ENV} to extend this list."),
|
||||
)
|
||||
|
||||
|
||||
def _mask_server_config(server: McpServerConfigResponse) -> McpServerConfigResponse:
|
||||
"""Return a copy of server config with sensitive fields masked.
|
||||
|
||||
Masks env values, header values, and removes OAuth secrets so they
|
||||
are not exposed through the GET API endpoint.
|
||||
"""
|
||||
masked_env = {k: _MASKED_VALUE for k in server.env}
|
||||
masked_headers = {k: _MASKED_VALUE for k in server.headers}
|
||||
masked_oauth = None
|
||||
if server.oauth is not None:
|
||||
masked_oauth = server.oauth.model_copy(
|
||||
update={
|
||||
"client_secret": None,
|
||||
"refresh_token": None,
|
||||
}
|
||||
)
|
||||
return server.model_copy(
|
||||
update={
|
||||
"env": masked_env,
|
||||
"headers": masked_headers,
|
||||
"oauth": masked_oauth,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _merge_preserving_secrets(
|
||||
incoming: McpServerConfigResponse,
|
||||
existing: McpServerConfigResponse,
|
||||
) -> McpServerConfigResponse:
|
||||
"""Merge incoming config with existing, preserving secrets masked by GET.
|
||||
|
||||
When the frontend toggles ``enabled`` it round-trips the full config:
|
||||
GET (masked) → modify enabled → PUT (masked values sent back).
|
||||
This function ensures masked values (``***``) are replaced with the
|
||||
real secrets from the current on-disk config.
|
||||
|
||||
``***`` is only accepted for keys that already exist in *existing*.
|
||||
New keys must provide a real value.
|
||||
|
||||
For OAuth secrets, ``None`` means "preserve the existing stored value"
|
||||
so masked GET responses can be safely round-tripped. To explicitly clear
|
||||
a stored secret, clients may send an empty string, which is converted
|
||||
to ``None`` before persisting.
|
||||
"""
|
||||
merged_env = {}
|
||||
for k, v in incoming.env.items():
|
||||
if v == _MASKED_VALUE:
|
||||
if k in existing.env:
|
||||
merged_env[k] = existing.env[k]
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot set env key '{k}' to masked value '***'; provide a real value.",
|
||||
)
|
||||
else:
|
||||
merged_env[k] = v
|
||||
|
||||
merged_headers = {}
|
||||
for k, v in incoming.headers.items():
|
||||
if v == _MASKED_VALUE:
|
||||
if k in existing.headers:
|
||||
merged_headers[k] = existing.headers[k]
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot set header '{k}' to masked value '***'; provide a real value.",
|
||||
)
|
||||
else:
|
||||
merged_headers[k] = v
|
||||
|
||||
merged_oauth = incoming.oauth
|
||||
if incoming.oauth is not None and existing.oauth is not None:
|
||||
# None = preserve (masked round-trip), "" = explicitly clear, else = new value
|
||||
merged_client_secret = existing.oauth.client_secret if incoming.oauth.client_secret is None else (None if incoming.oauth.client_secret == "" else incoming.oauth.client_secret)
|
||||
merged_refresh_token = existing.oauth.refresh_token if incoming.oauth.refresh_token is None else (None if incoming.oauth.refresh_token == "" else incoming.oauth.refresh_token)
|
||||
merged_oauth = incoming.oauth.model_copy(
|
||||
update={
|
||||
"client_secret": merged_client_secret,
|
||||
"refresh_token": merged_refresh_token,
|
||||
}
|
||||
)
|
||||
return incoming.model_copy(
|
||||
update={
|
||||
"env": merged_env,
|
||||
"headers": merged_headers,
|
||||
"oauth": merged_oauth,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/mcp/config",
|
||||
response_model=McpConfigResponse,
|
||||
summary="Get MCP Configuration",
|
||||
description="Retrieve the current Model Context Protocol (MCP) server configurations.",
|
||||
)
|
||||
async def get_mcp_configuration() -> McpConfigResponse:
|
||||
async def get_mcp_configuration(request: Request) -> McpConfigResponse:
|
||||
"""Get the current MCP configuration.
|
||||
|
||||
Returns:
|
||||
@ -83,16 +254,19 @@ async def get_mcp_configuration() -> McpConfigResponse:
|
||||
"enabled": true,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": {"GITHUB_TOKEN": "ghp_xxx"},
|
||||
"env": {"GITHUB_TOKEN": "***"},
|
||||
"description": "GitHub MCP server for repository operations"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
"""
|
||||
await _require_admin_user(request)
|
||||
|
||||
config = get_extensions_config()
|
||||
|
||||
return McpConfigResponse(mcp_servers={name: McpServerConfigResponse(**server.model_dump()) for name, server in config.mcp_servers.items()})
|
||||
servers = {name: _mask_server_config(McpServerConfigResponse(**server.model_dump())) for name, server in config.mcp_servers.items()}
|
||||
return McpConfigResponse(mcp_servers=servers)
|
||||
|
||||
|
||||
@router.put(
|
||||
@ -101,7 +275,7 @@ async def get_mcp_configuration() -> McpConfigResponse:
|
||||
summary="Update MCP Configuration",
|
||||
description="Update Model Context Protocol (MCP) server configurations and save to file.",
|
||||
)
|
||||
async def update_mcp_configuration(request: McpConfigUpdateRequest) -> McpConfigResponse:
|
||||
async def update_mcp_configuration(request: Request, body: McpConfigUpdateRequest) -> McpConfigResponse:
|
||||
"""Update the MCP configuration.
|
||||
|
||||
This will:
|
||||
@ -134,6 +308,9 @@ async def update_mcp_configuration(request: McpConfigUpdateRequest) -> McpConfig
|
||||
```
|
||||
"""
|
||||
try:
|
||||
await _require_admin_user(request)
|
||||
_validate_mcp_update_request(body)
|
||||
|
||||
# Get the current config path (or determine where to save it)
|
||||
config_path = ExtensionsConfig.resolve_config_path()
|
||||
|
||||
@ -142,14 +319,39 @@ async def update_mcp_configuration(request: McpConfigUpdateRequest) -> McpConfig
|
||||
config_path = Path.cwd().parent / "extensions_config.json"
|
||||
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
|
||||
|
||||
# Load current config to preserve skills configuration
|
||||
# Load current config to preserve skills
|
||||
current_config = get_extensions_config()
|
||||
|
||||
# Convert request to dict format for JSON serialization
|
||||
config_data = {
|
||||
"mcpServers": {name: server.model_dump() for name, server in request.mcp_servers.items()},
|
||||
"skills": {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()},
|
||||
}
|
||||
# Load raw (un-resolved) JSON from disk to use as the merge source.
|
||||
# This preserves $VAR placeholders in env values and top-level keys
|
||||
# like mcpInterceptors that would otherwise be lost.
|
||||
raw_servers: dict[str, dict] = {}
|
||||
raw_other_keys: dict = {}
|
||||
if config_path is not None and config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
raw_data = json.load(f)
|
||||
raw_servers = raw_data.get("mcpServers", {})
|
||||
# Preserve any top-level keys beyond mcpServers/skills
|
||||
for key, value in raw_data.items():
|
||||
if key not in ("mcpServers", "skills"):
|
||||
raw_other_keys[key] = value
|
||||
|
||||
# Merge incoming server configs with raw on-disk secrets
|
||||
merged_servers: dict[str, McpServerConfigResponse] = {}
|
||||
for name, incoming in body.mcp_servers.items():
|
||||
raw_server = raw_servers.get(name)
|
||||
if raw_server is not None:
|
||||
merged_servers[name] = _merge_preserving_secrets(
|
||||
incoming,
|
||||
McpServerConfigResponse(**raw_server),
|
||||
)
|
||||
else:
|
||||
merged_servers[name] = incoming
|
||||
|
||||
# Build config data preserving all top-level keys from the original file
|
||||
config_data = dict(raw_other_keys)
|
||||
config_data["mcpServers"] = {name: server.model_dump() for name, server in merged_servers.items()}
|
||||
config_data["skills"] = {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()}
|
||||
|
||||
# Write the configuration to file
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
@ -157,13 +359,15 @@ async def update_mcp_configuration(request: McpConfigUpdateRequest) -> McpConfig
|
||||
|
||||
logger.info(f"MCP configuration updated and saved to: {config_path}")
|
||||
|
||||
# NOTE: No need to reload/reset cache here - LangGraph Server (separate process)
|
||||
# will detect config file changes via mtime and reinitialize MCP tools automatically
|
||||
|
||||
# Reload the configuration and update the global cache
|
||||
# Reload the Gateway configuration and update the global cache. The
|
||||
# agent runtime lives in Gateway, so this keeps API reads and tool
|
||||
# execution aligned after extensions_config.json changes.
|
||||
reloaded_config = reload_extensions_config()
|
||||
return McpConfigResponse(mcp_servers={name: McpServerConfigResponse(**server.model_dump()) for name, server in reloaded_config.mcp_servers.items()})
|
||||
servers = {name: _mask_server_config(McpServerConfigResponse(**server.model_dump())) for name, server in reloaded_config.mcp_servers.items()}
|
||||
return McpConfigResponse(mcp_servers=servers)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update MCP configuration: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update MCP configuration: {str(e)}")
|
||||
|
||||
@ -7,7 +7,6 @@ is reused so that conversation history is preserved across calls.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
@ -16,8 +15,9 @@ from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.gateway.authz import require_permission
|
||||
from app.gateway.deps import get_checkpointer, get_feedback_repo, get_run_event_store, get_run_manager, get_run_store, get_stream_bridge
|
||||
from app.gateway.pagination import trim_run_message_page
|
||||
from app.gateway.routers.thread_runs import RunCreateRequest
|
||||
from app.gateway.services import sse_consumer, start_run
|
||||
from app.gateway.services import sse_consumer, start_run, wait_for_run_completion
|
||||
from deerflow.runtime import serialize_channel_values
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -66,24 +66,25 @@ async def stateless_wait(body: RunCreateRequest, request: Request) -> dict:
|
||||
Otherwise a new temporary thread is created.
|
||||
"""
|
||||
thread_id = _resolve_thread_id(body)
|
||||
bridge = get_stream_bridge(request)
|
||||
run_mgr = get_run_manager(request)
|
||||
record = await start_run(body, thread_id, request)
|
||||
|
||||
completed = True
|
||||
if record.task is not None:
|
||||
try:
|
||||
await record.task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
completed = await wait_for_run_completion(bridge, record, request, run_mgr)
|
||||
|
||||
checkpointer = get_checkpointer(request)
|
||||
config = {"configurable": {"thread_id": thread_id}}
|
||||
try:
|
||||
checkpoint_tuple = await checkpointer.aget_tuple(config)
|
||||
if checkpoint_tuple is not None:
|
||||
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
|
||||
channel_values = checkpoint.get("channel_values", {})
|
||||
return serialize_channel_values(channel_values)
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch final state for run %s", record.run_id)
|
||||
if completed:
|
||||
checkpointer = get_checkpointer(request)
|
||||
config = {"configurable": {"thread_id": thread_id}}
|
||||
try:
|
||||
checkpoint_tuple = await checkpointer.aget_tuple(config)
|
||||
if checkpoint_tuple is not None:
|
||||
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
|
||||
channel_values = checkpoint.get("channel_values", {})
|
||||
return serialize_channel_values(channel_values)
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch final state for run %s", record.run_id)
|
||||
|
||||
return {"status": record.status.value, "error": record.error}
|
||||
|
||||
@ -129,8 +130,7 @@ async def run_messages(
|
||||
before_seq=before_seq,
|
||||
after_seq=after_seq,
|
||||
)
|
||||
has_more = len(rows) > limit
|
||||
data = rows[:limit] if has_more else rows
|
||||
data, has_more = trim_run_message_page(rows, limit=limit, after_seq=after_seq)
|
||||
return {"data": data, "has_more": has_more}
|
||||
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
@ -30,6 +31,31 @@ class SuggestionsResponse(BaseModel):
|
||||
suggestions: list[str] = Field(default_factory=list, description="Suggested follow-up questions")
|
||||
|
||||
|
||||
# Matches a complete <think>...</think> block (case-insensitive, spans newlines).
|
||||
_THINK_BLOCK_RE = re.compile(r"<think\b[^>]*>.*?</think\s*>", re.IGNORECASE | re.DOTALL)
|
||||
# Matches a dangling, unclosed <think> (model truncated at max_tokens mid-thought).
|
||||
_OPEN_THINK_RE = re.compile(r"<think\b[^>]*>", re.IGNORECASE)
|
||||
|
||||
|
||||
def _strip_think_blocks(text: str) -> str:
|
||||
"""Remove reasoning-model ``<think>...</think>`` blocks from the response.
|
||||
|
||||
Reasoning models such as MiniMax-M3 inline their chain-of-thought into the
|
||||
message ``content`` wrapped in ``<think>...</think>`` (``reasoning_split``
|
||||
defaults to false), rather than exposing a separate ``reasoning_content``
|
||||
field. The thinking text frequently contains ``[`` / ``]`` characters, which
|
||||
corrupted the downstream ``find('[')`` / ``rfind(']')`` JSON extraction and
|
||||
produced empty suggestions. We strip the reasoning before parsing so only
|
||||
the actual answer remains.
|
||||
"""
|
||||
text = _THINK_BLOCK_RE.sub("", text)
|
||||
# Drop any unclosed <think> (and everything after it) left by truncation.
|
||||
open_match = _OPEN_THINK_RE.search(text)
|
||||
if open_match:
|
||||
text = text[: open_match.start()]
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _strip_markdown_code_fence(text: str) -> str:
|
||||
stripped = text.strip()
|
||||
if not stripped.startswith("```"):
|
||||
@ -41,7 +67,8 @@ def _strip_markdown_code_fence(text: str) -> str:
|
||||
|
||||
|
||||
def _parse_json_string_list(text: str) -> list[str] | None:
|
||||
candidate = _strip_markdown_code_fence(text)
|
||||
candidate = _strip_think_blocks(text)
|
||||
candidate = _strip_markdown_code_fence(candidate)
|
||||
start = candidate.find("[")
|
||||
end = candidate.rfind("]")
|
||||
if start == -1 or end == -1 or end <= start:
|
||||
|
||||
@ -21,7 +21,8 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.authz import require_permission
|
||||
from app.gateway.deps import get_checkpointer, get_current_user, get_feedback_repo, get_run_event_store, get_run_manager, get_run_store, get_stream_bridge
|
||||
from app.gateway.services import sse_consumer, start_run
|
||||
from app.gateway.pagination import trim_run_message_page
|
||||
from app.gateway.services import sse_consumer, start_run, wait_for_run_completion
|
||||
from deerflow.runtime import RunRecord, RunStatus, serialize_channel_values
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -66,6 +67,14 @@ class RunResponse(BaseModel):
|
||||
multitask_strategy: str = "reject"
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
total_input_tokens: int = 0
|
||||
total_output_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
llm_call_count: int = 0
|
||||
lead_agent_tokens: int = 0
|
||||
subagent_tokens: int = 0
|
||||
middleware_tokens: int = 0
|
||||
message_count: int = 0
|
||||
|
||||
|
||||
class ThreadTokenUsageModelBreakdown(BaseModel):
|
||||
@ -111,6 +120,14 @@ def _record_to_response(record: RunRecord) -> RunResponse:
|
||||
multitask_strategy=record.multitask_strategy,
|
||||
created_at=record.created_at,
|
||||
updated_at=record.updated_at,
|
||||
total_input_tokens=record.total_input_tokens,
|
||||
total_output_tokens=record.total_output_tokens,
|
||||
total_tokens=record.total_tokens,
|
||||
llm_call_count=record.llm_call_count,
|
||||
lead_agent_tokens=record.lead_agent_tokens,
|
||||
subagent_tokens=record.subagent_tokens,
|
||||
middleware_tokens=record.middleware_tokens,
|
||||
message_count=record.message_count,
|
||||
)
|
||||
|
||||
|
||||
@ -159,24 +176,25 @@ async def stream_run(thread_id: str, body: RunCreateRequest, request: Request) -
|
||||
@require_permission("runs", "create", owner_check=True, require_existing=True)
|
||||
async def wait_run(thread_id: str, body: RunCreateRequest, request: Request) -> dict:
|
||||
"""Create a run and block until it completes, returning the final state."""
|
||||
bridge = get_stream_bridge(request)
|
||||
run_mgr = get_run_manager(request)
|
||||
record = await start_run(body, thread_id, request)
|
||||
|
||||
completed = True
|
||||
if record.task is not None:
|
||||
try:
|
||||
await record.task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
completed = await wait_for_run_completion(bridge, record, request, run_mgr)
|
||||
|
||||
checkpointer = get_checkpointer(request)
|
||||
config = {"configurable": {"thread_id": thread_id}}
|
||||
try:
|
||||
checkpoint_tuple = await checkpointer.aget_tuple(config)
|
||||
if checkpoint_tuple is not None:
|
||||
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
|
||||
channel_values = checkpoint.get("channel_values", {})
|
||||
return serialize_channel_values(channel_values)
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch final state for run %s", record.run_id)
|
||||
if completed:
|
||||
checkpointer = get_checkpointer(request)
|
||||
config = {"configurable": {"thread_id": thread_id}}
|
||||
try:
|
||||
checkpoint_tuple = await checkpointer.aget_tuple(config)
|
||||
if checkpoint_tuple is not None:
|
||||
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
|
||||
channel_values = checkpoint.get("channel_values", {})
|
||||
return serialize_channel_values(channel_values)
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch final state for run %s", record.run_id)
|
||||
|
||||
return {"status": record.status.value, "error": record.error}
|
||||
|
||||
@ -261,7 +279,12 @@ async def join_run(thread_id: str, run_id: str, request: Request) -> StreamingRe
|
||||
)
|
||||
|
||||
|
||||
@router.api_route("/{thread_id}/runs/{run_id}/stream", methods=["GET", "POST"], response_model=None)
|
||||
# Register GET and POST as separate routes so each method gets a unique OpenAPI
|
||||
# operationId. ``api_route(methods=["GET", "POST"])`` shares one route registration
|
||||
# across both methods, which makes FastAPI emit the same ``operationId`` twice and
|
||||
# warn about a duplicate operation id during OpenAPI generation.
|
||||
@router.get("/{thread_id}/runs/{run_id}/stream", response_model=None)
|
||||
@router.post("/{thread_id}/runs/{run_id}/stream", response_model=None)
|
||||
@require_permission("runs", "read", owner_check=True)
|
||||
async def stream_existing_run(
|
||||
thread_id: str,
|
||||
@ -380,8 +403,7 @@ async def list_run_messages(
|
||||
before_seq=before_seq,
|
||||
after_seq=after_seq,
|
||||
)
|
||||
has_more = len(rows) > limit
|
||||
data = rows[:limit] if has_more else rows
|
||||
data, has_more = trim_run_message_page(rows, limit=limit, after_seq=after_seq)
|
||||
return {"data": data, "has_more": has_more}
|
||||
|
||||
|
||||
@ -402,8 +424,15 @@ async def list_run_events(
|
||||
|
||||
@router.get("/{thread_id}/token-usage", response_model=ThreadTokenUsageResponse)
|
||||
@require_permission("threads", "read", owner_check=True)
|
||||
async def thread_token_usage(thread_id: str, request: Request) -> ThreadTokenUsageResponse:
|
||||
async def thread_token_usage(
|
||||
thread_id: str,
|
||||
request: Request,
|
||||
include_active: bool = Query(default=False, description="Include running run progress snapshots"),
|
||||
) -> ThreadTokenUsageResponse:
|
||||
"""Thread-level token usage aggregation."""
|
||||
run_store = get_run_store(request)
|
||||
agg = await run_store.aggregate_tokens_by_thread(thread_id)
|
||||
if include_active:
|
||||
agg = await run_store.aggregate_tokens_by_thread(thread_id, include_active=True)
|
||||
else:
|
||||
agg = await run_store.aggregate_tokens_by_thread(thread_id)
|
||||
return ThreadTokenUsageResponse(thread_id=thread_id, **agg)
|
||||
|
||||
@ -17,7 +17,7 @@ import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from langgraph.checkpoint.base import empty_checkpoint
|
||||
from langgraph.checkpoint.base import empty_checkpoint, uuid6
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.gateway.authz import require_permission
|
||||
@ -536,9 +536,21 @@ async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, re
|
||||
metadata["step"] = metadata.get("step", 0) + 1
|
||||
metadata["writes"] = {body.as_node: body.values}
|
||||
|
||||
# Assign a new checkpoint ID so aput performs an INSERT rather than an
|
||||
# in-place REPLACE of the existing row. Use uuid6 (time-ordered) rather
|
||||
# than uuid4 (random) so the new ID is always lexicographically greater
|
||||
# than the previous one — LangGraph's checkpointers determine the "latest"
|
||||
# checkpoint by max(checkpoint_ids) string order, matching the uuid6 epoch.
|
||||
checkpoint["id"] = str(uuid6())
|
||||
|
||||
# aput requires checkpoint_ns in the config — use the same config used for the
|
||||
# read (which always includes checkpoint_ns=""). Do NOT include checkpoint_id
|
||||
# so that aput generates a fresh checkpoint ID for the new snapshot.
|
||||
# read (which always includes checkpoint_ns=""). The fresh checkpoint ID is
|
||||
# assigned above via checkpoint["id"]; keep checkpoint_id out of the config so
|
||||
# the write is keyed by the new checkpoint payload rather than the prior read.
|
||||
# All supported savers (InMemorySaver, AsyncSqliteSaver, AsyncPostgresSaver)
|
||||
# persist and echo back checkpoint["id"] verbatim — none mint their own — so
|
||||
# the new_config below carries the uuid6 we assigned here. (Regression-locked
|
||||
# by test_update_thread_state_inserts_new_checkpoint_each_call.)
|
||||
write_config: dict[str, Any] = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
@ -557,7 +569,7 @@ async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, re
|
||||
|
||||
# Sync title changes through the ThreadMetaStore abstraction so /threads/search
|
||||
# reflects them immediately in both sqlite and memory backends.
|
||||
if body.values and "title" in body.values:
|
||||
if thread_store and body.values and "title" in body.values:
|
||||
new_title = body.values["title"]
|
||||
if new_title: # Skip empty strings and None
|
||||
try:
|
||||
|
||||
@ -39,15 +39,39 @@ DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024
|
||||
DEFAULT_MAX_TOTAL_SIZE = 100 * 1024 * 1024
|
||||
|
||||
|
||||
class UploadedFileInfo(BaseModel):
|
||||
"""Uploaded file metadata exposed by upload and list APIs."""
|
||||
|
||||
filename: str
|
||||
size: int
|
||||
path: str
|
||||
virtual_path: str
|
||||
artifact_url: str
|
||||
extension: str | None = None
|
||||
modified: float | None = None
|
||||
original_filename: str | None = None
|
||||
markdown_file: str | None = None
|
||||
markdown_path: str | None = None
|
||||
markdown_virtual_path: str | None = None
|
||||
markdown_artifact_url: str | None = None
|
||||
|
||||
|
||||
class UploadResponse(BaseModel):
|
||||
"""Response model for file upload."""
|
||||
|
||||
success: bool
|
||||
files: list[dict[str, str]]
|
||||
files: list[UploadedFileInfo]
|
||||
message: str
|
||||
skipped_files: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UploadListResponse(BaseModel):
|
||||
"""Response model for uploaded file listing."""
|
||||
|
||||
files: list[UploadedFileInfo]
|
||||
count: int
|
||||
|
||||
|
||||
class UploadLimits(BaseModel):
|
||||
"""Application-level upload limits exposed to clients."""
|
||||
|
||||
@ -69,11 +93,30 @@ def _make_file_sandbox_writable(file_path: os.PathLike[str] | str) -> None:
|
||||
logger.warning("Skipping sandbox chmod for symlinked upload path: %s", file_path)
|
||||
return
|
||||
|
||||
writable_mode = stat.S_IMODE(file_stat.st_mode) | stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH
|
||||
writable_mode = stat.S_IMODE(file_stat.st_mode) | stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH | stat.S_IRGRP | stat.S_IROTH
|
||||
chmod_kwargs = {"follow_symlinks": False} if os.chmod in os.supports_follow_symlinks else {}
|
||||
os.chmod(file_path, writable_mode, **chmod_kwargs)
|
||||
|
||||
|
||||
def _make_file_sandbox_readable(file_path: os.PathLike[str] | str) -> None:
|
||||
"""Ensure uploaded files are readable by the sandbox process.
|
||||
|
||||
For Docker sandboxes (AIO), the gateway writes files as root with 0o600
|
||||
permissions, then bind-mounts the host directory into the container. The
|
||||
sandbox process inside the container runs as a non-root user and cannot
|
||||
read those files without group/other read bits. This function adds
|
||||
``S_IRGRP | S_IROTH`` so the sandbox can read the uploaded content.
|
||||
"""
|
||||
file_stat = os.lstat(file_path)
|
||||
if stat.S_ISLNK(file_stat.st_mode):
|
||||
logger.warning("Skipping sandbox chmod for symlinked upload path: %s", file_path)
|
||||
return
|
||||
|
||||
readable_mode = stat.S_IMODE(file_stat.st_mode) | stat.S_IRGRP | stat.S_IROTH
|
||||
chmod_kwargs = {"follow_symlinks": False} if os.chmod in os.supports_follow_symlinks else {}
|
||||
os.chmod(file_path, readable_mode, **chmod_kwargs)
|
||||
|
||||
|
||||
def _uses_thread_data_mounts(sandbox_provider: SandboxProvider) -> bool:
|
||||
return bool(getattr(sandbox_provider, "uses_thread_data_mounts", False))
|
||||
|
||||
@ -237,7 +280,7 @@ async def upload_files(
|
||||
|
||||
file_info = {
|
||||
"filename": safe_filename,
|
||||
"size": str(file_size),
|
||||
"size": file_size,
|
||||
"path": str(sandbox_uploads / safe_filename),
|
||||
"virtual_path": virtual_path,
|
||||
"artifact_url": upload_artifact_url(thread_id, safe_filename),
|
||||
@ -276,6 +319,16 @@ async def upload_files(
|
||||
_cleanup_uploaded_paths(written_paths)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to upload {file.filename}: {str(e)}")
|
||||
|
||||
# Uploaded files are created with 0o600 permissions (owner read/write only).
|
||||
# In Docker sandbox deployments the gateway writes as root but the sandbox
|
||||
# process runs as a non-root user (typically UID 1000). Without group/other
|
||||
# read bits the sandbox cannot access the files — whether the uploads
|
||||
# directory is bind-mounted into the container or synced via
|
||||
# sandbox.update_file. Always add group/other read bits so every sandbox
|
||||
# configuration can read the uploaded content.
|
||||
for file_path in written_paths:
|
||||
_make_file_sandbox_readable(file_path)
|
||||
|
||||
if sync_to_sandbox:
|
||||
for file_path, virtual_path in sandbox_sync_targets:
|
||||
_make_file_sandbox_writable(file_path)
|
||||
@ -304,9 +357,9 @@ async def get_upload_limits(
|
||||
return _get_upload_limits(config)
|
||||
|
||||
|
||||
@router.get("/list", response_model=dict)
|
||||
@router.get("/list", response_model=UploadListResponse)
|
||||
@require_permission("threads", "read", owner_check=True)
|
||||
async def list_uploaded_files(thread_id: str, request: Request) -> dict:
|
||||
async def list_uploaded_files(thread_id: str, request: Request) -> UploadListResponse:
|
||||
"""List all files in a thread's uploads directory."""
|
||||
try:
|
||||
uploads_dir = get_uploads_dir(thread_id)
|
||||
@ -320,7 +373,7 @@ async def list_uploaded_files(thread_id: str, request: Request) -> dict:
|
||||
for f in result["files"]:
|
||||
f["path"] = str(sandbox_uploads / f["filename"])
|
||||
|
||||
return result
|
||||
return UploadListResponse(**result)
|
||||
|
||||
|
||||
@router.delete("/{filename}")
|
||||
|
||||
@ -15,9 +15,11 @@ from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain_core.messages import BaseMessage
|
||||
from langchain_core.messages.utils import convert_to_messages
|
||||
|
||||
from app.gateway.deps import get_run_context, get_run_manager, get_stream_bridge
|
||||
from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE
|
||||
from app.gateway.utils import sanitize_log_param
|
||||
from deerflow.config.app_config import get_app_config
|
||||
from deerflow.runtime import (
|
||||
@ -32,6 +34,7 @@ from deerflow.runtime import (
|
||||
UnsupportedStrategyError,
|
||||
run_agent,
|
||||
)
|
||||
from deerflow.runtime.runs.naming import resolve_root_run_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -75,21 +78,35 @@ def normalize_stream_modes(raw: list[str] | str | None) -> list[str]:
|
||||
|
||||
|
||||
def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Convert LangGraph Platform input format to LangChain state dict."""
|
||||
"""Convert LangGraph Platform input format to LangChain state dict.
|
||||
|
||||
Delegates dict→message coercion to ``langchain_core.messages.utils.convert_to_messages``
|
||||
so that ``additional_kwargs`` (e.g. uploaded-file metadata — gh #3132), ``id``,
|
||||
``name``, and non-human roles (ai/system/tool) survive unchanged. An earlier
|
||||
hand-rolled version only forwarded ``content`` and collapsed every role to
|
||||
``HumanMessage``, which silently stripped frontend-supplied attachments.
|
||||
|
||||
Malformed message dicts (missing ``role``/``type``/``content``, unsupported
|
||||
role, etc.) raise ``HTTPException(400)`` with the offending index, instead
|
||||
of bubbling up as a 500. The gateway is a system boundary, so per-entry
|
||||
validation errors are the right shape for clients to retry against.
|
||||
"""
|
||||
if raw_input is None:
|
||||
return {}
|
||||
messages = raw_input.get("messages")
|
||||
if messages and isinstance(messages, list):
|
||||
converted = []
|
||||
for msg in messages:
|
||||
if isinstance(msg, dict):
|
||||
role = msg.get("role", msg.get("type", "user"))
|
||||
content = msg.get("content", "")
|
||||
if role in ("user", "human"):
|
||||
converted.append(HumanMessage(content=content))
|
||||
else:
|
||||
# TODO: handle other message types (system, ai, tool)
|
||||
converted.append(HumanMessage(content=content))
|
||||
converted: list[Any] = []
|
||||
for index, msg in enumerate(messages):
|
||||
if isinstance(msg, BaseMessage):
|
||||
converted.append(msg)
|
||||
elif isinstance(msg, dict):
|
||||
try:
|
||||
converted.extend(convert_to_messages([msg]))
|
||||
except (ValueError, TypeError, NotImplementedError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid message at input.messages[{index}]: {exc}",
|
||||
) from exc
|
||||
else:
|
||||
converted.append(msg)
|
||||
return {**raw_input, "messages": converted}
|
||||
@ -124,7 +141,14 @@ def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, An
|
||||
"""Merge whitelisted keys from ``body.context`` into both ``config['configurable']``
|
||||
and ``config['context']`` so they are visible to legacy configurable readers and
|
||||
to LangGraph ``ToolRuntime.context`` consumers (e.g. the ``setup_agent`` tool —
|
||||
see issue #2677)."""
|
||||
see issue #2677).
|
||||
|
||||
``user_id`` is intentionally propagated into ``config['context']`` in addition to
|
||||
the whitelisted keys, so non-web callers (e.g. IM channels) that supply identity in
|
||||
``body.context`` keep it on ``ToolRuntime.context``. It is merged with
|
||||
``setdefault`` so a server-authenticated id stamped by
|
||||
:func:`inject_authenticated_user_context` always wins over the client-supplied one.
|
||||
"""
|
||||
if not context:
|
||||
return
|
||||
configurable = config.setdefault("configurable", {})
|
||||
@ -135,6 +159,8 @@ def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, An
|
||||
configurable.setdefault(key, context[key])
|
||||
if isinstance(runtime_context, dict):
|
||||
runtime_context.setdefault(key, context[key])
|
||||
if "user_id" in context and isinstance(runtime_context, dict):
|
||||
runtime_context.setdefault("user_id", context["user_id"])
|
||||
|
||||
|
||||
def inject_authenticated_user_context(config: dict[str, Any], request: Request) -> None:
|
||||
@ -150,6 +176,9 @@ def inject_authenticated_user_context(config: dict[str, Any], request: Request)
|
||||
if user_id is None:
|
||||
return
|
||||
|
||||
if getattr(user, "system_role", None) == INTERNAL_SYSTEM_ROLE:
|
||||
return
|
||||
|
||||
runtime_context = config.setdefault("context", {})
|
||||
if isinstance(runtime_context, dict):
|
||||
runtime_context["user_id"] = str(user_id)
|
||||
@ -235,6 +264,7 @@ def build_run_config(
|
||||
target = config.setdefault("configurable", {})
|
||||
if target is not None and "agent_name" not in target:
|
||||
target["agent_name"] = normalized
|
||||
config.setdefault("run_name", resolve_root_run_name(config, normalized))
|
||||
if metadata:
|
||||
config.setdefault("metadata", {}).update(metadata)
|
||||
return config
|
||||
@ -385,3 +415,51 @@ async def sse_consumer(
|
||||
if record.status in (RunStatus.pending, RunStatus.running):
|
||||
if record.on_disconnect == DisconnectMode.cancel:
|
||||
await run_mgr.cancel(record.run_id)
|
||||
|
||||
|
||||
async def wait_for_run_completion(
|
||||
bridge: StreamBridge,
|
||||
record: RunRecord,
|
||||
request: Request,
|
||||
run_mgr: RunManager,
|
||||
) -> bool:
|
||||
"""Block until the run publishes ``END_SENTINEL``, honouring on_disconnect.
|
||||
|
||||
The non-streaming ``/wait`` endpoints used to ``await record.task``
|
||||
directly with no disconnect handling. When the client (or an
|
||||
intermediate HTTP proxy) timed out during a long tool call such as
|
||||
``pip install``, the handler would swallow ``CancelledError`` and
|
||||
serialize whatever checkpoint happened to exist — masking a half-finished
|
||||
run as a normal completion (issue #3265).
|
||||
|
||||
This helper consumes the same bridge that ``sse_consumer`` does so the
|
||||
wait path shares its disconnect semantics: each wake-up polls
|
||||
``request.is_disconnected()``; on a real disconnect it cancels the
|
||||
background run when ``record.on_disconnect`` is ``cancel``. The bridge's
|
||||
heartbeat sentinels guarantee at least one wake-up per
|
||||
``heartbeat_interval`` even when the agent emits no events for a while.
|
||||
|
||||
Returns:
|
||||
``True`` when ``END_SENTINEL`` was observed (run reached a terminal
|
||||
state), ``False`` when the loop exited because the client
|
||||
disconnected. Callers must skip checkpoint serialization on
|
||||
``False`` so a partial checkpoint is not returned as a normal
|
||||
response.
|
||||
"""
|
||||
completed = False
|
||||
try:
|
||||
async for entry in bridge.subscribe(record.run_id):
|
||||
# END_SENTINEL means the run reached a terminal state; honour it
|
||||
# even if the client just disconnected so the caller still serializes
|
||||
# the real final checkpoint.
|
||||
if entry is END_SENTINEL:
|
||||
completed = True
|
||||
return True
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
# Heartbeats and regular events: keep waiting for END_SENTINEL.
|
||||
return completed
|
||||
finally:
|
||||
if not completed and record.status in (RunStatus.pending, RunStatus.running):
|
||||
if record.on_disconnect == DisconnectMode.cancel:
|
||||
await run_mgr.cancel(record.run_id)
|
||||
|
||||
@ -228,10 +228,13 @@ Get current MCP server configurations.
|
||||
GET /api/mcp/config
|
||||
```
|
||||
|
||||
Requires an authenticated admin session. Sensitive env/header/OAuth secret
|
||||
values are masked in the response.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mcp_servers": {
|
||||
"github": {
|
||||
"enabled": true,
|
||||
"type": "stdio",
|
||||
@ -241,13 +244,6 @@ GET /api/mcp/config
|
||||
"GITHUB_TOKEN": "***"
|
||||
},
|
||||
"description": "GitHub operations"
|
||||
},
|
||||
"filesystem": {
|
||||
"enabled": false,
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
|
||||
"description": "File system access"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -262,10 +258,15 @@ PUT /api/mcp/config
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Requires an authenticated admin session. API-managed `stdio` MCP servers may
|
||||
only use allowed executable names for `command` (default: `npx`, `uvx`). Set
|
||||
`DEER_FLOW_MCP_STDIO_COMMAND_ALLOWLIST` to a comma-separated list when a
|
||||
deployment needs additional trusted launchers.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mcp_servers": {
|
||||
"github": {
|
||||
"enabled": true,
|
||||
"type": "stdio",
|
||||
@ -283,8 +284,18 @@ Content-Type: application/json
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "MCP configuration updated"
|
||||
"mcp_servers": {
|
||||
"github": {
|
||||
"enabled": true,
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": {
|
||||
"GITHUB_TOKEN": "***"
|
||||
},
|
||||
"description": "GitHub operations"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@ All other test plan sections were executed against either:
|
||||
| TC-DOCKER-03 | Per-worker rate limiter divergence | Confirms in-process `_login_attempts` dict doesn't share state across `gunicorn` workers (4 by default in the compose file); known limitation, documented | needs multi-worker container |
|
||||
| TC-DOCKER-04 | IM channels use internal Gateway auth | Verify Feishu/Slack/Telegram dispatchers attach the process-local internal auth header plus CSRF cookie/header when calling Gateway-compatible LangGraph APIs | needs `docker logs` |
|
||||
| TC-DOCKER-05 | Reset credentials surfacing | `reset_admin` writes a 0600 credential file in `DEER_FLOW_HOME` instead of logging plaintext. The file-based behavior is validated by non-Docker reset tests, so the only Docker-specific gap is verifying the volume mount carries the file out to the host | needs container + host volume |
|
||||
| TC-DOCKER-06 | Gateway-mode Docker deploy | `./scripts/deploy.sh --gateway` produces a 3-container topology (no `langgraph` container); same auth flow as standard mode | needs `docker compose --profile gateway` |
|
||||
| TC-DOCKER-06 | Docker deploy uses Gateway embedded runtime | `./scripts/deploy.sh` produces a Gateway + frontend + nginx topology (no `langgraph` container); same auth flow as local `make dev` | needs `docker compose up` |
|
||||
|
||||
## Coverage already provided by non-Docker tests
|
||||
|
||||
@ -43,7 +43,7 @@ the test cases that ran on sg_dev or local:
|
||||
| TC-DOCKER-03 (per-worker rate limit) | TC-GW-04 + TC-REENT-09 (single-worker rate limit + 5min expiry). The cross-worker divergence is an architectural property of the in-memory dict; no auth code path differs |
|
||||
| TC-DOCKER-04 (IM channels use internal auth) | Code-level: `app/channels/manager.py` creates the `langgraph_sdk` client with `create_internal_auth_headers()` plus CSRF cookie/header, so channel workers do not rely on browser cookies |
|
||||
| TC-DOCKER-05 (credential surfacing) | `reset_admin` writes `.deer-flow/admin_initial_credentials.txt` with mode 0600 and logs only the path — the only Docker-unique step is whether the bind mount projects this path onto the host, which is a `docker compose` config check, not a runtime behavior change |
|
||||
| TC-DOCKER-06 (gateway-mode container) | Section 七 7.2 covered by TC-GW-01..05 + Section 二 (gateway-mode auth flow on sg_dev) — same Gateway code, container is just a packaging change |
|
||||
| TC-DOCKER-06 (Gateway embedded runtime container) | Section 七 7.2 covered by TC-GW-01..05 + Section 二 (Gateway auth flow on sg_dev) — same Gateway code, container is just a packaging change |
|
||||
|
||||
## Reproduction steps when Docker becomes available
|
||||
|
||||
|
||||
@ -4,10 +4,12 @@
|
||||
|
||||
| 模式 | 启动命令 | Auth 层 | 端口 |
|
||||
|------|---------|---------|------|
|
||||
| 标准模式 | `make dev` | Gateway AuthMiddleware + LangGraph auth | 2026 (nginx) |
|
||||
| Gateway 模式 | `make dev-pro` | Gateway AuthMiddleware(全量) | 2026 (nginx) |
|
||||
| 标准模式 | `make dev` | Gateway AuthMiddleware(全量) | 2026 (nginx) |
|
||||
| 直连 Gateway | `cd backend && make gateway` | Gateway AuthMiddleware | 8001 |
|
||||
| 直连 LangGraph | `cd backend && make dev` | LangGraph auth | 2024 |
|
||||
| 直连 LangGraph 兼容性 | 手动运行 LangGraph 工具链时使用 | LangGraph auth | 2024 |
|
||||
|
||||
`make dev`、Docker dev 和生产部署默认都运行 Gateway embedded runtime。
|
||||
`app.gateway.langgraph_auth` 仅用于保留的直连 LangGraph 工具链 / Studio 兼容性测试,不是标准服务启动路径。
|
||||
|
||||
每种模式下都需执行以下测试。
|
||||
|
||||
@ -21,10 +23,8 @@
|
||||
# 清除已有数据
|
||||
rm -f backend/.deer-flow/data/deerflow.db
|
||||
|
||||
# 选择模式启动
|
||||
make dev # 标准模式
|
||||
# 或
|
||||
make dev-pro # Gateway 模式
|
||||
# 启动标准模式(Gateway embedded runtime)
|
||||
make dev
|
||||
```
|
||||
|
||||
**验证点:**
|
||||
@ -57,7 +57,7 @@ make dev
|
||||
|
||||
## 二、接口流程测试
|
||||
|
||||
> 以下用 `BASE=http://localhost:2026` 为例。标准模式和 Gateway 模式都用此地址。
|
||||
> 以下用 `BASE=http://localhost:2026` 为例。标准模式经 nginx 暴露此地址。
|
||||
> 直连测试替换为对应端口。
|
||||
>
|
||||
> **CSRF token 提取**:多处用到从 cookie jar 提取 CSRF token,统一使用:
|
||||
@ -211,20 +211,18 @@ curl -s -X POST $BASE/api/threads/search \
|
||||
|
||||
**预期:** 返回 0 或仅包含 user2 自己的 thread
|
||||
|
||||
### 2.3 标准模式 LangGraph Server 隔离
|
||||
### 2.3 LangGraph-compatible Gateway 路由隔离
|
||||
|
||||
> 仅在标准模式下测试。Gateway 模式不跑 LangGraph Server。
|
||||
|
||||
#### TC-API-10: LangGraph 端点需要 cookie
|
||||
#### TC-API-10: LangGraph-compatible 端点需要 cookie
|
||||
|
||||
```bash
|
||||
# 不带 cookie 访问 LangGraph 接口
|
||||
# 不带 cookie 访问 LangGraph-compatible 接口
|
||||
curl -s -w "%{http_code}" $BASE/api/langgraph/threads
|
||||
```
|
||||
|
||||
**预期:** 401
|
||||
|
||||
#### TC-API-11: LangGraph 带 cookie 可访问
|
||||
#### TC-API-11: LangGraph-compatible 路由带 cookie 可访问
|
||||
|
||||
```bash
|
||||
curl -s $BASE/api/langgraph/threads -b user1.txt | jq length
|
||||
@ -232,10 +230,10 @@ curl -s $BASE/api/langgraph/threads -b user1.txt | jq length
|
||||
|
||||
**预期:** 200,返回 user1 的 thread 列表
|
||||
|
||||
#### TC-API-12: LangGraph 隔离 — 用户只看到自己的
|
||||
#### TC-API-12: LangGraph-compatible 路由隔离 — 用户只看到自己的
|
||||
|
||||
```bash
|
||||
# user2 查 LangGraph threads
|
||||
# user2 查 threads
|
||||
curl -s $BASE/api/langgraph/threads -b user2.txt | jq length
|
||||
```
|
||||
|
||||
@ -1234,21 +1232,11 @@ P2=$(awk -F': ' '/^password:/ {print $2}' /tmp/deerflow-reset-p2.txt)
|
||||
## 七、模式差异测试
|
||||
|
||||
> 以下用 `GW=http://localhost:8001` 表示直连 Gateway,`BASE=http://localhost:2026` 表示经 nginx。
|
||||
> Gateway 模式启动命令:`make dev-pro`(或 `./scripts/serve.sh --dev --gateway`)。
|
||||
> 标准启动命令:`make dev`(或 `./scripts/serve.sh --dev`)。
|
||||
|
||||
### 7.1 标准模式独有
|
||||
### 7.1 标准启动模式
|
||||
|
||||
> 启动命令:`make dev`(或 `./scripts/serve.sh --dev`)
|
||||
|
||||
#### TC-MODE-01: LangGraph Server 独立运行,需 cookie
|
||||
|
||||
```bash
|
||||
# 无 cookie 访问 LangGraph
|
||||
curl -s -w "%{http_code}" -o /dev/null $BASE/api/langgraph/threads/search
|
||||
# 预期: 403(LangGraph auth handler 拒绝)
|
||||
```
|
||||
|
||||
#### TC-MODE-02: LangGraph auth 的 token_version 检查
|
||||
#### TC-MODE-01: Gateway AuthMiddleware 的 token_version 检查
|
||||
|
||||
```bash
|
||||
# 登录拿 cookie
|
||||
@ -1261,9 +1249,9 @@ curl -s -X POST $BASE/api/v1/auth/change-password \
|
||||
-b cookies.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF" \
|
||||
-d '{"current_password":"正确密码","new_password":"NewPass1!"}' -c new_cookies.txt
|
||||
|
||||
# 用旧 cookie 访问 LangGraph
|
||||
# 用旧 cookie 访问 LangGraph-compatible 路由
|
||||
curl -s -w "%{http_code}" $BASE/api/langgraph/threads/search -b cookies.txt
|
||||
# 预期: 403(token_version 不匹配)
|
||||
# 预期: 401(token_version 不匹配)
|
||||
|
||||
# 用新 cookie 访问
|
||||
CSRF2=$(grep csrf_token new_cookies.txt | awk '{print $NF}')
|
||||
@ -1272,7 +1260,7 @@ curl -s -w "%{http_code}" -X POST $BASE/api/langgraph/threads/search \
|
||||
# 预期: 200
|
||||
```
|
||||
|
||||
#### TC-MODE-03: LangGraph auth 的 owner filter 隔离
|
||||
#### TC-MODE-02: Gateway owner filter 隔离
|
||||
|
||||
```bash
|
||||
# user1 创建 thread
|
||||
@ -1297,18 +1285,9 @@ print('OK: user2 sees', len(threads), 'threads, none belong to user1')
|
||||
"
|
||||
```
|
||||
|
||||
### 7.2 Gateway 模式独有
|
||||
|
||||
> 启动命令:`make dev-pro`(或 `./scripts/serve.sh --dev --gateway`)
|
||||
> 无 LangGraph Server 进程,agent runtime 嵌入 Gateway。
|
||||
|
||||
#### TC-MODE-04: 所有请求经 AuthMiddleware
|
||||
#### TC-MODE-03: 所有请求经 AuthMiddleware
|
||||
|
||||
```bash
|
||||
# 确认 LangGraph Server 未运行
|
||||
curl -s -w "%{http_code}" -o /dev/null http://localhost:2024/ok
|
||||
# 预期: 000(连接被拒)
|
||||
|
||||
# Gateway API 受保护
|
||||
curl -s -w "%{http_code}" -o /dev/null $BASE/api/models
|
||||
# 预期: 401
|
||||
@ -1319,7 +1298,7 @@ curl -s -w "%{http_code}" -o /dev/null -X POST $BASE/api/langgraph/threads/searc
|
||||
# 预期: 401
|
||||
```
|
||||
|
||||
#### TC-MODE-05: Gateway 模式下完整 auth 流程
|
||||
#### TC-MODE-04: 标准模式下完整 auth 流程
|
||||
|
||||
```bash
|
||||
# 登录
|
||||
@ -1334,7 +1313,7 @@ curl -s -X POST $BASE/api/langgraph/threads \
|
||||
-d '{"metadata":{}}' | python3 -c "import sys,json; print(json.load(sys.stdin)['thread_id'])"
|
||||
# 预期: 返回 thread_id
|
||||
|
||||
# CSRF 保护(Gateway 模式下 CSRFMiddleware 直接覆盖所有路由)
|
||||
# CSRF 保护(CSRFMiddleware 覆盖所有 Gateway 路由)
|
||||
curl -s -w "%{http_code}" -o /dev/null -X POST $BASE/api/langgraph/threads \
|
||||
-b cookies.txt -H "Content-Type: application/json" -d '{"metadata":{}}'
|
||||
# 预期: 403(CSRF token missing)
|
||||
@ -1433,7 +1412,7 @@ done
|
||||
|
||||
### 7.4 Docker 部署
|
||||
|
||||
> 启动命令:`./scripts/deploy.sh`(标准)或 `./scripts/deploy.sh --gateway`(Gateway 模式)
|
||||
> 启动命令:`./scripts/deploy.sh`
|
||||
> Docker Compose 文件:`docker/docker-compose.yaml`
|
||||
>
|
||||
> 前置条件:
|
||||
@ -1542,16 +1521,16 @@ docker logs deer-flow-gateway 2>&1 | grep -iE "Password: .{15,}" && echo "FAIL:
|
||||
- 容器日志输出**路径**(不是密码本身),符合 CodeQL `py/clear-text-logging-sensitive-data` 规则
|
||||
- `grep "Password:"` 在日志中**应当无匹配**(旧行为已废弃,simplify pass 移除了日志泄露路径)
|
||||
|
||||
#### TC-DOCKER-06: Gateway 模式 Docker 部署
|
||||
#### TC-DOCKER-06: Docker 部署
|
||||
|
||||
```bash
|
||||
# Gateway 模式:无 langgraph 容器
|
||||
./scripts/deploy.sh --gateway
|
||||
# 标准 Docker 模式:runtime 嵌入 gateway 容器
|
||||
./scripts/deploy.sh
|
||||
sleep 15
|
||||
|
||||
# 确认 langgraph 容器不存在
|
||||
docker ps --filter name=deer-flow-langgraph --format '{{.Names}}' | wc -l
|
||||
# 预期: 0
|
||||
# 确认 gateway 容器存在
|
||||
docker ps --filter name=deer-flow-gateway --format '{{.Names}}'
|
||||
# 预期: deer-flow-gateway
|
||||
|
||||
# auth 流程正常:未登录受保护接口返回 401
|
||||
curl -s -w "%{http_code}" -o /dev/null $BASE/api/models
|
||||
|
||||
@ -124,8 +124,8 @@ python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
|
||||
## 兼容性
|
||||
|
||||
- **标准模式**(`make dev`):完全兼容;无 admin 时访问 `/setup` 初始化
|
||||
- **Gateway 模式**(`make dev-pro`):完全兼容
|
||||
- **本地开发**(`make dev`):Gateway embedded runtime 完全兼容;无 admin 时访问 `/setup` 初始化
|
||||
- **Gateway embedded runtime**:标准脚本、Docker dev 和生产部署均通过 Gateway 提供认证与 LangGraph-compatible API
|
||||
- **Docker 部署**:完全兼容,`.deer-flow/data/deerflow.db` 需持久化卷挂载
|
||||
- **IM 渠道**(Feishu/Slack/Telegram):通过 Gateway 内部认证通信,使用 `default` 用户桶
|
||||
- **DeerFlowClient**(嵌入式):不经过 HTTP,不受认证影响
|
||||
|
||||
154
backend/docs/BLOCKING_IO_DETECTION.md
Normal file
154
backend/docs/BLOCKING_IO_DETECTION.md
Normal file
@ -0,0 +1,154 @@
|
||||
# Blocking IO detection usage and maintenance
|
||||
|
||||
This document describes how to use and maintain DeerFlow backend blocking-IO
|
||||
detection for async event-loop safety.
|
||||
|
||||
The goal is narrow: find and prevent synchronous IO from blocking backend
|
||||
async event-loop paths. Static and runtime detection are complementary, but
|
||||
they have different jobs.
|
||||
|
||||
## Static detector
|
||||
|
||||
The static detector is the discovery tool. It scans backend source code and
|
||||
reports candidate blocking-IO call sites that may need human review.
|
||||
|
||||
Run it from the repository root:
|
||||
|
||||
```bash
|
||||
make detect-blocking-io
|
||||
```
|
||||
|
||||
Or from `backend/`:
|
||||
|
||||
```bash
|
||||
make detect-blocking-io
|
||||
```
|
||||
|
||||
The report is written to:
|
||||
|
||||
```text
|
||||
.deer-flow/blocking-io-findings.json
|
||||
```
|
||||
|
||||
Use this output for review and triage. A static finding is a candidate, not
|
||||
proof that production blocks the event loop at runtime. The current static
|
||||
rules are intentionally broad; prefer triaging existing output before adding
|
||||
new static rules.
|
||||
|
||||
Add a static rule only when review finds a recurring high-risk blocking
|
||||
pattern that is invisible to the current detector.
|
||||
|
||||
## Runtime detector
|
||||
|
||||
The runtime detector is the CI regression guard. It uses Blockbuster to fail a
|
||||
focused test when code under `app.*` or `deerflow.*` performs blocking IO on
|
||||
the asyncio event-loop thread.
|
||||
|
||||
Run it from `backend/`:
|
||||
|
||||
```bash
|
||||
make test-blocking-io
|
||||
```
|
||||
|
||||
The runtime gate starts from confirmed production bugs and protects those
|
||||
paths from regressing. It does not prove that the entire backend is free of
|
||||
blocking IO; it only covers the production paths exercised by
|
||||
`backend/tests/blocking_io/`.
|
||||
|
||||
## Maintenance workflow
|
||||
|
||||
Use the static detector to find candidates, then use review to decide which
|
||||
async production paths are worth protecting in CI.
|
||||
|
||||
The normal workflow is:
|
||||
|
||||
1. Run the static detector to find backend blocking-IO candidates.
|
||||
2. Use human review to pick high-risk production async paths.
|
||||
3. Add or update a focused runtime anchor in `backend/tests/blocking_io/`.
|
||||
4. Let CI prevent that path from regressing.
|
||||
|
||||
Runtime detection has two maintenance paths.
|
||||
|
||||
### Add a runtime rule
|
||||
|
||||
Add a runtime rule when Blockbuster's default rules do not cover a generic
|
||||
blocking primitive used by production code.
|
||||
|
||||
Rules belong in:
|
||||
|
||||
```text
|
||||
backend/tests/support/detectors/blocking_io_runtime.py
|
||||
```
|
||||
|
||||
Add them to `_PROJECT_BLOCKING_RULES`, not directly inside individual tests.
|
||||
Keeping rules centralized makes it clear which extra primitives DeerFlow
|
||||
expects Blockbuster to catch.
|
||||
|
||||
Example shape:
|
||||
|
||||
```python
|
||||
import subprocess
|
||||
|
||||
from blockbuster import BlockBusterFunction
|
||||
|
||||
_PROJECT_BLOCKING_RULES = (
|
||||
(
|
||||
"subprocess.Popen.__init__",
|
||||
BlockBusterFunction(
|
||||
subprocess.Popen,
|
||||
"__init__",
|
||||
scanned_modules=["app", "deerflow"],
|
||||
),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
Do not add a runtime rule just because a business path is not tested. A rule
|
||||
only expands what Blockbuster can intercept after code runs.
|
||||
|
||||
### Add a runtime anchor
|
||||
|
||||
Add a runtime anchor when a high-risk async production path should be protected
|
||||
by CI but no existing `backend/tests/blocking_io/` test executes it.
|
||||
|
||||
Anchors belong in:
|
||||
|
||||
```text
|
||||
backend/tests/blocking_io/
|
||||
```
|
||||
|
||||
A good anchor should:
|
||||
|
||||
- Call the real production async entry point.
|
||||
- Avoid bypassing the blocking surface with test-only `asyncio.to_thread`
|
||||
wrappers.
|
||||
- Use real local filesystem inputs when the bug shape is filesystem IO.
|
||||
- Mock only the external dependency boundary, such as a network service or
|
||||
third-party saver class.
|
||||
- Fail if a future change moves the blocking operation back onto the event
|
||||
loop.
|
||||
|
||||
Avoid testing only the low-level helper unless that helper is the production
|
||||
async entry point. The runtime gate is most useful when it protects the caller
|
||||
that production actually executes.
|
||||
|
||||
## Current runtime coverage
|
||||
|
||||
The runtime anchors protect confirmed blocking-IO bug shapes:
|
||||
|
||||
- SQLite checkpointer setup, including path resolution and parent-directory
|
||||
creation.
|
||||
- Subagent skill metadata loading through `SubagentExecutor._load_skills()`.
|
||||
- `JsonlRunEventStore` async API (`put` / `list_*` / `delete_*`): the JSONL
|
||||
run-event backend offloads its synchronous file IO via `asyncio.to_thread`
|
||||
(fix #3084); this anchor drives the real async API under the gate so any
|
||||
blocking IO reintroduced on the loop fails, not only removal of one
|
||||
`to_thread` call.
|
||||
- `UploadsMiddleware.before_agent` uploads-directory scan: a sync-only middleware
|
||||
hook runs on the event loop under async graph execution, so the scan is
|
||||
offloaded via `abefore_agent` + `run_in_executor`.
|
||||
- Gate health checks: Blockbuster catches unoffloaded calls, opt-out works, and
|
||||
patches are restored after exceptions.
|
||||
|
||||
As static detection and review identify more high-risk async paths, add new
|
||||
runtime anchors incrementally.
|
||||
@ -36,6 +36,7 @@ models:
|
||||
- OpenAI (`langchain_openai:ChatOpenAI`)
|
||||
- Anthropic (`langchain_anthropic:ChatAnthropic`)
|
||||
- DeepSeek (`langchain_deepseek:ChatDeepSeek`)
|
||||
- Xiaomi MiMo (`deerflow.models.patched_mimo:PatchedChatMiMo`)
|
||||
- Claude Code OAuth (`deerflow.models.claude_provider:ClaudeChatModel`)
|
||||
- Codex CLI (`deerflow.models.openai_codex_provider:CodexChatModel`)
|
||||
- Any LangChain-compatible provider
|
||||
@ -94,25 +95,35 @@ models:
|
||||
thinking:
|
||||
type: enabled
|
||||
|
||||
- name: minimax-m2.5
|
||||
display_name: MiniMax M2.5
|
||||
- name: minimax-m3
|
||||
display_name: MiniMax M3
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: MiniMax-M2.5
|
||||
model: MiniMax-M3
|
||||
api_key: $MINIMAX_API_KEY
|
||||
base_url: https://api.minimax.io/v1
|
||||
max_tokens: 4096
|
||||
temperature: 1.0 # MiniMax requires temperature in (0.0, 1.0]
|
||||
supports_vision: true
|
||||
|
||||
- name: minimax-m2.5-highspeed
|
||||
display_name: MiniMax M2.5 Highspeed
|
||||
- name: minimax-m2.7
|
||||
display_name: MiniMax M2.7
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: MiniMax-M2.5-highspeed
|
||||
model: MiniMax-M2.7
|
||||
api_key: $MINIMAX_API_KEY
|
||||
base_url: https://api.minimax.io/v1
|
||||
max_tokens: 4096
|
||||
temperature: 1.0 # MiniMax requires temperature in (0.0, 1.0]
|
||||
supports_vision: true
|
||||
supports_vision: false # M2.7 is text-only; M3 supports vision
|
||||
|
||||
- name: minimax-m2.7-highspeed
|
||||
display_name: MiniMax M2.7 Highspeed
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: MiniMax-M2.7-highspeed
|
||||
api_key: $MINIMAX_API_KEY
|
||||
base_url: https://api.minimax.io/v1
|
||||
max_tokens: 4096
|
||||
temperature: 1.0 # MiniMax requires temperature in (0.0, 1.0]
|
||||
supports_vision: false # M2.7 is text-only; M3 supports vision
|
||||
- name: openrouter-gemini-2.5-flash
|
||||
display_name: Gemini 2.5 Flash (OpenRouter)
|
||||
use: langchain_openai:ChatOpenAI
|
||||
@ -166,6 +177,37 @@ models:
|
||||
|
||||
For Gemini accessed **without** thinking (e.g. via OpenRouter where thinking is not activated), the plain `langchain_openai:ChatOpenAI` with `supports_thinking: false` is sufficient and no patch is needed.
|
||||
|
||||
**MiMo with thinking via OpenAI-compatible API**:
|
||||
|
||||
MiMo returns `reasoning_content` on assistant messages in thinking mode. In multi-turn agent conversations with tool calls, subsequent requests must preserve that historical `reasoning_content` on assistant messages or the MiMo API can return HTTP 400. Standard `langchain_openai:ChatOpenAI` drops this provider-specific field, so use `deerflow.models.patched_mimo:PatchedChatMiMo`:
|
||||
|
||||
For pay-as-you-go API keys (`sk-...`), use `https://api.xiaomimimo.com/v1`. For Token Plan keys (`tp-...`), use the regional Token Plan Base URL shown in the MiMo console, such as `https://token-plan-cn.xiaomimimo.com/v1`. MiMo documents these key types as separate and non-interchangeable.
|
||||
|
||||
`PatchedChatMiMo` is model-id agnostic. Use it for every MiMo thinking model entry you configure, including model entries referenced by `subagents.*.model` overrides (for example `mimo-v2.5-pro`, `mimo-v2.5`, `mimo-v2-pro`, `mimo-v2-omni`, or `mimo-v2-flash`).
|
||||
|
||||
```yaml
|
||||
models:
|
||||
- name: mimo-v2.5-pro
|
||||
display_name: MiMo V2.5 Pro
|
||||
use: deerflow.models.patched_mimo:PatchedChatMiMo
|
||||
model: mimo-v2.5-pro
|
||||
api_key: $MIMO_API_KEY
|
||||
base_url: https://api.xiaomimimo.com/v1
|
||||
max_tokens: 8192
|
||||
supports_thinking: true
|
||||
supports_vision: false
|
||||
when_thinking_enabled:
|
||||
extra_body:
|
||||
thinking:
|
||||
type: enabled
|
||||
when_thinking_disabled:
|
||||
extra_body:
|
||||
thinking:
|
||||
type: disabled
|
||||
```
|
||||
|
||||
`PatchedChatMiMo` preserves MiMo's `choices[].message.reasoning_content`, streaming `delta.reasoning_content`, and request-history assistant `reasoning_content` fields. It does not reuse the DeepSeek provider.
|
||||
|
||||
### Tool Groups
|
||||
|
||||
Organize tools into logical groups:
|
||||
@ -319,6 +361,7 @@ models:
|
||||
- `OPENAI_API_KEY` - OpenAI API key
|
||||
- `ANTHROPIC_API_KEY` - Anthropic API key
|
||||
- `DEEPSEEK_API_KEY` - DeepSeek API key
|
||||
- `MIMO_API_KEY` - Xiaomi MiMo API key
|
||||
- `NOVITA_API_KEY` - Novita API key (OpenAI-compatible endpoint)
|
||||
- `TAVILY_API_KEY` - Tavily search API key
|
||||
- `DEER_FLOW_PROJECT_ROOT` - Project root for relative runtime paths
|
||||
|
||||
@ -14,6 +14,19 @@ DeerFlow supports configurable MCP servers and skills to extend its capabilities
|
||||
3. Configure each server’s command, arguments, and environment variables as needed.
|
||||
4. Restart the application to load and register MCP tools.
|
||||
|
||||
## Filesystem MCP Servers
|
||||
|
||||
DeerFlow already provides built-in file tools for thread-scoped workspace access.
|
||||
Do not add an MCP filesystem server for the same DeerFlow workspace. The
|
||||
overlapping file tools use different path semantics, which can make LLM tool
|
||||
selection and file access behavior unstable.
|
||||
|
||||
DeerFlow does not currently adapt the MCP Roots mode for filesystem servers. In
|
||||
particular, it does not publish per-thread MCP roots or map DeerFlow sandbox
|
||||
paths such as `/mnt/user-data/...` to paths accepted by
|
||||
`@modelcontextprotocol/server-filesystem`. Use DeerFlow's built-in file tools
|
||||
for DeerFlow workspace files.
|
||||
|
||||
## OAuth Support (HTTP/SSE MCP Servers)
|
||||
|
||||
For `http` and `sse` MCP servers, DeerFlow supports OAuth token acquisition and automatic token refresh.
|
||||
@ -88,7 +101,6 @@ MCP servers expose tools that are automatically discovered and integrated into D
|
||||
|
||||
MCP servers can provide access to:
|
||||
|
||||
- **File systems**
|
||||
- **Databases** (e.g., PostgreSQL)
|
||||
- **External APIs** (e.g., GitHub, Brave Search)
|
||||
- **Browser automation** (e.g., Puppeteer)
|
||||
@ -97,4 +109,4 @@ MCP servers can provide access to:
|
||||
## Learn More
|
||||
|
||||
For detailed documentation about the Model Context Protocol, visit:
|
||||
https://modelcontextprotocol.io
|
||||
https://modelcontextprotocol.io
|
||||
|
||||
@ -19,6 +19,7 @@ This directory contains detailed documentation for the DeerFlow backend.
|
||||
| [STREAMING.md](STREAMING.md) | Token-level streaming design: Gateway vs DeerFlowClient paths, `stream_mode` semantics, per-id dedup |
|
||||
| [FILE_UPLOAD.md](FILE_UPLOAD.md) | File upload functionality |
|
||||
| [PATH_EXAMPLES.md](PATH_EXAMPLES.md) | Path types and usage examples |
|
||||
| [SANDBOX_MEMORY_PROFILING.md](SANDBOX_MEMORY_PROFILING.md) | Sandbox memory baseline and runtime comparison guide |
|
||||
| [summarization.md](summarization.md) | Context summarization feature |
|
||||
| [plan_mode_usage.md](plan_mode_usage.md) | Plan mode with TodoList |
|
||||
| [AUTO_TITLE_GENERATION.md](AUTO_TITLE_GENERATION.md) | Automatic title generation |
|
||||
|
||||
120
backend/docs/REPLAY_E2E.md
Normal file
120
backend/docs/REPLAY_E2E.md
Normal file
@ -0,0 +1,120 @@
|
||||
# Record/Replay E2E — front-back contract verification
|
||||
|
||||
Deterministic, **key-free** end-to-end checks that a backend change can't
|
||||
silently break the frontend (and vice-versa). Two complementary layers, fed by a
|
||||
single recording.
|
||||
|
||||
## Why
|
||||
|
||||
The mock-based frontend e2e hand-writes the backend's JSON/SSE, so a backend
|
||||
schema or SSE change passes green ("fake green"). These layers replay a recorded
|
||||
**real** run against the **real** backend (and, for Layer 2, the real frontend),
|
||||
so contract drift turns the build red instead.
|
||||
|
||||
## The two layers
|
||||
|
||||
- **Layer 1 — backend golden** (`tests/test_replay_golden.py`): replays a fixture
|
||||
through the real FastAPI gateway with `ReplayChatModel` and asserts the streamed
|
||||
SSE event sequence equals a committed golden. Fast, no browser. Guards protocol
|
||||
*shape*.
|
||||
- **Layer 2 — full-stack render** (`frontend/tests/e2e-real-backend/`): real
|
||||
Next.js + real gateway (replay model) + Chromium; asserts the replayed
|
||||
auto-title and a follow-up suggestion render in the browser. Guards semantic
|
||||
*render*. (Complementary to Layer 1 — neither subsumes the other.)
|
||||
|
||||
Layer 2 also hosts **cross-stack contract scenarios** — the dangerous class
|
||||
where a backend change silently breaks a frontend assumption and *both sides'
|
||||
unit tests stay green*. See below.
|
||||
|
||||
## Cross-stack scenario: multi-run render order (`multi-run-order.spec.ts`)
|
||||
|
||||
Regression guard for issue **#3352** (after context compression, refreshing a
|
||||
thread rendered history out of order). Root cause was a front-back desync:
|
||||
backend `RunManager.list_by_thread` returns runs **newest-first** (PR #2932),
|
||||
while the frontend (`core/threads/hooks.ts`) iterated runs and **prepended** each
|
||||
loaded page — inverting chronological order once the checkpoint no longer held
|
||||
the older messages. The backend ordering test was green throughout, and the
|
||||
frontend regression unit test hardcodes "backend returns newest-first" in a mock,
|
||||
so only a *real frontend against a real backend* catches the desync.
|
||||
|
||||
This scenario does **not** record a conversation. It uses a **test-only seeder**
|
||||
(`tests/seed_runs_router.py`, mounted on the replay gateway only when
|
||||
`DEERFLOW_ENABLE_TEST_SEED=1`) to stand up a thread with ≥2 runs and per-run
|
||||
message events — and deliberately **no checkpoint**, which is the #3352
|
||||
precondition: it forces the frontend's per-run reload path to be the sole source
|
||||
of truth so the ordering bug becomes observable. The seeder writes through the
|
||||
gateway's own run/event stores using the request's auth context, so the real
|
||||
`list_by_thread` → `/runs/{id}/messages` → prepend path runs live. Reverting the
|
||||
#3354 frontend fix turns this spec red.
|
||||
|
||||
## How replay works
|
||||
|
||||
`tests/replay_provider.py::ReplayChatModel` returns recorded assistant turns keyed
|
||||
by a **normalized hash of the model caller + conversation**. The conversation is
|
||||
human / ai / tool messages — role, text, tool-call name+args; with
|
||||
`<system-reminder>`, dates, UUIDs, tmp paths stripped. The caller is the stable
|
||||
source of the model call (`lead_agent`, `middleware:title`, `suggest_agent`,
|
||||
`subagent:*`, etc.). A miss raises loudly rather than passing silently.
|
||||
|
||||
**The system prompt is excluded from the match key.** The lead-agent system
|
||||
prompt is a living, frequently-edited implementation detail — its wording changes
|
||||
across PRs (e.g. #3195 added a "File Editing Workflow" section). Hashing it would
|
||||
make every fixture go stale and red-fail unrelated PRs the moment anyone edits the
|
||||
prompt. The conversation flow (user input → tool calls → results → answer) is the
|
||||
stable contract that identifies a recorded turn. The caller still stays in the
|
||||
key so two different model users with identical conversation text do not compete
|
||||
for the same replay bucket. (This mirrors how open-design's mock picker keys on
|
||||
the user prompt, not the system internals.) Combined with pinning skills +
|
||||
extensions empty and disabling memory/summarization
|
||||
(`tests/_replay_fixture.py::build_config_yaml`), a fixture replays the same across
|
||||
machines, days, prompt edits, and CI. Replaying needs **no API key**.
|
||||
|
||||
A swallowed hash-miss keeps the SSE *event shapes* identical (the gateway wraps it
|
||||
into a normal assistant error message), so the Layer-1 golden can't catch a miss
|
||||
by shape alone — it inspects `replay_provider.replay_misses()` and fails loud
|
||||
instead. Layer-2 already fails on a miss (the recorded turns never render).
|
||||
|
||||
## Record a new scenario (needs a real key — dev machine only)
|
||||
|
||||
Recording drives the **real frontend** so captured inputs match exactly what the
|
||||
browser sends; fixtures contain no API key.
|
||||
|
||||
```bash
|
||||
# 1. drive the real frontend against a real-model gateway, capturing model calls
|
||||
OPENAI_API_KEY=... OPENAI_API_BASE=<openai-compatible-endpoint>/v1 \
|
||||
DEERFLOW_RECORD_OUT=/tmp/rec/turns.jsonl RECORD_MODEL=<model> \
|
||||
bash -c 'cd frontend && pnpm exec playwright test -c playwright.record.config.ts'
|
||||
|
||||
# 2. stitch the capture into a fixture
|
||||
cd backend && uv run python scripts/build_fixture_from_jsonl.py \
|
||||
--jsonl /tmp/rec/turns.jsonl --meta /tmp/rec/turns.jsonl.meta.json \
|
||||
--out tests/fixtures/replay/<scenario>.<mode>.json --model <model>
|
||||
|
||||
# 3. regenerate the committed golden
|
||||
DEERFLOW_WRITE_GOLDEN=1 PYTHONPATH=. uv run pytest tests/test_replay_golden.py
|
||||
```
|
||||
|
||||
## Run (no key)
|
||||
|
||||
```bash
|
||||
cd backend && PYTHONPATH=. uv run pytest tests/test_replay_golden.py # Layer 1
|
||||
cd frontend && pnpm exec playwright test -c playwright.real-backend.config.ts # Layer 2
|
||||
```
|
||||
|
||||
## CI
|
||||
|
||||
`.github/workflows/replay-e2e.yml` runs both layers on changes to **either** side
|
||||
of the contract (`frontend/**`, `backend/app/gateway/**`,
|
||||
`backend/packages/harness/**`, fixtures). DOM assertions are the gate; the rendered
|
||||
screenshot + Playwright HTML report are uploaded as a CI artifact.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Visual regression baselines are OS-specific, so they are a **local dev gate
|
||||
only** (gitignored); CI uploads the render as an artifact for human review
|
||||
instead of hard-asserting a cross-OS baseline.
|
||||
- Fixtures are coupled to the recording-time prompt; if new
|
||||
environment-dependent content enters the system prompt, extend the
|
||||
normalization in `replay_provider.py` (or pin it in `build_config_yaml`).
|
||||
- Re-record a scenario if the agent graph changes how many model calls it makes
|
||||
— the replay raises loudly on a hash miss pointing at the divergence.
|
||||
81
backend/docs/SANDBOX_MEMORY_PROFILING.md
Normal file
81
backend/docs/SANDBOX_MEMORY_PROFILING.md
Normal file
@ -0,0 +1,81 @@
|
||||
# Sandbox Memory Profiling
|
||||
|
||||
This guide records a repeatable baseline before changing the sandbox runtime.
|
||||
Issue #3213 reports per-sandbox memory near 1 GiB in Kubernetes. Before adding
|
||||
or recommending a new provider, capture the current AIO sandbox baseline and
|
||||
compare candidates with the same DeerFlow workload.
|
||||
|
||||
## What to Measure
|
||||
|
||||
Measure at least these samples:
|
||||
|
||||
1. Empty sandbox after it becomes ready.
|
||||
2. After a simple bash command.
|
||||
3. After a Python task that imports common packages.
|
||||
4. After a Node task when Node-based workloads are expected.
|
||||
5. After generating files under `/mnt/user-data/outputs`.
|
||||
6. After release and warm reuse.
|
||||
7. At the target concurrency level, for example 10, 50, or 100 sandboxes.
|
||||
|
||||
`kubectl top` reports Kubernetes/container working set memory. Treat it as a
|
||||
capacity signal, not exclusive RSS/PSS. Pod-level memory includes every
|
||||
container in the Pod and may include cache charged to the cgroup. If a result
|
||||
looks surprising, inspect the sandbox processes and cgroup metrics on the node
|
||||
before drawing conclusions.
|
||||
|
||||
## Capture a Snapshot
|
||||
|
||||
Run this from the repository root:
|
||||
|
||||
```bash
|
||||
python scripts/sandbox_memory_profile.py \
|
||||
--namespace deer-flow \
|
||||
--selector app=deer-flow-sandbox \
|
||||
--sample empty \
|
||||
--include-processes \
|
||||
--format markdown
|
||||
```
|
||||
|
||||
Use a descriptive `--sample` value for each phase:
|
||||
|
||||
```bash
|
||||
python scripts/sandbox_memory_profile.py --sample after-bash --format json
|
||||
python scripts/sandbox_memory_profile.py --sample after-python --format json
|
||||
python scripts/sandbox_memory_profile.py --sample after-artifact --format json
|
||||
```
|
||||
|
||||
`--include-processes` runs `kubectl exec ... ps` in each sandbox Pod and adds
|
||||
the highest-RSS processes to the report. This helps distinguish Pod-level cgroup
|
||||
memory from process RSS. The two numbers will not match exactly because cgroup
|
||||
memory can include cache and other kernel-accounted memory.
|
||||
|
||||
Save the raw JSON when comparing backends so totals, pod names, images,
|
||||
requests, limits, and timestamps can be audited later.
|
||||
|
||||
## Candidate Runtime Matrix
|
||||
|
||||
For AIO, CubeSandbox, OpenSandbox, gVisor, Kata, or another candidate, compare
|
||||
the same workload and record:
|
||||
|
||||
| Area | Required Evidence |
|
||||
| --- | --- |
|
||||
| Capacity | Pod or instance count, total memory, average memory, max memory |
|
||||
| Startup | Ready latency at 1, 10, 50, and 100 concurrent sandboxes |
|
||||
| Commands | Bash output, timeout behavior, failure shape |
|
||||
| Files | `read_file`, `write_file`, binary `update_file`, `list_dir`, `glob`, `grep` |
|
||||
| Uploads | Files uploaded by the gateway are visible inside the sandbox |
|
||||
| Artifacts | Files written to `/mnt/user-data/outputs` are readable by the backend artifact API |
|
||||
| Paths | `/mnt/user-data/workspace`, `/mnt/user-data/uploads`, `/mnt/user-data/outputs`, `/mnt/acp-workspace`, and skills paths keep their expected semantics |
|
||||
| Isolation | Different users and threads cannot read each other's data |
|
||||
| Cleanup | Release, idle timeout, process restart, and orphan cleanup free resources |
|
||||
| Operations | Deployment prerequisites, privileged components, networking, storage, and upgrade path |
|
||||
|
||||
## PR Guidance
|
||||
|
||||
Do not claim that a new provider fixes high-concurrency memory usage until the
|
||||
same DeerFlow workload has been measured on both the current AIO sandbox and the
|
||||
candidate backend.
|
||||
|
||||
For an experimental provider PR, prefer `Related to #3213` unless the PR also
|
||||
includes reproducible DeerFlow workload data that demonstrates the target memory
|
||||
reduction and preserves uploads, outputs, artifacts, and isolation behavior.
|
||||
@ -26,7 +26,7 @@
|
||||
- Replace sync `requests` with `httpx.AsyncClient` in community tools (tavily, jina_ai, firecrawl, infoquest, image_search)
|
||||
- [x] Replace sync `model.invoke()` with async `model.ainvoke()` in title_middleware and memory updater
|
||||
- Consider `asyncio.to_thread()` wrapper for remaining blocking file I/O
|
||||
- For production: use `langgraph up` (multi-worker) instead of `langgraph dev` (single-worker)
|
||||
- For production: tune Gateway worker/runtime settings for long-running agent workloads
|
||||
|
||||
## Resolved Issues
|
||||
|
||||
|
||||
@ -4,22 +4,22 @@
|
||||
|
||||
`create_deerflow_agent` 通过 `RuntimeFeatures` 组装的完整 middleware 链(默认全开时):
|
||||
|
||||
| # | Middleware | `before_agent` | `before_model` | `after_model` | `after_agent` | `wrap_tool_call` | 主 Agent | Subagent | 来源 |
|
||||
|---|-----------|:-:|:-:|:-:|:-:|:-:|:-:|:-:|------|
|
||||
| 0 | ThreadDataMiddleware | ✓ | | | | | ✓ | ✓ | `sandbox` |
|
||||
| 1 | UploadsMiddleware | ✓ | | | | | ✓ | ✗ | `sandbox` |
|
||||
| 2 | SandboxMiddleware | ✓ | | | ✓ | | ✓ | ✓ | `sandbox` |
|
||||
| 3 | DanglingToolCallMiddleware | | | ✓ | | | ✓ | ✗ | 始终开启 |
|
||||
| 4 | GuardrailMiddleware | | | | | ✓ | ✓ | ✓ | *Phase 2 纳入* |
|
||||
| 5 | ToolErrorHandlingMiddleware | | | | | ✓ | ✓ | ✓ | 始终开启 |
|
||||
| 6 | SummarizationMiddleware | | | ✓ | | | ✓ | ✗ | `summarization` |
|
||||
| 7 | TodoMiddleware | | | ✓ | | | ✓ | ✗ | `plan_mode` 参数 |
|
||||
| 8 | TitleMiddleware | | | ✓ | | | ✓ | ✗ | `auto_title` |
|
||||
| 9 | MemoryMiddleware | | | | ✓ | | ✓ | ✗ | `memory` |
|
||||
| 10 | ViewImageMiddleware | | ✓ | | | | ✓ | ✗ | `vision` |
|
||||
| 11 | SubagentLimitMiddleware | | | ✓ | | | ✓ | ✗ | `subagent` |
|
||||
| 12 | LoopDetectionMiddleware | | | ✓ | | | ✓ | ✗ | 始终开启 |
|
||||
| 13 | ClarificationMiddleware | | | ✓ | | | ✓ | ✗ | 始终最后 |
|
||||
| # | Middleware | `before_agent` | `before_model` | `after_model` | `after_agent` | `wrap_model_call` | `wrap_tool_call` | 主 Agent | Subagent | 来源 |
|
||||
|---|-----------|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|------|
|
||||
| 0 | ThreadDataMiddleware | ✓ | | | | | | ✓ | ✓ | `sandbox` |
|
||||
| 1 | UploadsMiddleware | ✓ | | | | | | ✓ | ✗ | `sandbox` |
|
||||
| 2 | SandboxMiddleware | ✓ | | | ✓ | | | ✓ | ✓ | `sandbox` |
|
||||
| 3 | DanglingToolCallMiddleware | | | | | ✓ | | ✓ | ✗ | 始终开启 |
|
||||
| 4 | GuardrailMiddleware | | | | | | ✓ | ✓ | ✓ | *Phase 2 纳入* |
|
||||
| 5 | ToolErrorHandlingMiddleware | | | | | | ✓ | ✓ | ✓ | 始终开启 |
|
||||
| 6 | SummarizationMiddleware | | ✓ | | | | | ✓ | ✗ | `summarization` |
|
||||
| 7 | TodoMiddleware | | ✓ | ✓ | | ✓ | | ✓ | ✗ | `plan_mode` 参数 |
|
||||
| 8 | TitleMiddleware | | | ✓ | | | | ✓ | ✗ | `auto_title` |
|
||||
| 9 | MemoryMiddleware | | | | ✓ | | | ✓ | ✗ | `memory` |
|
||||
| 10 | ViewImageMiddleware | | ✓ | | | | | ✓ | ✗ | `vision` |
|
||||
| 11 | SubagentLimitMiddleware | | | ✓ | | | | ✓ | ✗ | `subagent` |
|
||||
| 12 | LoopDetectionMiddleware | ✓ | | ✓ | ✓ | ✓ | | ✓ | ✗ | 始终开启 |
|
||||
| 13 | ClarificationMiddleware | | | | | | ✓ | ✓ | ✗ | 始终最后 |
|
||||
|
||||
主 agent **14 个** middleware(`make_lead_agent`),subagent **4 个**(ThreadData、Sandbox、Guardrail、ToolErrorHandling)。`create_deerflow_agent` Phase 1 实现 **13 个**(Guardrail 仅支持自定义实例,无内置默认)。
|
||||
|
||||
@ -35,7 +35,7 @@ graph TB
|
||||
|
||||
subgraph BA ["<b>before_agent</b> 正序 0→N"]
|
||||
direction TB
|
||||
TD["[0] ThreadData<br/>创建线程目录"] --> UL["[1] Uploads<br/>扫描上传文件"] --> SB["[2] Sandbox<br/>获取沙箱"]
|
||||
TD["[0] ThreadData<br/>创建线程目录"] --> UL["[1] Uploads<br/>扫描上传文件"] --> SB["[2] Sandbox<br/>获取沙箱"] --> LD_BA["[12] LoopDetection<br/>清理 stale warning"]
|
||||
end
|
||||
|
||||
subgraph BM ["<b>before_model</b> 正序 0→N"]
|
||||
@ -43,34 +43,42 @@ graph TB
|
||||
VI["[10] ViewImage<br/>注入图片 base64"]
|
||||
end
|
||||
|
||||
SB --> VI
|
||||
VI --> M["<b>MODEL</b>"]
|
||||
subgraph WM ["<b>wrap_model_call</b>"]
|
||||
direction TB
|
||||
DTC_WM["[3] DanglingToolCall<br/>补悬空 ToolMessage"] --> LD_WM["[12] LoopDetection<br/>注入当前 run warning"]
|
||||
end
|
||||
|
||||
LD_BA --> VI
|
||||
VI --> DTC_WM
|
||||
LD_WM --> M["<b>MODEL</b>"]
|
||||
|
||||
subgraph AM ["<b>after_model</b> 反序 N→0"]
|
||||
direction TB
|
||||
CL["[13] Clarification<br/>拦截 ask_clarification"] --> LD["[12] LoopDetection<br/>检测循环"] --> SL["[11] SubagentLimit<br/>截断多余 task"] --> TI["[8] Title<br/>生成标题"] --> SM["[6] Summarization<br/>上下文压缩"] --> DTC["[3] DanglingToolCall<br/>补缺失 ToolMessage"]
|
||||
LD["[12] LoopDetection<br/>检测循环/排队 warning"] --> SL["[11] SubagentLimit<br/>截断多余 task"] --> TI["[8] Title<br/>生成标题"]
|
||||
end
|
||||
|
||||
M --> CL
|
||||
M --> LD
|
||||
|
||||
subgraph AA ["<b>after_agent</b> 反序 N→0"]
|
||||
direction TB
|
||||
SBR["[2] Sandbox<br/>释放沙箱"] --> MEM["[9] Memory<br/>入队记忆"]
|
||||
LD_CLEAN["[12] LoopDetection<br/>清理 pending warning"] --> MEM["[9] Memory<br/>入队记忆"] --> SBR["[2] Sandbox<br/>释放沙箱"]
|
||||
end
|
||||
|
||||
DTC --> SBR
|
||||
MEM --> END(["response"])
|
||||
TI --> LD_CLEAN
|
||||
SBR --> END(["response"])
|
||||
|
||||
classDef beforeNode fill:#a0a8b5,stroke:#636b7a,color:#2d3239
|
||||
classDef modelNode fill:#b5a8a0,stroke:#7a6b63,color:#2d3239
|
||||
classDef wrapModelNode fill:#a8a0b5,stroke:#6b637a,color:#2d3239
|
||||
classDef afterModelNode fill:#b5a0a8,stroke:#7a636b,color:#2d3239
|
||||
classDef afterAgentNode fill:#a0b5a8,stroke:#637a6b,color:#2d3239
|
||||
classDef terminalNode fill:#a8b5a0,stroke:#6b7a63,color:#2d3239
|
||||
|
||||
class TD,UL,SB,VI beforeNode
|
||||
class TD,UL,SB,LD_BA,VI beforeNode
|
||||
class DTC_WM,LD_WM wrapModelNode
|
||||
class M modelNode
|
||||
class CL,LD,SL,TI,SM,DTC afterModelNode
|
||||
class SBR,MEM afterAgentNode
|
||||
class LD,SL,TI afterModelNode
|
||||
class LD_CLEAN,SBR,MEM afterAgentNode
|
||||
class START,END terminalNode
|
||||
```
|
||||
|
||||
@ -82,13 +90,12 @@ sequenceDiagram
|
||||
participant TD as ThreadDataMiddleware
|
||||
participant UL as UploadsMiddleware
|
||||
participant SB as SandboxMiddleware
|
||||
participant LD as LoopDetectionMiddleware
|
||||
participant VI as ViewImageMiddleware
|
||||
participant DTC as DanglingToolCallMiddleware
|
||||
participant M as MODEL
|
||||
participant CL as ClarificationMiddleware
|
||||
participant SL as SubagentLimitMiddleware
|
||||
participant TI as TitleMiddleware
|
||||
participant SM as SummarizationMiddleware
|
||||
participant DTC as DanglingToolCallMiddleware
|
||||
participant MEM as MemoryMiddleware
|
||||
|
||||
U ->> TD: invoke
|
||||
@ -103,19 +110,26 @@ sequenceDiagram
|
||||
activate SB
|
||||
Note right of SB: before_agent 获取沙箱
|
||||
|
||||
SB ->> VI: before_model
|
||||
SB ->> LD: before_agent
|
||||
activate LD
|
||||
Note right of LD: before_agent 清理同 thread 旧 run 的 pending warning
|
||||
LD ->> VI: before_model
|
||||
activate VI
|
||||
Note right of VI: before_model 注入图片 base64
|
||||
|
||||
VI ->> M: messages + tools
|
||||
VI ->> DTC: wrap_model_call
|
||||
activate DTC
|
||||
Note right of DTC: wrap_model_call 补悬空 ToolMessage
|
||||
DTC ->> LD: wrap_model_call
|
||||
Note right of LD: wrap_model_call drain 当前 run warning 并追加到末尾
|
||||
LD ->> M: messages + tools
|
||||
activate M
|
||||
M -->> CL: AI response
|
||||
M -->> LD: AI response
|
||||
deactivate M
|
||||
|
||||
activate CL
|
||||
Note right of CL: after_model 拦截 ask_clarification
|
||||
CL -->> SL: after_model
|
||||
deactivate CL
|
||||
Note right of LD: after_model 检测循环;warning 入队,hard-stop 清 tool_calls
|
||||
LD -->> SL: after_model
|
||||
deactivate LD
|
||||
|
||||
activate SL
|
||||
Note right of SL: after_model 截断多余 task
|
||||
@ -124,22 +138,18 @@ sequenceDiagram
|
||||
|
||||
activate TI
|
||||
Note right of TI: after_model 生成标题
|
||||
TI -->> SM: after_model
|
||||
TI -->> DTC: done
|
||||
deactivate TI
|
||||
|
||||
activate SM
|
||||
Note right of SM: after_model 上下文压缩
|
||||
SM -->> DTC: after_model
|
||||
deactivate SM
|
||||
|
||||
activate DTC
|
||||
Note right of DTC: after_model 补缺失 ToolMessage
|
||||
DTC -->> VI: done
|
||||
deactivate DTC
|
||||
|
||||
VI -->> SB: done
|
||||
deactivate VI
|
||||
|
||||
Note right of LD: after_agent 清理当前 run 未消费 warning
|
||||
|
||||
Note right of MEM: after_agent 入队记忆
|
||||
|
||||
Note right of SB: after_agent 释放沙箱
|
||||
SB -->> UL: done
|
||||
deactivate SB
|
||||
@ -147,8 +157,6 @@ sequenceDiagram
|
||||
UL -->> TD: done
|
||||
deactivate UL
|
||||
|
||||
Note right of MEM: after_agent 入队记忆
|
||||
|
||||
TD -->> U: response
|
||||
deactivate TD
|
||||
```
|
||||
@ -224,12 +232,12 @@ sequenceDiagram
|
||||
participant TD as ThreadData
|
||||
participant UL as Uploads
|
||||
participant SB as Sandbox
|
||||
participant LD as LoopDetection
|
||||
participant VI as ViewImage
|
||||
participant DTC as DanglingToolCall
|
||||
participant M as MODEL
|
||||
participant CL as Clarification
|
||||
participant SL as SubagentLimit
|
||||
participant TI as Title
|
||||
participant SM as Summarization
|
||||
participant MEM as Memory
|
||||
|
||||
U ->> TD: invoke
|
||||
@ -238,34 +246,40 @@ sequenceDiagram
|
||||
Note right of UL: before_agent 扫描文件
|
||||
UL ->> SB: .
|
||||
Note right of SB: before_agent 获取沙箱
|
||||
SB ->> LD: .
|
||||
Note right of LD: before_agent 清理 stale pending warning
|
||||
|
||||
loop 每轮对话(tool call 循环)
|
||||
SB ->> VI: .
|
||||
Note right of VI: before_model 注入图片
|
||||
VI ->> M: messages + tools
|
||||
M -->> CL: AI response
|
||||
Note right of CL: after_model 拦截 ask_clarification
|
||||
CL -->> SL: .
|
||||
VI ->> DTC: .
|
||||
Note right of DTC: wrap_model_call 补悬空工具结果
|
||||
DTC ->> LD: .
|
||||
Note right of LD: wrap_model_call 注入当前 run warning
|
||||
LD ->> M: messages + tools
|
||||
M -->> LD: AI response
|
||||
Note right of LD: after_model 检测循环/排队 warning
|
||||
LD -->> SL: .
|
||||
Note right of SL: after_model 截断多余 task
|
||||
SL -->> TI: .
|
||||
Note right of TI: after_model 生成标题
|
||||
TI -->> SM: .
|
||||
Note right of SM: after_model 上下文压缩
|
||||
end
|
||||
|
||||
Note right of SB: after_agent 释放沙箱
|
||||
SB -->> MEM: .
|
||||
Note right of LD: after_agent 清理当前 run pending warning
|
||||
LD -->> MEM: .
|
||||
Note right of MEM: after_agent 入队记忆
|
||||
MEM -->> U: response
|
||||
MEM -->> SB: .
|
||||
Note right of SB: after_agent 释放沙箱
|
||||
SB -->> U: response
|
||||
```
|
||||
|
||||
> [!warning] 不是洋葱
|
||||
> 14 个 middleware 中只有 SandboxMiddleware 有 before/after 对称(获取/释放)。其余都是单向的:要么只在 `before_*` 做事,要么只在 `after_*` 做事。`before_agent` / `after_agent` 只跑一次,`before_model` / `after_model` 每轮循环都跑。
|
||||
> 大部分 middleware 只用一个阶段。SandboxMiddleware 使用 `before_agent`/`after_agent` 做资源获取/释放;LoopDetectionMiddleware 也使用这两个钩子,但用途是清理 run-scoped pending warnings,不是资源生命周期对称。`before_agent` / `after_agent` 只跑一次,`before_model` / `after_model` / `wrap_model_call` 每轮循环都跑。
|
||||
|
||||
硬依赖只有 2 处:
|
||||
|
||||
1. **ThreadData 在 Sandbox 之前** — sandbox 需要线程目录
|
||||
2. **Clarification 在列表最后** — `after_model` 反序时最先执行,第一个拦截 `ask_clarification`
|
||||
2. **Clarification 在列表最后** — `wrap_tool_call` 处理 `ask_clarification` 时优先拦截,并通过 `Command(goto=END)` 中断执行
|
||||
|
||||
### 结论
|
||||
|
||||
@ -273,19 +287,19 @@ sequenceDiagram
|
||||
|---|---|---|
|
||||
| 每个 middleware | before + after 对称 | 大多只用一个钩子 |
|
||||
| 激活条 | 嵌套(外长内短) | 不嵌套(串行) |
|
||||
| 反序的意义 | 清理与初始化配对 | 仅影响 after_model 的执行优先级 |
|
||||
| 反序的意义 | 清理与初始化配对 | 影响 `after_model` / `after_agent` 的执行优先级 |
|
||||
| 典型例子 | Auth: 校验 token / 清理上下文 | ThreadData: 只创建目录,没有清理 |
|
||||
|
||||
## 关键设计点
|
||||
|
||||
### ClarificationMiddleware 为什么在列表最后?
|
||||
|
||||
位置最后 = `after_model` 最先执行。它需要**第一个**看到 model 输出,检查是否有 `ask_clarification` tool call。如果有,立即中断(`Command(goto=END)`),后续 middleware 的 `after_model` 不再执行。
|
||||
位置最后使它在工具调用包装链中优先拦截 `ask_clarification`。如果命中,它返回 `Command(goto=END)`,把格式化后的澄清问题写成 `ToolMessage` 并中断执行。
|
||||
|
||||
### SandboxMiddleware 的对称性
|
||||
|
||||
`before_agent`(正序第 3 个)获取沙箱,`after_agent`(反序第 1 个)释放沙箱。外层进入 → 外层退出,天然的洋葱对称。
|
||||
|
||||
### 大部分 middleware 只用一个钩子
|
||||
### LoopDetectionMiddleware 为什么同时用多个钩子?
|
||||
|
||||
14 个 middleware 中,只有 SandboxMiddleware 同时用了 `before_agent` + `after_agent`(获取/释放)。其余都只在一个阶段执行。洋葱模型的反序特性主要影响 `after_model` 阶段的执行顺序。
|
||||
`after_model` 只做检测:重复工具调用达到 warning 阈值时,把 warning 放入 `(thread_id, run_id)` 作用域的 pending 队列。真正注入发生在下一次 `wrap_model_call`:此时上一轮 `AIMessage(tool_calls)` 对应的 `ToolMessage` 已经在请求里,warning 追加在末尾,不会破坏 OpenAI/Moonshot 的 tool-call pairing。`before_agent` 清理同一 thread 下旧 run 的残留 warning,`after_agent` 清理当前 run 没被消费的 warning。
|
||||
|
||||
@ -127,8 +127,8 @@ complex_agent = create_agent_for_task("high")
|
||||
## How It Works
|
||||
|
||||
1. When `make_lead_agent(config)` is called, it extracts `is_plan_mode` from `config.configurable`
|
||||
2. The config is passed to `_build_middlewares(config)`
|
||||
3. `_build_middlewares()` reads `is_plan_mode` and calls `_create_todo_list_middleware(is_plan_mode)`
|
||||
2. The config is passed to `build_middlewares(config)`
|
||||
3. `build_middlewares()` reads `is_plan_mode` and calls `_create_todo_list_middleware(is_plan_mode)`
|
||||
4. If `is_plan_mode=True`, a `TodoListMiddleware` instance is created and added to the middleware chain
|
||||
5. The middleware automatically adds a `write_todos` tool to the agent's toolset
|
||||
6. The agent can use this tool to manage tasks during execution
|
||||
@ -141,7 +141,7 @@ make_lead_agent(config)
|
||||
│
|
||||
├─> Extracts: is_plan_mode = config.configurable.get("is_plan_mode", False)
|
||||
│
|
||||
└─> _build_middlewares(config)
|
||||
└─> build_middlewares(config)
|
||||
│
|
||||
├─> ThreadDataMiddleware
|
||||
├─> SandboxMiddleware
|
||||
@ -156,7 +156,7 @@ make_lead_agent(config)
|
||||
### Agent Module
|
||||
- **Location**: `packages/harness/deerflow/agents/lead_agent/agent.py`
|
||||
- **Function**: `_create_todo_list_middleware(is_plan_mode: bool)` - Creates TodoListMiddleware if plan mode is enabled
|
||||
- **Function**: `_build_middlewares(config: RunnableConfig)` - Builds middleware chain based on runtime config
|
||||
- **Function**: `build_middlewares(config: RunnableConfig)` - Builds middleware chain based on runtime config
|
||||
- **Function**: `make_lead_agent(config: RunnableConfig)` - Creates agent with appropriate middlewares
|
||||
|
||||
### Runtime Configuration
|
||||
|
||||
@ -1,3 +1,25 @@
|
||||
"""Lead agent factory.
|
||||
|
||||
INVARIANT — tracing callback placement
|
||||
======================================
|
||||
|
||||
Tracing callbacks (Langfuse, LangSmith) are attached at the **graph
|
||||
invocation root** in :func:`_make_lead_agent` (see the
|
||||
``build_tracing_callbacks()`` block that appends to ``config["callbacks"]``).
|
||||
Every ``create_chat_model(...)`` call inside this module — and inside any
|
||||
middleware reachable from this graph (e.g. ``TitleMiddleware``) — MUST pass
|
||||
``attach_tracing=False``.
|
||||
|
||||
Forgetting that flag emits duplicate spans (one rooted at the graph, one at
|
||||
the model) AND prevents the Langfuse handler's ``propagate_attributes``
|
||||
path from firing, so ``session_id`` / ``user_id`` never reach the trace.
|
||||
The four current sites are: bootstrap agent, default agent, summarization
|
||||
middleware, and the async path inside ``TitleMiddleware``. Any new in-graph
|
||||
``create_chat_model`` call must add to this list and pass the flag.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from langchain.agents import create_agent
|
||||
@ -9,6 +31,7 @@ from deerflow.agents.memory.summarization_hook import memory_flush_hook
|
||||
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
||||
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
||||
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
||||
from deerflow.agents.middlewares.safety_finish_reason_middleware import SafetyFinishReasonMiddleware
|
||||
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
|
||||
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
|
||||
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
||||
@ -22,9 +45,12 @@ from deerflow.config.app_config import AppConfig, get_app_config
|
||||
from deerflow.models import create_chat_model
|
||||
from deerflow.skills.tool_policy import filter_tools_by_skill_allowed_tools
|
||||
from deerflow.skills.types import Skill
|
||||
from deerflow.tracing import build_tracing_callbacks
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BOOTSTRAP_SKILL_NAMES = {"bootstrap"}
|
||||
|
||||
|
||||
def _get_runtime_config(config: RunnableConfig) -> dict:
|
||||
"""Merge legacy configurable options with LangGraph runtime context."""
|
||||
@ -73,10 +99,14 @@ def _create_summarization_middleware(*, app_config: AppConfig | None = None) ->
|
||||
# Bind "middleware:summarize" tag so RunJournal identifies these LLM calls
|
||||
# as middleware rather than lead_agent (SummarizationMiddleware is a
|
||||
# LangChain built-in, so we tag the model at creation time).
|
||||
# attach_tracing=False because the graph-level RunnableConfig (set in
|
||||
# ``_make_lead_agent``) already carries tracing callbacks; binding them
|
||||
# again at the model level would emit duplicate spans and break
|
||||
# ``session_id`` / ``user_id`` propagation.
|
||||
if config.model_name:
|
||||
model = create_chat_model(name=config.model_name, thinking_enabled=False, app_config=resolved_app_config)
|
||||
model = create_chat_model(name=config.model_name, thinking_enabled=False, app_config=resolved_app_config, attach_tracing=False)
|
||||
else:
|
||||
model = create_chat_model(thinking_enabled=False, app_config=resolved_app_config)
|
||||
model = create_chat_model(thinking_enabled=False, app_config=resolved_app_config, attach_tracing=False)
|
||||
model = model.with_config(tags=["middleware:summarize"])
|
||||
|
||||
# Prepare kwargs
|
||||
@ -237,20 +267,31 @@ Being proactive with task management demonstrates thoroughness and ensures all r
|
||||
# ViewImageMiddleware should be before ClarificationMiddleware to inject image details before LLM
|
||||
# ToolErrorHandlingMiddleware should be before ClarificationMiddleware to convert tool exceptions to ToolMessages
|
||||
# ClarificationMiddleware should be last to intercept clarification requests after model calls
|
||||
def _build_middlewares(
|
||||
def build_middlewares(
|
||||
config: RunnableConfig,
|
||||
model_name: str | None,
|
||||
agent_name: str | None = None,
|
||||
custom_middlewares: list[AgentMiddleware] | None = None,
|
||||
*,
|
||||
available_skills: set[str] | None = None,
|
||||
app_config: AppConfig | None = None,
|
||||
deferred_setup=None,
|
||||
):
|
||||
"""Build middleware chain based on runtime configuration.
|
||||
"""Build the lead-agent middleware chain based on runtime configuration.
|
||||
|
||||
Public entry point for the lead agent's full middleware composition. Used by
|
||||
``make_lead_agent`` and by the embedded ``DeerFlowClient`` (a lead-agent variant
|
||||
that needs the identical chain). Keep this name stable: it is imported across a
|
||||
module boundary, so renames/signature changes ripple into ``client.py``.
|
||||
|
||||
Args:
|
||||
config: Runtime configuration containing configurable options like is_plan_mode.
|
||||
model_name: Resolved runtime model name; gates vision-only middleware.
|
||||
agent_name: If provided, MemoryMiddleware will use per-agent memory storage.
|
||||
custom_middlewares: Optional list of custom middlewares to inject into the chain.
|
||||
app_config: Explicit AppConfig; falls back to ``get_app_config()`` when omitted.
|
||||
deferred_setup: Optional deferred-MCP-tool setup that attaches
|
||||
``DeferredToolFilterMiddleware`` when ``tool_search`` is enabled.
|
||||
|
||||
Returns:
|
||||
List of middleware instances.
|
||||
@ -264,6 +305,13 @@ def _build_middlewares(
|
||||
|
||||
middlewares.append(DynamicContextMiddleware(agent_name=agent_name, app_config=resolved_app_config))
|
||||
|
||||
# Deterministically load a full SKILL.md when the user starts the turn with
|
||||
# /skill-name. This keeps the base system prompt metadata-only while giving
|
||||
# explicit user activation priority over model-side relevance guessing.
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
||||
|
||||
middlewares.append(SkillActivationMiddleware(available_skills=available_skills, app_config=resolved_app_config))
|
||||
|
||||
# Add summarization middleware if enabled
|
||||
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
|
||||
if summarization_middleware is not None:
|
||||
@ -292,11 +340,13 @@ def _build_middlewares(
|
||||
if model_config is not None and model_config.supports_vision:
|
||||
middlewares.append(ViewImageMiddleware())
|
||||
|
||||
# Add DeferredToolFilterMiddleware to hide deferred tool schemas from model binding
|
||||
if resolved_app_config.tool_search.enabled:
|
||||
# Hide deferred tool schemas from model binding until tool_search promotes them.
|
||||
# The deferred set + catalog hash come from the build-time setup (assembled
|
||||
# after tool-policy filtering); promotion is read from graph state.
|
||||
if deferred_setup is not None and deferred_setup.deferred_names:
|
||||
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
|
||||
|
||||
middlewares.append(DeferredToolFilterMiddleware())
|
||||
middlewares.append(DeferredToolFilterMiddleware(deferred_setup.deferred_names, deferred_setup.catalog_hash))
|
||||
|
||||
# Add SubagentLimitMiddleware to truncate excess parallel task calls
|
||||
subagent_enabled = cfg.get("subagent_enabled", False)
|
||||
@ -313,6 +363,15 @@ def _build_middlewares(
|
||||
if custom_middlewares:
|
||||
middlewares.extend(custom_middlewares)
|
||||
|
||||
# SafetyFinishReasonMiddleware — suppress tool execution when the provider
|
||||
# safety-terminated the response. Registered after custom middlewares so
|
||||
# that LangChain's reverse-order after_model dispatch runs Safety first;
|
||||
# cleared tool_calls then flow through Loop/Subagent accounting without
|
||||
# firing extra alarms. See safety_finish_reason_middleware.py docstring.
|
||||
safety_config = resolved_app_config.safety_finish_reason
|
||||
if safety_config.enabled:
|
||||
middlewares.append(SafetyFinishReasonMiddleware.from_config(safety_config))
|
||||
|
||||
# ClarificationMiddleware should always be last
|
||||
middlewares.append(ClarificationMiddleware())
|
||||
return middlewares
|
||||
@ -320,7 +379,7 @@ def _build_middlewares(
|
||||
|
||||
def _available_skill_names(agent_config, is_bootstrap: bool) -> set[str] | None:
|
||||
if is_bootstrap:
|
||||
return {"bootstrap"}
|
||||
return set(_BOOTSTRAP_SKILL_NAMES)
|
||||
if agent_config and agent_config.skills is not None:
|
||||
return set(agent_config.skills)
|
||||
return None
|
||||
@ -351,6 +410,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
# Lazy import to avoid circular dependency
|
||||
from deerflow.tools import get_available_tools
|
||||
from deerflow.tools.builtins import setup_agent, update_agent
|
||||
from deerflow.tools.builtins.tool_search import assemble_deferred_tools
|
||||
|
||||
cfg = _get_runtime_config(config)
|
||||
resolved_app_config = app_config
|
||||
@ -408,20 +468,44 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
}
|
||||
)
|
||||
|
||||
# Inject tracing callbacks at the graph invocation root so a single LangGraph
|
||||
# run produces one trace with all node / LLM / tool calls as child spans,
|
||||
# AND so the Langfuse handler sees ``on_chain_start(parent_run_id=None)`` and
|
||||
# actually propagates ``langfuse_session_id`` / ``langfuse_user_id`` from
|
||||
# ``config["metadata"]`` onto the trace. Without root-level attachment the
|
||||
# model is a nested observation and the handler strips ``langfuse_*`` keys.
|
||||
tracing_callbacks = build_tracing_callbacks()
|
||||
if tracing_callbacks:
|
||||
existing = config.get("callbacks") or []
|
||||
if not isinstance(existing, list):
|
||||
existing = list(existing)
|
||||
config["callbacks"] = [*existing, *tracing_callbacks]
|
||||
|
||||
skills_for_tool_policy = _load_enabled_skills_for_tool_policy(available_skills, app_config=resolved_app_config)
|
||||
|
||||
if is_bootstrap:
|
||||
# Special bootstrap agent with minimal prompt for initial custom agent creation flow
|
||||
tools = get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled, app_config=resolved_app_config) + [setup_agent]
|
||||
# Keep the bootstrap skill set intentionally narrow so agent creation
|
||||
# remains deterministic before the custom agent's own config exists.
|
||||
raw_tools = get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled, app_config=resolved_app_config) + [setup_agent]
|
||||
filtered = filter_tools_by_skill_allowed_tools(raw_tools, skills_for_tool_policy)
|
||||
final_tools, setup = assemble_deferred_tools(filtered, enabled=resolved_app_config.tool_search.enabled)
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config),
|
||||
tools=filter_tools_by_skill_allowed_tools(tools, skills_for_tool_policy),
|
||||
middleware=_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config, attach_tracing=False),
|
||||
tools=final_tools,
|
||||
middleware=build_middlewares(
|
||||
config,
|
||||
model_name=model_name,
|
||||
available_skills=set(_BOOTSTRAP_SKILL_NAMES),
|
||||
app_config=resolved_app_config,
|
||||
deferred_setup=setup,
|
||||
),
|
||||
system_prompt=apply_prompt_template(
|
||||
subagent_enabled=subagent_enabled,
|
||||
max_concurrent_subagents=max_concurrent_subagents,
|
||||
available_skills=set(["bootstrap"]),
|
||||
available_skills=set(_BOOTSTRAP_SKILL_NAMES),
|
||||
app_config=resolved_app_config,
|
||||
deferred_names=setup.deferred_names,
|
||||
),
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
@ -430,17 +514,27 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
# The default agent (no agent_name) does not see this tool.
|
||||
extra_tools = [update_agent] if agent_name else []
|
||||
# Default lead agent (unchanged behavior)
|
||||
tools = get_available_tools(model_name=model_name, groups=agent_config.tool_groups if agent_config else None, subagent_enabled=subagent_enabled, app_config=resolved_app_config)
|
||||
raw_tools = get_available_tools(model_name=model_name, groups=agent_config.tool_groups if agent_config else None, subagent_enabled=subagent_enabled, app_config=resolved_app_config)
|
||||
filtered = filter_tools_by_skill_allowed_tools(raw_tools + extra_tools, skills_for_tool_policy)
|
||||
final_tools, setup = assemble_deferred_tools(filtered, enabled=resolved_app_config.tool_search.enabled)
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config),
|
||||
tools=filter_tools_by_skill_allowed_tools(tools + extra_tools, skills_for_tool_policy),
|
||||
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_name, app_config=resolved_app_config),
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config, attach_tracing=False),
|
||||
tools=final_tools,
|
||||
middleware=build_middlewares(
|
||||
config,
|
||||
model_name=model_name,
|
||||
agent_name=agent_name,
|
||||
available_skills=available_skills,
|
||||
app_config=resolved_app_config,
|
||||
deferred_setup=setup,
|
||||
),
|
||||
system_prompt=apply_prompt_template(
|
||||
subagent_enabled=subagent_enabled,
|
||||
max_concurrent_subagents=max_concurrent_subagents,
|
||||
agent_name=agent_name,
|
||||
available_skills=set(agent_config.skills) if agent_config and agent_config.skills is not None else None,
|
||||
available_skills=available_skills,
|
||||
app_config=resolved_app_config,
|
||||
deferred_names=setup.deferred_names,
|
||||
),
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
|
||||
@ -10,6 +10,7 @@ from deerflow.config.agents_config import load_agent_soul
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
from deerflow.skills.types import Skill, SkillCategory
|
||||
from deerflow.subagents import get_available_subagent_names
|
||||
from deerflow.tools.builtins.tool_search import get_deferred_tools_prompt_section
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from deerflow.config.app_config import AppConfig
|
||||
@ -542,6 +543,14 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
|
||||
{subagent_reminder}- Skill First: Always load the relevant skill before starting **complex** tasks.
|
||||
- Progressive Loading: Load resources incrementally as referenced in skills
|
||||
- Output Files: Final deliverables must be in `/mnt/user-data/outputs`
|
||||
- File Editing Workflow: When revising an existing file, prefer
|
||||
`str_replace` over `write_file` — it sends only the diff and avoids
|
||||
re-emitting the whole file (mirrors Claude Code's Edit and Codex's
|
||||
apply_patch). When writing long new content from scratch, split it
|
||||
into sections: the first `write_file` call creates the file, then use
|
||||
`write_file` with append=True to extend it section by section. This
|
||||
keeps each tool call small and avoids mid-stream chunk-gap timeouts
|
||||
on oversized single-shot writes. (See issue #3189.)
|
||||
- Clarity: Be direct and helpful, avoid unnecessary meta-commentary
|
||||
- Including Images and Mermaid: Images and Mermaid diagrams are always welcomed in the Markdown format, and you're encouraged to use `\n\n` or "```mermaid" to display images in response or Markdown files
|
||||
- Multi-task: Better utilize parallel tool calling to call multiple tools at one time for better performance
|
||||
@ -616,6 +625,11 @@ You have access to skills that provide optimized workflows for specific tasks. E
|
||||
4. Load referenced resources only when needed during execution
|
||||
5. Follow the skill's instructions precisely
|
||||
|
||||
**Explicit Slash Skill Activation:**
|
||||
- If the user starts a request with `/<skill-name>`, that skill was explicitly requested for the current turn.
|
||||
- Follow the activated skill before choosing a general workflow.
|
||||
- The runtime injects the activated skill content for explicit slash activations; do not call `read_file` for that SKILL.md again unless the injected skill references supporting resources you need.
|
||||
|
||||
**Skills are located at:** {container_base_path}
|
||||
{skill_evolution_section}
|
||||
{skills_list}
|
||||
@ -678,42 +692,13 @@ SOUL.md or config.yaml — those write into a temporary sandbox/tool workspace a
|
||||
Rules:
|
||||
- Always pass the FULL replacement text for `soul` (no patch semantics). Start from your current SOUL above and apply the user's edits.
|
||||
- Only pass the fields that should change. Omit the others to preserve them.
|
||||
- Never pass literal strings like `"null"`, `"none"`, or `"undefined"` for unchanged fields.
|
||||
- Pass `skills=[]` to disable all skills, or omit `skills` to keep the existing whitelist.
|
||||
- After `update_agent` returns successfully, tell the user the change is persisted and will take effect on the next turn.
|
||||
</self_update>
|
||||
"""
|
||||
|
||||
|
||||
def get_deferred_tools_prompt_section(*, app_config: AppConfig | None = None) -> str:
|
||||
"""Generate <available-deferred-tools> block for the system prompt.
|
||||
|
||||
Lists only deferred tool names so the agent knows what exists
|
||||
and can use tool_search to load them.
|
||||
Returns empty string when tool_search is disabled or no tools are deferred.
|
||||
"""
|
||||
from deerflow.tools.builtins.tool_search import get_deferred_registry
|
||||
|
||||
if app_config is None:
|
||||
try:
|
||||
from deerflow.config import get_app_config
|
||||
|
||||
config = get_app_config()
|
||||
except Exception:
|
||||
return ""
|
||||
else:
|
||||
config = app_config
|
||||
|
||||
if not config.tool_search.enabled:
|
||||
return ""
|
||||
|
||||
registry = get_deferred_registry()
|
||||
if not registry:
|
||||
return ""
|
||||
|
||||
names = "\n".join(e.name for e in registry.entries)
|
||||
return f"<available-deferred-tools>\n{names}\n</available-deferred-tools>"
|
||||
|
||||
|
||||
def _build_acp_section(*, app_config: AppConfig | None = None) -> str:
|
||||
"""Build the ACP agent prompt section, only if ACP agents are configured."""
|
||||
if app_config is None:
|
||||
@ -772,6 +757,7 @@ def apply_prompt_template(
|
||||
agent_name: str | None = None,
|
||||
available_skills: set[str] | None = None,
|
||||
app_config: AppConfig | None = None,
|
||||
deferred_names: frozenset[str] = frozenset(),
|
||||
) -> str:
|
||||
# Include subagent section only if enabled (from runtime parameter)
|
||||
n = max_concurrent_subagents
|
||||
@ -799,7 +785,7 @@ def apply_prompt_template(
|
||||
skills_section = get_skills_prompt_section(available_skills, app_config=app_config)
|
||||
|
||||
# Get deferred tools section (tool_search)
|
||||
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
|
||||
deferred_tools_section = get_deferred_tools_prompt_section(deferred_names=deferred_names)
|
||||
|
||||
# Build ACP agent section only if ACP agents are configured
|
||||
acp_section = _build_acp_section(app_config=app_config)
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
"""Prompt templates for memory update and injection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import tiktoken
|
||||
|
||||
@ -160,6 +165,39 @@ Rules:
|
||||
Return ONLY valid JSON."""
|
||||
|
||||
|
||||
# Module-level tiktoken encoding cache. Populated lazily on first use;
|
||||
# subsequent calls are a dict lookup (no network I/O). Pre-warming at
|
||||
# startup via :func:`warm_tiktoken_cache` avoids blocking a request on the
|
||||
# (potentially slow) first ``get_encoding`` call.
|
||||
_tiktoken_encoding_cache: dict[str, tiktoken.Encoding] = {}
|
||||
|
||||
|
||||
def _get_tiktoken_encoding(encoding_name: str = "cl100k_base") -> tiktoken.Encoding | None:
|
||||
"""Return a cached tiktoken encoding, or ``None`` on failure / unavailability.
|
||||
|
||||
On the very first call for a given *encoding_name*, tiktoken may need to
|
||||
download the BPE data from ``openaipublic.blob.core.windows.net``. In
|
||||
network-restricted environments (e.g. deployments behind the GFW) this
|
||||
download can block for tens of minutes before the OS TCP timeout kicks in.
|
||||
The caller must therefore be prepared for this to block and should run it
|
||||
off the event loop (e.g. via ``asyncio.to_thread``).
|
||||
"""
|
||||
if not TIKTOKEN_AVAILABLE:
|
||||
return None
|
||||
|
||||
cached = _tiktoken_encoding_cache.get(encoding_name)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
encoding = tiktoken.get_encoding(encoding_name)
|
||||
_tiktoken_encoding_cache[encoding_name] = encoding
|
||||
return encoding
|
||||
except Exception:
|
||||
logger.warning("Failed to load tiktoken encoding %r; falling back to char-based estimation", encoding_name, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _count_tokens(text: str, encoding_name: str = "cl100k_base") -> int:
|
||||
"""Count tokens in text using tiktoken.
|
||||
|
||||
@ -170,18 +208,30 @@ def _count_tokens(text: str, encoding_name: str = "cl100k_base") -> int:
|
||||
Returns:
|
||||
The number of tokens in the text.
|
||||
"""
|
||||
if not TIKTOKEN_AVAILABLE:
|
||||
encoding = _get_tiktoken_encoding(encoding_name)
|
||||
if encoding is None:
|
||||
# Fallback to character-based estimation if tiktoken is not available
|
||||
# or the encoding failed to load.
|
||||
return len(text) // 4
|
||||
|
||||
try:
|
||||
encoding = tiktoken.get_encoding(encoding_name)
|
||||
return len(encoding.encode(text))
|
||||
except Exception:
|
||||
# Fallback to character-based estimation on error
|
||||
return len(text) // 4
|
||||
|
||||
|
||||
def warm_tiktoken_cache() -> bool:
|
||||
"""Pre-warm the tiktoken encoding cache.
|
||||
|
||||
Call at startup (off the event loop) so the first request never blocks
|
||||
on the BPE download. Returns ``True`` if the encoding was loaded
|
||||
successfully (or was already cached), ``False`` if tiktoken is
|
||||
unavailable or the download failed.
|
||||
"""
|
||||
return _get_tiktoken_encoding("cl100k_base") is not None
|
||||
|
||||
|
||||
def _coerce_confidence(value: Any, default: float = 0.0) -> float:
|
||||
"""Coerce a confidence-like value to a bounded float in [0, 1].
|
||||
|
||||
|
||||
@ -227,6 +227,110 @@ def _extract_text(content: Any) -> str:
|
||||
return str(content)
|
||||
|
||||
|
||||
_REQUIRED_MEMORY_UPDATE_TOP_LEVEL_KEYS = frozenset({"user", "history", "newFacts", "factsToRemove"})
|
||||
|
||||
|
||||
def _normalize_memory_update_fact(fact: Any) -> dict[str, Any] | None:
|
||||
"""Normalize a single fact entry from a model-produced memory update."""
|
||||
if not isinstance(fact, dict):
|
||||
return None
|
||||
|
||||
raw_content = fact.get("content")
|
||||
if not isinstance(raw_content, str):
|
||||
return None
|
||||
content = raw_content.strip()
|
||||
if not content:
|
||||
return None
|
||||
|
||||
raw_category = fact.get("category")
|
||||
category = raw_category.strip() if isinstance(raw_category, str) and raw_category.strip() else "context"
|
||||
|
||||
raw_confidence = fact.get("confidence", 0.5)
|
||||
if isinstance(raw_confidence, bool):
|
||||
return None
|
||||
if isinstance(raw_confidence, str):
|
||||
raw_confidence = raw_confidence.strip()
|
||||
if not raw_confidence:
|
||||
return None
|
||||
try:
|
||||
raw_confidence = float(raw_confidence)
|
||||
except ValueError:
|
||||
return None
|
||||
elif isinstance(raw_confidence, (int, float)):
|
||||
raw_confidence = float(raw_confidence)
|
||||
else:
|
||||
return None
|
||||
|
||||
if not math.isfinite(raw_confidence):
|
||||
return None
|
||||
|
||||
normalized_fact = {
|
||||
"content": content,
|
||||
"category": category,
|
||||
"confidence": raw_confidence,
|
||||
}
|
||||
source_error = fact.get("sourceError")
|
||||
if isinstance(source_error, str):
|
||||
normalized_source_error = source_error.strip()
|
||||
if normalized_source_error:
|
||||
normalized_fact["sourceError"] = normalized_source_error
|
||||
|
||||
return normalized_fact
|
||||
|
||||
|
||||
def _normalize_memory_update_data(update_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Coerce parsed memory update data into the shape consumed by _apply_updates."""
|
||||
user = update_data.get("user")
|
||||
history = update_data.get("history")
|
||||
new_facts = update_data.get("newFacts")
|
||||
facts_to_remove = update_data.get("factsToRemove")
|
||||
normalized_facts_to_remove = [fact_id for fact_id in facts_to_remove if isinstance(fact_id, str)] if isinstance(facts_to_remove, list) else []
|
||||
normalized_new_facts = []
|
||||
dropped_new_fact = not isinstance(new_facts, list)
|
||||
if isinstance(new_facts, list):
|
||||
for fact in new_facts:
|
||||
normalized_fact = _normalize_memory_update_fact(fact)
|
||||
if normalized_fact is not None:
|
||||
normalized_new_facts.append(normalized_fact)
|
||||
else:
|
||||
dropped_new_fact = True
|
||||
|
||||
if normalized_facts_to_remove and dropped_new_fact:
|
||||
raise json.JSONDecodeError(
|
||||
"Unsafe partial memory update: factsToRemove with malformed newFacts",
|
||||
json.dumps(update_data, ensure_ascii=False),
|
||||
0,
|
||||
)
|
||||
|
||||
return {
|
||||
"user": user if isinstance(user, dict) else {},
|
||||
"history": history if isinstance(history, dict) else {},
|
||||
"newFacts": normalized_new_facts,
|
||||
"factsToRemove": normalized_facts_to_remove,
|
||||
}
|
||||
|
||||
|
||||
def _parse_memory_update_response(response_content: Any) -> dict[str, Any]:
|
||||
"""Parse the first valid memory-update JSON object from an LLM response.
|
||||
|
||||
Some providers may wrap JSON in thinking traces, prose, or markdown fences
|
||||
even when prompted to return JSON only. This parser accepts safely
|
||||
extractable JSON objects but does not repair truncated or malformed JSON.
|
||||
"""
|
||||
response_text = _extract_text(response_content).strip()
|
||||
decoder = json.JSONDecoder()
|
||||
|
||||
for match in re.finditer(r"\{", response_text):
|
||||
try:
|
||||
parsed, _end = decoder.raw_decode(response_text[match.start() :])
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(parsed, dict) and _REQUIRED_MEMORY_UPDATE_TOP_LEVEL_KEYS.issubset(parsed):
|
||||
return _normalize_memory_update_data(parsed)
|
||||
|
||||
raise json.JSONDecodeError("No valid memory update JSON object found", response_text, 0)
|
||||
|
||||
|
||||
# Matches sentences that describe a file-upload *event* rather than general
|
||||
# file-related work. Deliberately narrow to avoid removing legitimate facts
|
||||
# such as "User works with CSV files" or "prefers PDF export".
|
||||
@ -338,7 +442,7 @@ class MemoryUpdater:
|
||||
reinforcement_detected=reinforcement_detected,
|
||||
)
|
||||
prompt = MEMORY_UPDATE_PROMPT.format(
|
||||
current_memory=json.dumps(current_memory, indent=2),
|
||||
current_memory=json.dumps(current_memory, indent=2, ensure_ascii=False),
|
||||
conversation=conversation_text,
|
||||
correction_hint=correction_hint,
|
||||
)
|
||||
@ -353,13 +457,7 @@ class MemoryUpdater:
|
||||
user_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Parse the model response, apply updates, and persist memory."""
|
||||
response_text = _extract_text(response_content).strip()
|
||||
|
||||
if response_text.startswith("```"):
|
||||
lines = response_text.split("\n")
|
||||
response_text = "\n".join(lines[1:-1] if lines[-1] == "```" else lines[1:])
|
||||
|
||||
update_data = json.loads(response_text)
|
||||
update_data = _parse_memory_update_response(response_content)
|
||||
# Deep-copy before in-place mutation so a subsequent save() failure
|
||||
# cannot corrupt the still-cached original object reference.
|
||||
updated_memory = self._apply_updates(copy.deepcopy(current_memory), update_data, thread_id)
|
||||
|
||||
@ -15,6 +15,7 @@ to the end of the message list as before_model + add_messages reducer would do.
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict, deque
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import override
|
||||
|
||||
@ -25,6 +26,11 @@ from langchain_core.messages import ToolMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Workaround for issue #2894: malformed write_file calls can carry huge Markdown
|
||||
# payloads in invalid tool-call args. Keep recovery error details short so the
|
||||
# synthetic ToolMessage does not echo large or malformed content back to the model.
|
||||
_MAX_RECOVERY_ERROR_DETAIL_LEN = 500
|
||||
|
||||
|
||||
class DanglingToolCallMiddleware(AgentMiddleware[AgentState]):
|
||||
"""Inserts placeholder ToolMessages for dangling tool calls before model invocation.
|
||||
@ -97,9 +103,25 @@ class DanglingToolCallMiddleware(AgentMiddleware[AgentState]):
|
||||
@staticmethod
|
||||
def _synthetic_tool_message_content(tool_call: dict) -> str:
|
||||
if tool_call.get("invalid"):
|
||||
name = tool_call.get("name")
|
||||
error = tool_call.get("error")
|
||||
if isinstance(error, str) and error:
|
||||
return f"[Tool call could not be executed because its arguments were invalid: {error}]"
|
||||
error_text = error[:_MAX_RECOVERY_ERROR_DETAIL_LEN] if isinstance(error, str) and error else ""
|
||||
# Workaround for issue #2894: malformed write_file calls can carry huge Markdown
|
||||
# payloads in invalid tool-call args. Keep recovery guidance actionable without
|
||||
# echoing large or malformed content back to the model.
|
||||
if name == "write_file":
|
||||
details = f" Parser error: {error_text}" if error_text else ""
|
||||
return (
|
||||
"[write_file failed before execution: the tool-call arguments were not valid JSON, "
|
||||
"so no file was written. This often happens when the model tries to write a very "
|
||||
"large Markdown file in a single tool call, especially when `content` contains "
|
||||
"unescaped quotes, inline JSON, backslashes, or code fences. Do not retry the same "
|
||||
"large `write_file` payload for this artifact; provide the report/content directly "
|
||||
"as normal assistant text in your next response. If a file write is still needed "
|
||||
f"later, split the file into smaller sections instead of one large payload.{details}]"
|
||||
)
|
||||
if error_text:
|
||||
return f"[Tool call could not be executed because its arguments were invalid: {error_text}]"
|
||||
return "[Tool call could not be executed because its arguments were invalid.]"
|
||||
return "[Tool call was interrupted and did not return a result.]"
|
||||
|
||||
@ -109,10 +131,10 @@ class DanglingToolCallMiddleware(AgentMiddleware[AgentState]):
|
||||
This normalizes model-bound causal order before provider serialization while
|
||||
preserving already-valid transcripts unchanged.
|
||||
"""
|
||||
tool_messages_by_id: dict[str, ToolMessage] = {}
|
||||
tool_messages_by_id: dict[str, deque[ToolMessage]] = defaultdict(deque)
|
||||
for msg in messages:
|
||||
if isinstance(msg, ToolMessage):
|
||||
tool_messages_by_id.setdefault(msg.tool_call_id, msg)
|
||||
tool_messages_by_id[msg.tool_call_id].append(msg)
|
||||
|
||||
tool_call_ids: set[str] = set()
|
||||
for msg in messages:
|
||||
@ -124,7 +146,6 @@ class DanglingToolCallMiddleware(AgentMiddleware[AgentState]):
|
||||
tool_call_ids.add(tc_id)
|
||||
|
||||
patched: list = []
|
||||
consumed_tool_msg_ids: set[str] = set()
|
||||
patch_count = 0
|
||||
for msg in messages:
|
||||
if isinstance(msg, ToolMessage) and msg.tool_call_id in tool_call_ids:
|
||||
@ -136,13 +157,13 @@ class DanglingToolCallMiddleware(AgentMiddleware[AgentState]):
|
||||
|
||||
for tc in self._message_tool_calls(msg):
|
||||
tc_id = tc.get("id")
|
||||
if not tc_id or tc_id in consumed_tool_msg_ids:
|
||||
if not tc_id:
|
||||
continue
|
||||
|
||||
existing_tool_msg = tool_messages_by_id.get(tc_id)
|
||||
tool_msg_queue = tool_messages_by_id.get(tc_id)
|
||||
existing_tool_msg = tool_msg_queue.popleft() if tool_msg_queue else None
|
||||
if existing_tool_msg is not None:
|
||||
patched.append(existing_tool_msg)
|
||||
consumed_tool_msg_ids.add(tc_id)
|
||||
else:
|
||||
patched.append(
|
||||
ToolMessage(
|
||||
@ -152,7 +173,6 @@ class DanglingToolCallMiddleware(AgentMiddleware[AgentState]):
|
||||
status="error",
|
||||
)
|
||||
)
|
||||
consumed_tool_msg_ids.add(tc_id)
|
||||
patch_count += 1
|
||||
|
||||
if patched == messages:
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
"""Middleware to filter deferred tool schemas from model binding.
|
||||
|
||||
When tool_search is enabled, MCP tools are registered in the DeferredToolRegistry
|
||||
and passed to ToolNode for execution, but their schemas should NOT be sent to the
|
||||
LLM via bind_tools (that's the whole point of deferral — saving context tokens).
|
||||
When tool_search is enabled, MCP tools are still passed to ToolNode for
|
||||
execution, but their schemas must NOT be sent to the LLM via bind_tools until
|
||||
the model has discovered them via tool_search. This middleware removes the
|
||||
still-deferred tools from request.tools before model binding, and blocks tool
|
||||
calls to tools that have not been promoted yet.
|
||||
|
||||
This middleware intercepts wrap_model_call and removes deferred tools from
|
||||
request.tools so that model.bind_tools only receives active tool schemas.
|
||||
The agent discovers deferred tools at runtime via the tool_search tool.
|
||||
The deferred name set and the catalog hash are injected at construction time
|
||||
(no ContextVar). Promotion state is read from graph state (``state["promoted"]``),
|
||||
scoped by catalog hash so a stale persisted promotion cannot expose a renamed
|
||||
or drifted tool.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@ -24,47 +27,49 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeferredToolFilterMiddleware(AgentMiddleware[AgentState]):
|
||||
"""Remove deferred tools from request.tools before model binding.
|
||||
"""Hide deferred tool schemas from the bound model until promoted.
|
||||
|
||||
ToolNode still holds all tools (including deferred) for execution routing,
|
||||
but the LLM only sees active tool schemas — deferred tools are discoverable
|
||||
via tool_search at runtime.
|
||||
but the LLM only sees active tool schemas plus tools that have already been
|
||||
promoted (recorded in ``state["promoted"]`` under the current catalog hash).
|
||||
"""
|
||||
|
||||
def __init__(self, deferred_names: frozenset[str], catalog_hash: str | None):
|
||||
super().__init__()
|
||||
self._deferred = deferred_names
|
||||
self._catalog_hash = catalog_hash
|
||||
|
||||
def _promoted(self, state) -> set[str]:
|
||||
promoted = (state or {}).get("promoted")
|
||||
if promoted and promoted.get("catalog_hash") == self._catalog_hash:
|
||||
return set(promoted.get("names") or [])
|
||||
return set()
|
||||
|
||||
def _hidden(self, state) -> set[str]:
|
||||
return set(self._deferred) - self._promoted(state)
|
||||
|
||||
def _filter_tools(self, request: ModelRequest) -> ModelRequest:
|
||||
from deerflow.tools.builtins.tool_search import get_deferred_registry
|
||||
|
||||
registry = get_deferred_registry()
|
||||
if not registry:
|
||||
if not self._deferred:
|
||||
return request
|
||||
|
||||
deferred_names = registry.deferred_names
|
||||
active_tools = [t for t in request.tools if getattr(t, "name", None) not in deferred_names]
|
||||
|
||||
if len(active_tools) < len(request.tools):
|
||||
logger.debug(f"Filtered {len(request.tools) - len(active_tools)} deferred tool schema(s) from model binding")
|
||||
|
||||
return request.override(tools=active_tools)
|
||||
hide = self._hidden(request.state)
|
||||
if not hide:
|
||||
return request
|
||||
active = [t for t in request.tools if getattr(t, "name", None) not in hide]
|
||||
if len(active) < len(request.tools):
|
||||
logger.debug("Filtered %d deferred tool schema(s) from model binding", len(request.tools) - len(active))
|
||||
return request.override(tools=active)
|
||||
|
||||
def _blocked_tool_message(self, request: ToolCallRequest) -> ToolMessage | None:
|
||||
from deerflow.tools.builtins.tool_search import get_deferred_registry
|
||||
|
||||
registry = get_deferred_registry()
|
||||
if not registry:
|
||||
if not self._deferred:
|
||||
return None
|
||||
|
||||
tool_name = str(request.tool_call.get("name") or "")
|
||||
if not tool_name:
|
||||
name = str(request.tool_call.get("name") or "")
|
||||
if not name or name not in self._hidden(request.state):
|
||||
return None
|
||||
|
||||
if not registry.contains(tool_name):
|
||||
return None
|
||||
|
||||
tool_call_id = str(request.tool_call.get("id") or "missing_tool_call_id")
|
||||
return ToolMessage(
|
||||
content=(f"Error: Tool '{tool_name}' is deferred and has not been promoted yet. Call tool_search first to expose and promote this tool's schema, then retry."),
|
||||
content=(f"Error: Tool '{name}' is deferred and has not been promoted yet. Call tool_search first to expose and promote this tool's schema, then retry."),
|
||||
tool_call_id=tool_call_id,
|
||||
name=tool_name,
|
||||
name=name,
|
||||
status="error",
|
||||
)
|
||||
|
||||
|
||||
@ -28,6 +28,7 @@ Date-update format:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
@ -43,6 +44,12 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Upper bound (seconds) for a single _inject() offload. If the warm-up at
|
||||
# gateway startup failed silently, the first request may still hit a cold
|
||||
# tiktoken BPE download that blocks until the OS TCP timeout (~26 min).
|
||||
# This cap ensures the request degrades gracefully instead of hanging.
|
||||
_INJECT_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
_DATE_RE = re.compile(r"<current_date>([^<]+)</current_date>")
|
||||
_DYNAMIC_CONTEXT_REMINDER_KEY = "dynamic_context_reminder"
|
||||
_SUMMARY_MESSAGE_NAME = "summary"
|
||||
@ -201,4 +208,25 @@ class DynamicContextMiddleware(AgentMiddleware):
|
||||
|
||||
@override
|
||||
async def abefore_agent(self, state, runtime: Runtime) -> dict | None:
|
||||
return self._inject(state)
|
||||
# _inject() performs synchronous file I/O (memory JSON loading) and
|
||||
# potentially blocking network calls (tiktoken encoding download on
|
||||
# first use). Offload to a thread so the event loop is never blocked
|
||||
# — a blocking call here starves all concurrent HTTP handlers (auth,
|
||||
# SSE heartbeats, etc.). See issue #3402.
|
||||
#
|
||||
# Bounded timeout: if startup warm-up failed silently (e.g. network
|
||||
# blip during deploy), the first request's cold tiktoken download can
|
||||
# block for tens of minutes (OS TCP timeout). Time-box injection so
|
||||
# the request degrades gracefully (no memory context) rather than
|
||||
# hanging.
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
asyncio.to_thread(self._inject, state),
|
||||
timeout=_INJECT_TIMEOUT_SECONDS,
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"DynamicContextMiddleware: injection timed out (%.1fs); skipping memory/date injection for this turn",
|
||||
_INJECT_TIMEOUT_SECONDS,
|
||||
)
|
||||
return None
|
||||
|
||||
@ -62,6 +62,41 @@ _AUTH_PATTERNS = (
|
||||
"未授权",
|
||||
)
|
||||
|
||||
# Per-exception retry budget overrides.
|
||||
#
|
||||
# Some transient errors are retriable in principle but expensive to retry at
|
||||
# the default budget. StreamChunkTimeoutError in particular fires after the
|
||||
# upstream provider has already stalled for `stream_chunk_timeout` seconds
|
||||
# (typically 120-240s); a full 3-attempt loop can therefore stack 6-12 minutes
|
||||
# of dead air before surfacing the failure to the user. We keep exactly one
|
||||
# retry (cheap reconnect that catches genuine transient TCP blips) and then
|
||||
# fail fast — the same buffered payload is overwhelmingly likely to fail
|
||||
# again at the upstream provider for the same reason.
|
||||
#
|
||||
# Keys are exception class *names* (not classes) so we don't introduce
|
||||
# import-time coupling on optional dependencies like langchain-openai. The
|
||||
# value is the absolute max attempt count, NOT additional retries — so a
|
||||
# value of 2 means "1 first attempt + 1 retry" (the CR-requested
|
||||
# "keep one retry" behavior).
|
||||
_RETRY_BUDGET_OVERRIDES: dict[str, int] = {
|
||||
"StreamChunkTimeoutError": 2,
|
||||
}
|
||||
|
||||
# Exception class names that indicate the upstream stream-chunk watchdog
|
||||
# fired because the model stalled mid-flight. These deserve a more specific
|
||||
# user-facing message than the generic "temporarily unavailable" copy,
|
||||
# because the typical root cause is a long tool-call serialization stalling
|
||||
# the upstream stream — and the most actionable advice we can give the user
|
||||
# is "ask for a shorter / split output" rather than "wait and retry".
|
||||
# Generic connection drops (httpx RemoteProtocolError / ReadError) are
|
||||
# intentionally excluded: they routinely fire on transient network blips
|
||||
# with normal payloads, where the "split the work" guidance is misleading.
|
||||
_STREAM_DROP_EXCEPTIONS: frozenset[str] = frozenset(
|
||||
{
|
||||
"StreamChunkTimeoutError",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
"""Retry transient LLM errors and surface graceful assistant messages."""
|
||||
@ -83,6 +118,18 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
self._circuit_state = "closed"
|
||||
self._circuit_probe_in_flight = False
|
||||
|
||||
def _max_attempts_for(self, exc: BaseException) -> int:
|
||||
"""Return the effective max attempt count for this exception.
|
||||
|
||||
Falls back to `self.retry_max_attempts` unless the exception class name
|
||||
appears in the per-exception override table.
|
||||
"""
|
||||
override = _RETRY_BUDGET_OVERRIDES.get(type(exc).__name__)
|
||||
if override is None:
|
||||
return self.retry_max_attempts
|
||||
|
||||
return min(override, self.retry_max_attempts)
|
||||
|
||||
def _check_circuit(self) -> bool:
|
||||
"""Returns True if circuit is OPEN (fast fail), False otherwise."""
|
||||
with self._circuit_lock:
|
||||
@ -153,6 +200,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
"InternalServerError",
|
||||
"ReadError", # httpx.ReadError: connection dropped mid-stream
|
||||
"RemoteProtocolError", # httpx: server closed connection unexpectedly
|
||||
"StreamChunkTimeoutError", # langchain-openai: chunk gap exceeded stream_chunk_timeout
|
||||
}:
|
||||
return True, "transient"
|
||||
if status_code in _RETRIABLE_STATUS_CODES:
|
||||
@ -177,6 +225,24 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
def _build_circuit_breaker_message(self) -> str:
|
||||
return "The configured LLM provider is currently unavailable due to continuous failures. Circuit breaker is engaged to protect the system. Please wait a moment before trying again."
|
||||
|
||||
def _build_error_fallback_message(
|
||||
self,
|
||||
content: str,
|
||||
*,
|
||||
error_type: str,
|
||||
reason: str,
|
||||
detail: str,
|
||||
) -> AIMessage:
|
||||
return AIMessage(
|
||||
content=content,
|
||||
additional_kwargs={
|
||||
"deerflow_error_fallback": True,
|
||||
"error_type": error_type,
|
||||
"error_reason": reason,
|
||||
"error_detail": detail,
|
||||
},
|
||||
)
|
||||
|
||||
def _build_user_message(self, exc: BaseException, reason: str) -> str:
|
||||
detail = _extract_error_detail(exc)
|
||||
if reason == "quota":
|
||||
@ -184,9 +250,31 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
if reason == "auth":
|
||||
return "The configured LLM provider rejected the request because authentication or access is invalid. Please check the provider credentials and try again."
|
||||
if reason in {"busy", "transient"}:
|
||||
# Stream-drop failures (chunk-gap timeout, peer-closed connection,
|
||||
# raw read error) almost always point at a single oversized
|
||||
# tool-call payload — the model spent so long serializing JSON
|
||||
# arguments that the upstream provider buffered and the stream
|
||||
# gap exceeded `stream_chunk_timeout`. Surfacing this distinct
|
||||
# cause lets the user split or shorten their next request
|
||||
# instead of helplessly retrying the same prompt.
|
||||
if type(exc).__name__ in _STREAM_DROP_EXCEPTIONS:
|
||||
return (
|
||||
"The model's streaming response was interrupted before it could "
|
||||
"finish. This usually happens when a single response or tool call "
|
||||
"is very large — please ask the assistant to split the work into "
|
||||
"smaller steps, or shorten the requested output, and try again."
|
||||
)
|
||||
return "The configured LLM provider is temporarily unavailable after multiple retries. Please wait a moment and continue the conversation."
|
||||
return f"LLM request failed: {detail}"
|
||||
|
||||
def _build_user_fallback_message(self, exc: BaseException, reason: str) -> AIMessage:
|
||||
return self._build_error_fallback_message(
|
||||
self._build_user_message(exc, reason),
|
||||
error_type=type(exc).__name__,
|
||||
reason=reason,
|
||||
detail=_extract_error_detail(exc),
|
||||
)
|
||||
|
||||
def _emit_retry_event(self, attempt: int, wait_ms: int, reason: str) -> None:
|
||||
try:
|
||||
from langgraph.config import get_stream_writer
|
||||
@ -212,7 +300,12 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelCallResult:
|
||||
if self._check_circuit():
|
||||
return AIMessage(content=self._build_circuit_breaker_message())
|
||||
return self._build_error_fallback_message(
|
||||
self._build_circuit_breaker_message(),
|
||||
error_type="CircuitBreakerOpen",
|
||||
reason="circuit_open",
|
||||
detail="LLM circuit breaker is open",
|
||||
)
|
||||
|
||||
attempt = 1
|
||||
while True:
|
||||
@ -228,7 +321,8 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
raise
|
||||
except Exception as exc:
|
||||
retriable, reason = self._classify_error(exc)
|
||||
if retriable and attempt < self.retry_max_attempts:
|
||||
max_attempts = self._max_attempts_for(exc)
|
||||
if retriable and attempt < max_attempts:
|
||||
wait_ms = self._build_retry_delay_ms(attempt, exc)
|
||||
logger.warning(
|
||||
"Transient LLM error on attempt %d/%d; retrying in %dms: %s",
|
||||
@ -249,7 +343,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
)
|
||||
if retriable:
|
||||
self._record_failure()
|
||||
return AIMessage(content=self._build_user_message(exc, reason))
|
||||
return self._build_user_fallback_message(exc, reason)
|
||||
|
||||
@override
|
||||
async def awrap_model_call(
|
||||
@ -258,7 +352,12 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelCallResult:
|
||||
if self._check_circuit():
|
||||
return AIMessage(content=self._build_circuit_breaker_message())
|
||||
return self._build_error_fallback_message(
|
||||
self._build_circuit_breaker_message(),
|
||||
error_type="CircuitBreakerOpen",
|
||||
reason="circuit_open",
|
||||
detail="LLM circuit breaker is open",
|
||||
)
|
||||
|
||||
attempt = 1
|
||||
while True:
|
||||
@ -274,7 +373,8 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
raise
|
||||
except Exception as exc:
|
||||
retriable, reason = self._classify_error(exc)
|
||||
if retriable and attempt < self.retry_max_attempts:
|
||||
max_attempts = self._max_attempts_for(exc)
|
||||
if retriable and attempt < max_attempts:
|
||||
wait_ms = self._build_retry_delay_ms(attempt, exc)
|
||||
logger.warning(
|
||||
"Transient LLM error on attempt %d/%d; retrying in %dms: %s",
|
||||
@ -295,7 +395,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
)
|
||||
if retriable:
|
||||
self._record_failure()
|
||||
return AIMessage(content=self._build_user_message(exc, reason))
|
||||
return self._build_user_fallback_message(exc, reason)
|
||||
|
||||
|
||||
def _matches_any(detail: str, patterns: tuple[str, ...]) -> bool:
|
||||
|
||||
@ -6,10 +6,36 @@ arguments indefinitely until the recursion limit kills the run.
|
||||
Detection strategy:
|
||||
1. After each model response, hash the tool calls (name + args).
|
||||
2. Track recent hashes in a sliding window.
|
||||
3. If the same hash appears >= warn_threshold times, inject a
|
||||
"you are repeating yourself — wrap up" system message (once per hash).
|
||||
3. If the same hash appears >= warn_threshold times, queue a
|
||||
"you are repeating yourself — wrap up" warning for the current
|
||||
thread/run. The warning is **injected at the next model call** (in
|
||||
``wrap_model_call``) as a ``HumanMessage`` appended to the message
|
||||
list, *after* all ToolMessage responses to the previous
|
||||
AIMessage(tool_calls).
|
||||
4. If it appears >= hard_limit times, strip all tool_calls from the
|
||||
response so the agent is forced to produce a final text answer.
|
||||
|
||||
Why the warning is injected at ``wrap_model_call`` instead of
|
||||
``after_model``:
|
||||
|
||||
``after_model`` fires immediately after the model emits an
|
||||
``AIMessage`` that may carry ``tool_calls``. The tools node has not
|
||||
run yet, so no matching ``ToolMessage`` exists in the history. Any
|
||||
message we add here lands *between* the assistant's tool_calls and
|
||||
their responses. OpenAI/Moonshot reject the next request with
|
||||
``"tool_call_ids did not have response messages"`` because their
|
||||
validators require the assistant's tool_calls to be followed
|
||||
immediately by tool messages. Anthropic also disallows mid-stream
|
||||
``SystemMessage``. By deferring the warning to ``wrap_model_call``,
|
||||
every prior ToolMessage is already present in the request's message
|
||||
list and the warning is appended at the end — pairing intact, no
|
||||
``AIMessage`` semantics are mutated.
|
||||
|
||||
Queued warnings are intentionally transient. If a run ends before the
|
||||
next model request drains a queued warning, ``after_agent`` drops it
|
||||
instead of carrying it into a later invocation for the same thread. The
|
||||
hard-stop path still forces termination when the configured safety limit
|
||||
is reached.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -19,11 +45,14 @@ import json
|
||||
import logging
|
||||
import threading
|
||||
from collections import OrderedDict, defaultdict
|
||||
from collections.abc import Awaitable, Callable
|
||||
from copy import deepcopy
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -38,6 +67,7 @@ _DEFAULT_WINDOW_SIZE = 20 # track last N tool calls
|
||||
_DEFAULT_MAX_TRACKED_THREADS = 100 # LRU eviction limit
|
||||
_DEFAULT_TOOL_FREQ_WARN = 30 # warn after 30 calls to the same tool type
|
||||
_DEFAULT_TOOL_FREQ_HARD_LIMIT = 50 # force-stop after 50 calls to the same tool type
|
||||
_MAX_PENDING_WARNINGS_PER_RUN = 4
|
||||
|
||||
|
||||
def _normalize_tool_call_args(raw_args: object) -> tuple[dict, str | None]:
|
||||
@ -195,6 +225,12 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
||||
self._warned: dict[str, set[str]] = defaultdict(set)
|
||||
self._tool_freq: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
||||
self._tool_freq_warned: dict[str, set[str]] = defaultdict(set)
|
||||
# Per-thread/run queue of warnings to inject at the next model call.
|
||||
# Populated by ``after_model`` (detection) and drained by
|
||||
# ``wrap_model_call`` (injection); see module docstring.
|
||||
self._pending_warnings: dict[tuple[str, str], list[str]] = defaultdict(list)
|
||||
self._pending_warning_touch_order: OrderedDict[tuple[str, str], None] = OrderedDict()
|
||||
self._max_pending_warning_keys = max(1, self.max_tracked_threads * 2)
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: LoopDetectionConfig) -> LoopDetectionMiddleware:
|
||||
@ -213,9 +249,20 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
||||
"""Extract thread_id from runtime context for per-thread tracking."""
|
||||
thread_id = runtime.context.get("thread_id") if runtime.context else None
|
||||
if thread_id:
|
||||
return thread_id
|
||||
return str(thread_id)
|
||||
return "default"
|
||||
|
||||
def _get_run_id(self, runtime: Runtime) -> str:
|
||||
"""Extract run_id from runtime context for per-run warning scoping."""
|
||||
run_id = runtime.context.get("run_id") if runtime.context else None
|
||||
if run_id:
|
||||
return str(run_id)
|
||||
return "default"
|
||||
|
||||
def _pending_key(self, runtime: Runtime) -> tuple[str, str]:
|
||||
"""Return the pending-warning key for the current thread/run."""
|
||||
return self._get_thread_id(runtime), self._get_run_id(runtime)
|
||||
|
||||
def _evict_if_needed(self) -> None:
|
||||
"""Evict least recently used threads if over the limit.
|
||||
|
||||
@ -226,8 +273,52 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
||||
self._warned.pop(evicted_id, None)
|
||||
self._tool_freq.pop(evicted_id, None)
|
||||
self._tool_freq_warned.pop(evicted_id, None)
|
||||
for key in list(self._pending_warnings):
|
||||
if key[0] == evicted_id:
|
||||
self._drop_pending_warning_key_locked(key)
|
||||
logger.debug("Evicted loop tracking for thread %s (LRU)", evicted_id)
|
||||
|
||||
def _drop_pending_warning_key_locked(self, key: tuple[str, str]) -> None:
|
||||
"""Drop all pending-warning bookkeeping for one thread/run key.
|
||||
|
||||
Must be called while holding self._lock.
|
||||
"""
|
||||
self._pending_warnings.pop(key, None)
|
||||
self._pending_warning_touch_order.pop(key, None)
|
||||
|
||||
def _touch_pending_warning_key_locked(self, key: tuple[str, str]) -> None:
|
||||
"""Mark a pending-warning key as recently used.
|
||||
|
||||
Must be called while holding self._lock.
|
||||
"""
|
||||
self._pending_warning_touch_order[key] = None
|
||||
self._pending_warning_touch_order.move_to_end(key)
|
||||
|
||||
def _prune_pending_warning_state_locked(self, protected_key: tuple[str, str]) -> None:
|
||||
"""Cap pending-warning state across abnormal or concurrent runs.
|
||||
|
||||
Must be called while holding self._lock.
|
||||
"""
|
||||
overflow = len(self._pending_warning_touch_order) - self._max_pending_warning_keys
|
||||
if overflow <= 0:
|
||||
return
|
||||
|
||||
candidates = [key for key in self._pending_warning_touch_order if key != protected_key]
|
||||
for key in candidates[:overflow]:
|
||||
self._drop_pending_warning_key_locked(key)
|
||||
|
||||
def _queue_pending_warning(self, runtime: Runtime, warning: str) -> None:
|
||||
"""Queue one transient warning for the current thread/run with caps."""
|
||||
pending_key = self._pending_key(runtime)
|
||||
with self._lock:
|
||||
warnings = self._pending_warnings[pending_key]
|
||||
if warning not in warnings:
|
||||
warnings.append(warning)
|
||||
if len(warnings) > _MAX_PENDING_WARNINGS_PER_RUN:
|
||||
del warnings[: len(warnings) - _MAX_PENDING_WARNINGS_PER_RUN]
|
||||
self._touch_pending_warning_key_locked(pending_key)
|
||||
self._prune_pending_warning_state_locked(protected_key=pending_key)
|
||||
|
||||
def _track_and_check(self, state: AgentState, runtime: Runtime) -> tuple[str | None, bool]:
|
||||
"""Track tool calls and check for loops.
|
||||
|
||||
@ -268,6 +359,12 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
||||
if len(history) > self.window_size:
|
||||
history[:] = history[-self.window_size :]
|
||||
|
||||
warned_hashes = self._warned.get(thread_id)
|
||||
if warned_hashes is not None:
|
||||
warned_hashes.intersection_update(history)
|
||||
if not warned_hashes:
|
||||
self._warned.pop(thread_id, None)
|
||||
|
||||
count = history.count(call_hash)
|
||||
tool_names = [tc.get("name", "?") for tc in tool_calls]
|
||||
|
||||
@ -381,7 +478,10 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
||||
warning, hard_stop = self._track_and_check(state, runtime)
|
||||
|
||||
if hard_stop:
|
||||
# Strip tool_calls from the last AIMessage to force text output
|
||||
# Strip tool_calls from the last AIMessage to force text output.
|
||||
# Once tool_calls are stripped, the AIMessage no longer requires
|
||||
# matching ToolMessage responses, so mutating it in place here
|
||||
# is safe for OpenAI/Moonshot pairing validators.
|
||||
messages = state.get("messages", [])
|
||||
last_msg = messages[-1]
|
||||
content = self._append_text(last_msg.content, warning or _HARD_STOP_MSG)
|
||||
@ -389,33 +489,48 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
||||
return {"messages": [stripped_msg]}
|
||||
|
||||
if warning:
|
||||
# WORKAROUND for v2.0-m1 — see #2724.
|
||||
#
|
||||
# Append the warning to the AIMessage content instead of
|
||||
# injecting a separate HumanMessage. Inserting any non-tool
|
||||
# message between an AIMessage(tool_calls=...) and its
|
||||
# ToolMessage responses breaks OpenAI/Moonshot strict pairing
|
||||
# validation ("tool_call_ids did not have response messages")
|
||||
# because the tools node has not run yet at after_model time.
|
||||
# tool_calls are preserved so the tools node still executes.
|
||||
#
|
||||
# This is a temporary mitigation: mutating an existing
|
||||
# AIMessage to carry framework-authored text leaks loop-warning
|
||||
# text into downstream consumers (MemoryMiddleware fact
|
||||
# extraction, TitleMiddleware, telemetry, model replay) as if
|
||||
# the model said it. The proper fix is to defer warning
|
||||
# injection from after_model to wrap_model_call so every prior
|
||||
# ToolMessage is already in the request — see RFC #2517 (which
|
||||
# lists "loop intervention does not leave invalid
|
||||
# tool-call/tool-message state" as acceptance criteria) and
|
||||
# the prototype on `fix/loop-detection-tool-call-pairing`.
|
||||
messages = state.get("messages", [])
|
||||
last_msg = messages[-1]
|
||||
patched_msg = last_msg.model_copy(update={"content": self._append_text(last_msg.content, warning)})
|
||||
return {"messages": [patched_msg]}
|
||||
# Defer injection to the next model call. We must NOT alter the
|
||||
# AIMessage(tool_calls=...) here (would put framework words in
|
||||
# the model's mouth, polluting downstream consumers like
|
||||
# MemoryMiddleware), nor insert a separate non-tool message
|
||||
# (would break OpenAI/Moonshot tool-call pairing because the
|
||||
# tools node has not produced ToolMessage responses yet). The
|
||||
# warning is delivered via ``wrap_model_call`` below.
|
||||
self._queue_pending_warning(runtime, warning)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def _clear_other_run_pending_warnings(self, runtime: Runtime) -> None:
|
||||
"""Drop stale pending warnings for previous runs in this thread."""
|
||||
thread_id, current_run_id = self._pending_key(runtime)
|
||||
with self._lock:
|
||||
for key in list(self._pending_warnings):
|
||||
if key[0] == thread_id and key[1] != current_run_id:
|
||||
self._drop_pending_warning_key_locked(key)
|
||||
|
||||
def _clear_current_run_pending_warnings(self, runtime: Runtime) -> None:
|
||||
"""Drop pending warnings owned by the current thread/run."""
|
||||
pending_key = self._pending_key(runtime)
|
||||
with self._lock:
|
||||
self._drop_pending_warning_key_locked(pending_key)
|
||||
|
||||
@staticmethod
|
||||
def _format_warning_message(warnings: list[str]) -> str:
|
||||
"""Merge pending warnings into one prompt message."""
|
||||
deduped = list(dict.fromkeys(warnings))
|
||||
return "\n\n".join(deduped)
|
||||
|
||||
@override
|
||||
def before_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
self._clear_other_run_pending_warnings(runtime)
|
||||
return None
|
||||
|
||||
@override
|
||||
async def abefore_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
self._clear_other_run_pending_warnings(runtime)
|
||||
return None
|
||||
|
||||
@override
|
||||
def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
return self._apply(state, runtime)
|
||||
@ -424,6 +539,59 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
||||
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
return self._apply(state, runtime)
|
||||
|
||||
@override
|
||||
def after_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
self._clear_current_run_pending_warnings(runtime)
|
||||
return None
|
||||
|
||||
@override
|
||||
async def aafter_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
self._clear_current_run_pending_warnings(runtime)
|
||||
return None
|
||||
|
||||
def _drain_pending_warnings(self, runtime: Runtime) -> list[str]:
|
||||
"""Pop and return all queued warnings for *runtime*'s thread/run."""
|
||||
pending_key = self._pending_key(runtime)
|
||||
with self._lock:
|
||||
warnings = self._pending_warnings.pop(pending_key, [])
|
||||
self._pending_warning_touch_order.pop(pending_key, None)
|
||||
return warnings
|
||||
|
||||
def _augment_request(self, request: ModelRequest) -> ModelRequest:
|
||||
"""Append queued loop warnings (if any) to the outgoing message list.
|
||||
|
||||
The warning is placed *after* every existing message, including the
|
||||
ToolMessage responses to the previous AIMessage(tool_calls). This
|
||||
keeps ``assistant tool_calls -> tool_messages`` pairing intact for
|
||||
OpenAI/Moonshot, avoids the Anthropic mid-stream SystemMessage
|
||||
restriction (we use HumanMessage), and never mutates an existing
|
||||
AIMessage.
|
||||
"""
|
||||
warnings = self._drain_pending_warnings(request.runtime)
|
||||
if not warnings:
|
||||
return request
|
||||
new_messages = [
|
||||
*request.messages,
|
||||
HumanMessage(content=self._format_warning_message(warnings), name="loop_warning"),
|
||||
]
|
||||
return request.override(messages=new_messages)
|
||||
|
||||
@override
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelCallResult:
|
||||
return handler(self._augment_request(request))
|
||||
|
||||
@override
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelCallResult:
|
||||
return await handler(self._augment_request(request))
|
||||
|
||||
def reset(self, thread_id: str | None = None) -> None:
|
||||
"""Clear tracking state. If thread_id given, clear only that thread."""
|
||||
with self._lock:
|
||||
@ -432,8 +600,13 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
||||
self._warned.pop(thread_id, None)
|
||||
self._tool_freq.pop(thread_id, None)
|
||||
self._tool_freq_warned.pop(thread_id, None)
|
||||
for key in list(self._pending_warnings):
|
||||
if key[0] == thread_id:
|
||||
self._drop_pending_warning_key_locked(key)
|
||||
else:
|
||||
self._history.clear()
|
||||
self._warned.clear()
|
||||
self._tool_freq.clear()
|
||||
self._tool_freq_warned.clear()
|
||||
self._pending_warnings.clear()
|
||||
self._pending_warning_touch_order.clear()
|
||||
|
||||
@ -0,0 +1,317 @@
|
||||
"""Suppress tool execution when the provider safety-terminated the response.
|
||||
|
||||
Background — see issue bytedance/deer-flow#3028.
|
||||
|
||||
Some providers (OpenAI ``finish_reason='content_filter'``, Anthropic
|
||||
``stop_reason='refusal'``, Gemini ``finish_reason='SAFETY'`` ...) can stop
|
||||
generation mid-stream while still returning partially-formed ``tool_calls``.
|
||||
LangChain's tool router treats any AIMessage with a non-empty ``tool_calls``
|
||||
field as "go execute these", so half-truncated arguments — e.g. a markdown
|
||||
``write_file`` that stops in the middle of a sentence — get dispatched as if
|
||||
they were complete. The agent then sees the truncated file, tries to fix it,
|
||||
gets filtered again, and loops.
|
||||
|
||||
This middleware sits at ``after_model`` and gates that behaviour: when a
|
||||
configured ``SafetyTerminationDetector`` fires *and* the AIMessage carries
|
||||
tool calls, we strip the tool calls (both structured and raw provider
|
||||
payloads), append a user-facing explanation, and stash observability fields
|
||||
in ``additional_kwargs.safety_termination`` so logs, traces, and SSE
|
||||
consumers can see what happened.
|
||||
|
||||
Hook choice: ``after_model`` (not ``wrap_model_call``) because the response
|
||||
is a *normal* return — not an exception — and we want to participate in the
|
||||
same after-model chain as ``LoopDetectionMiddleware``, with which we share
|
||||
the same tool-call-suppression mechanic but a different trigger.
|
||||
|
||||
Placement: register *after* ``LoopDetectionMiddleware`` in the middleware
|
||||
list. LangChain factory wires ``after_model`` edges in reverse list order
|
||||
(``langchain/agents/factory.py:add_edge("model", middleware_w_after_model[-1])``,
|
||||
then walks ``range(len-1, 0, -1)``), so the *last* registered middleware is
|
||||
the *first* to observe the model output. Registering Safety after Loop
|
||||
means Safety sees the raw response first, clears tool calls if it fires,
|
||||
and Loop then accounts against the cleaned message.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.messages import AIMessage
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from deerflow.agents.middlewares.safety_termination_detectors import (
|
||||
SafetyTermination,
|
||||
SafetyTerminationDetector,
|
||||
default_detectors,
|
||||
)
|
||||
from deerflow.agents.middlewares.tool_call_metadata import clone_ai_message_with_tool_calls
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from deerflow.config.safety_finish_reason_config import SafetyFinishReasonConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_USER_FACING_MESSAGE = (
|
||||
"The model provider stopped this response with a safety-related signal "
|
||||
"({reason_field}={reason_value!r}, detector={detector!r}). Any tool "
|
||||
"calls produced in this turn were suppressed because their arguments "
|
||||
"may be truncated and unsafe to execute. Please rephrase the request "
|
||||
"or ask for a narrower output."
|
||||
)
|
||||
|
||||
|
||||
class SafetyFinishReasonMiddleware(AgentMiddleware[AgentState]):
|
||||
"""Strip tool_calls from AIMessages flagged by a SafetyTerminationDetector."""
|
||||
|
||||
def __init__(self, detectors: list[SafetyTerminationDetector] | None = None) -> None:
|
||||
super().__init__()
|
||||
# Copy so caller mutations after construction don't leak into us.
|
||||
self._detectors: list[SafetyTerminationDetector] = list(detectors) if detectors else default_detectors()
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: SafetyFinishReasonConfig) -> SafetyFinishReasonMiddleware:
|
||||
"""Construct from validated Pydantic config, honouring the
|
||||
reflection-loaded detector list when provided.
|
||||
|
||||
An explicit empty list is intentionally rejected — it would silently
|
||||
disable detection while leaving the middleware in the chain, which
|
||||
is the worst of both worlds. Use ``enabled: false`` instead.
|
||||
"""
|
||||
if config.detectors is None:
|
||||
return cls()
|
||||
|
||||
if not config.detectors:
|
||||
raise ValueError("safety_finish_reason.detectors must be omitted (use built-ins) or contain at least one entry; use enabled=false to disable the middleware entirely.")
|
||||
|
||||
from deerflow.reflection import resolve_variable
|
||||
|
||||
detectors: list[SafetyTerminationDetector] = []
|
||||
for entry in config.detectors:
|
||||
detector_cls = resolve_variable(entry.use)
|
||||
kwargs = dict(entry.config) if entry.config else {}
|
||||
detector = detector_cls(**kwargs)
|
||||
if not isinstance(detector, SafetyTerminationDetector):
|
||||
raise TypeError(f"{entry.use} did not produce a SafetyTerminationDetector (got {type(detector).__name__}); ensure it has a `name` attribute and a `detect(message)` method")
|
||||
detectors.append(detector)
|
||||
return cls(detectors=detectors)
|
||||
|
||||
# ----- detection -------------------------------------------------------
|
||||
|
||||
def _detect(self, message: AIMessage) -> SafetyTermination | None:
|
||||
for detector in self._detectors:
|
||||
try:
|
||||
hit = detector.detect(message)
|
||||
except Exception: # noqa: BLE001 - never let a buggy detector break the agent run
|
||||
logger.exception("SafetyTerminationDetector %r raised; treating as no-match", getattr(detector, "name", type(detector).__name__))
|
||||
continue
|
||||
if hit is not None:
|
||||
return hit
|
||||
return None
|
||||
|
||||
# ----- message rewriting ----------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _append_user_message(content: object, text: str) -> str | list:
|
||||
"""Append a plain-text explanation to AIMessage content.
|
||||
|
||||
Mirrors ``LoopDetectionMiddleware._append_text`` so list-content
|
||||
responses (Anthropic thinking blocks, vLLM reasoning splits) keep
|
||||
their structure instead of being string-coerced into a TypeError.
|
||||
"""
|
||||
if content is None or content == "":
|
||||
return text
|
||||
if isinstance(content, list):
|
||||
return [*content, {"type": "text", "text": f"\n\n{text}"}]
|
||||
if isinstance(content, str):
|
||||
return content + f"\n\n{text}"
|
||||
return str(content) + f"\n\n{text}"
|
||||
|
||||
def _build_suppressed_message(
|
||||
self,
|
||||
message: AIMessage,
|
||||
termination: SafetyTermination,
|
||||
) -> AIMessage:
|
||||
suppressed_names = [tc.get("name") or "unknown" for tc in (message.tool_calls or [])]
|
||||
explanation = _USER_FACING_MESSAGE.format(
|
||||
reason_field=termination.reason_field,
|
||||
reason_value=termination.reason_value,
|
||||
detector=termination.detector,
|
||||
)
|
||||
new_content = self._append_user_message(message.content, explanation)
|
||||
|
||||
# clone_ai_message_with_tool_calls handles structured tool_calls,
|
||||
# raw additional_kwargs.tool_calls, and function_call in one shot.
|
||||
# It only rewrites finish_reason when the old value was "tool_calls",
|
||||
# which is not our case — content_filter / refusal / SAFETY stay put
|
||||
# so downstream SSE / converters keep seeing the real provider reason.
|
||||
cleared = clone_ai_message_with_tool_calls(message, [], content=new_content)
|
||||
|
||||
# Re-clone additional_kwargs so we don't accidentally mutate the
|
||||
# dict returned by clone_ai_message_with_tool_calls (which already
|
||||
# made a shallow copy, but downstream model_copy still references
|
||||
# it). Then stamp the observability record.
|
||||
kwargs = dict(getattr(cleared, "additional_kwargs", None) or {})
|
||||
kwargs["safety_termination"] = {
|
||||
"detector": termination.detector,
|
||||
"reason_field": termination.reason_field,
|
||||
"reason_value": termination.reason_value,
|
||||
"suppressed_tool_call_count": len(suppressed_names),
|
||||
"suppressed_tool_call_names": suppressed_names,
|
||||
"extras": dict(termination.extras) if termination.extras else {},
|
||||
}
|
||||
return cleared.model_copy(update={"additional_kwargs": kwargs})
|
||||
|
||||
# ----- observability ---------------------------------------------------
|
||||
|
||||
def _emit_event(
|
||||
self,
|
||||
termination: SafetyTermination,
|
||||
suppressed_names: list[str],
|
||||
runtime: Runtime,
|
||||
) -> None:
|
||||
"""Notify SSE consumers (e.g. the web UI) that a tool turn was
|
||||
suppressed so they can reconcile any "tool starting..." placeholders
|
||||
already streamed to the user. Failures are logged at debug and
|
||||
ignored — this is a best-effort signal."""
|
||||
try:
|
||||
from langgraph.config import get_stream_writer
|
||||
|
||||
writer = get_stream_writer()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("get_stream_writer unavailable; skipping safety_termination event", exc_info=True)
|
||||
return
|
||||
|
||||
thread_id = None
|
||||
if runtime is not None and getattr(runtime, "context", None):
|
||||
thread_id = runtime.context.get("thread_id") if isinstance(runtime.context, dict) else None
|
||||
|
||||
try:
|
||||
writer(
|
||||
{
|
||||
"type": "safety_termination",
|
||||
"detector": termination.detector,
|
||||
"reason_field": termination.reason_field,
|
||||
"reason_value": termination.reason_value,
|
||||
"suppressed_tool_call_count": len(suppressed_names),
|
||||
"suppressed_tool_call_names": suppressed_names,
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Failed to emit safety_termination stream event", exc_info=True)
|
||||
|
||||
def _record_audit_event(
|
||||
self,
|
||||
termination: SafetyTermination,
|
||||
message,
|
||||
tool_calls: list[dict],
|
||||
runtime: Runtime,
|
||||
) -> None:
|
||||
"""Write a ``middleware:safety_termination`` record to RunEventStore
|
||||
for post-run auditability.
|
||||
|
||||
The custom stream event in ``_emit_event`` is consumed by live SSE
|
||||
clients and disappears after the run; this event is persisted so an
|
||||
operator can answer "which runs were safety-suppressed today?" from
|
||||
a single SQL query without joining the message body. Worker exposes
|
||||
the run-scoped ``RunJournal`` via ``runtime.context["__run_journal"]``;
|
||||
absent in unit-test / subagent / no-event-store paths, in which case
|
||||
we silently skip.
|
||||
|
||||
Tool **arguments** are deliberately **not** recorded — those are the
|
||||
very content the provider filtered; persisting them would defeat the
|
||||
purpose of the safety filter. Names / count / ids are sufficient for
|
||||
audit and debugging (issue #3028 review).
|
||||
"""
|
||||
journal = None
|
||||
if runtime is not None and getattr(runtime, "context", None):
|
||||
context = runtime.context
|
||||
if isinstance(context, dict):
|
||||
journal = context.get("__run_journal")
|
||||
if journal is None:
|
||||
return
|
||||
|
||||
suppressed_names = [tc.get("name") or "unknown" for tc in tool_calls]
|
||||
suppressed_ids = [tc.get("id") for tc in tool_calls if tc.get("id")]
|
||||
|
||||
changes = {
|
||||
"detector": termination.detector,
|
||||
"reason_field": termination.reason_field,
|
||||
"reason_value": termination.reason_value,
|
||||
"suppressed_tool_call_count": len(tool_calls),
|
||||
"suppressed_tool_call_names": suppressed_names,
|
||||
"suppressed_tool_call_ids": suppressed_ids,
|
||||
"message_id": getattr(message, "id", None),
|
||||
"extras": dict(termination.extras) if termination.extras else {},
|
||||
}
|
||||
|
||||
try:
|
||||
journal.record_middleware(
|
||||
tag="safety_termination",
|
||||
name=type(self).__name__,
|
||||
hook="after_model",
|
||||
action="suppress_tool_calls",
|
||||
changes=changes,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
# Audit-event persistence must never break agent execution.
|
||||
logger.debug("Failed to record middleware:safety_termination event", exc_info=True)
|
||||
|
||||
# ----- main apply ------------------------------------------------------
|
||||
|
||||
def _apply(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
messages = state.get("messages", [])
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
last = messages[-1]
|
||||
if not isinstance(last, AIMessage):
|
||||
return None
|
||||
|
||||
# Issue scope: only intervene when there's something to suppress.
|
||||
# ``content_filter`` without tool_calls is allowed through unchanged
|
||||
# so the partial text response (if any) reaches the user naturally.
|
||||
tool_calls = last.tool_calls
|
||||
if not tool_calls:
|
||||
return None
|
||||
|
||||
termination = self._detect(last)
|
||||
if termination is None:
|
||||
return None
|
||||
|
||||
patched = self._build_suppressed_message(last, termination)
|
||||
|
||||
thread_id = None
|
||||
if runtime is not None and getattr(runtime, "context", None):
|
||||
thread_id = runtime.context.get("thread_id") if isinstance(runtime.context, dict) else None
|
||||
|
||||
logger.warning(
|
||||
"Provider safety termination detected — suppressed %d tool call(s)",
|
||||
len(tool_calls),
|
||||
extra={
|
||||
"thread_id": thread_id,
|
||||
"detector": termination.detector,
|
||||
"reason_field": termination.reason_field,
|
||||
"reason_value": termination.reason_value,
|
||||
"suppressed_tool_call_names": [tc.get("name") for tc in tool_calls],
|
||||
},
|
||||
)
|
||||
|
||||
self._emit_event(termination, [tc.get("name") or "unknown" for tc in tool_calls], runtime)
|
||||
self._record_audit_event(termination, last, list(tool_calls), runtime)
|
||||
|
||||
return {"messages": [patched]}
|
||||
|
||||
# ----- hooks -----------------------------------------------------------
|
||||
|
||||
@override
|
||||
def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
return self._apply(state, runtime)
|
||||
|
||||
@override
|
||||
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
return self._apply(state, runtime)
|
||||
@ -0,0 +1,237 @@
|
||||
"""Detectors for provider-side safety termination signals.
|
||||
|
||||
Different LLM providers signal "I stopped this response for safety reasons"
|
||||
through different fields with different values. This module defines a small
|
||||
strategy interface and three built-in detectors that cover the major
|
||||
providers DeerFlow supports today. New providers (Wenxin, Hunyuan, Bedrock
|
||||
adapters, in-house gateways, ...) can be added by implementing
|
||||
``SafetyTerminationDetector`` and wiring it through
|
||||
``config.yaml: safety_finish_reason.detectors``.
|
||||
|
||||
The middleware that consumes these detectors lives in
|
||||
``safety_finish_reason_middleware.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SafetyTermination:
|
||||
"""A detected safety-related termination signal.
|
||||
|
||||
Attributes:
|
||||
detector: Name of the detector that produced this result. Used for
|
||||
observability so operators can see which provider rule fired.
|
||||
reason_field: The message metadata field that carried the signal
|
||||
(e.g. ``finish_reason``, ``stop_reason``).
|
||||
reason_value: The actual value of that field
|
||||
(e.g. ``content_filter``, ``refusal``, ``SAFETY``).
|
||||
extras: Provider-specific metadata that may help downstream
|
||||
consumers (e.g. Azure OpenAI content_filter_results, Gemini
|
||||
safety_ratings). Detectors are free to populate or skip this.
|
||||
"""
|
||||
|
||||
detector: str
|
||||
reason_field: str
|
||||
reason_value: str
|
||||
extras: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SafetyTerminationDetector(Protocol):
|
||||
"""Strategy interface for provider safety termination detection."""
|
||||
|
||||
name: str
|
||||
|
||||
def detect(self, message: AIMessage) -> SafetyTermination | None:
|
||||
"""Return a SafetyTermination if *message* indicates provider safety
|
||||
termination, otherwise return ``None``.
|
||||
|
||||
Implementations must be side-effect free and tolerant of missing or
|
||||
oddly-typed metadata — detectors run on every model response.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def _get_metadata_value(message: AIMessage, field_name: str) -> str | None:
|
||||
"""Read a string-typed value from either ``response_metadata`` or
|
||||
``additional_kwargs``.
|
||||
|
||||
LangChain provider adapters are inconsistent about where they stash
|
||||
provider stop signals. Most modern adapters use ``response_metadata``,
|
||||
but some legacy / passthrough paths still surface them via
|
||||
``additional_kwargs``. We check both, in that order, and only accept
|
||||
string values — Pydantic enums or dicts are ignored so we never raise
|
||||
on malformed inputs.
|
||||
"""
|
||||
for container_name in ("response_metadata", "additional_kwargs"):
|
||||
container = getattr(message, container_name, None) or {}
|
||||
if not isinstance(container, dict):
|
||||
continue
|
||||
value = container.get(field_name)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
class OpenAICompatibleContentFilterDetector:
|
||||
"""OpenAI-compatible content_filter signal.
|
||||
|
||||
Covers OpenAI, Azure OpenAI, Moonshot/Kimi, DeepSeek, Mistral, vLLM,
|
||||
Qwen (OpenAI-compatible mode), and any other adapter that follows the
|
||||
OpenAI ``finish_reason`` convention.
|
||||
|
||||
Some Chinese providers ship custom OpenAI-compatible gateways that use
|
||||
alternative tokens like ``sensitive`` or ``violation``. Extend the set
|
||||
via the ``finish_reasons`` kwarg in config.
|
||||
"""
|
||||
|
||||
name = "openai_compatible_content_filter"
|
||||
|
||||
def __init__(self, finish_reasons: list[str] | tuple[str, ...] | None = None) -> None:
|
||||
configured = finish_reasons if finish_reasons is not None else ("content_filter",)
|
||||
self._finish_reasons: frozenset[str] = frozenset(r.lower() for r in configured)
|
||||
|
||||
def detect(self, message: AIMessage) -> SafetyTermination | None:
|
||||
value = _get_metadata_value(message, "finish_reason")
|
||||
if value is None or value.lower() not in self._finish_reasons:
|
||||
return None
|
||||
|
||||
extras: dict[str, Any] = {}
|
||||
# Azure OpenAI ships a structured content_filter_results block; carry it
|
||||
# through so operators can see *what* was filtered without re-tracing.
|
||||
response_metadata = getattr(message, "response_metadata", None) or {}
|
||||
if isinstance(response_metadata, dict):
|
||||
filter_results = response_metadata.get("content_filter_results")
|
||||
if filter_results:
|
||||
extras["content_filter_results"] = filter_results
|
||||
|
||||
return SafetyTermination(
|
||||
detector=self.name,
|
||||
reason_field="finish_reason",
|
||||
reason_value=value,
|
||||
extras=extras,
|
||||
)
|
||||
|
||||
|
||||
class AnthropicRefusalDetector:
|
||||
"""Anthropic ``stop_reason == "refusal"`` signal.
|
||||
|
||||
Anthropic models surface safety refusals via a dedicated ``stop_reason``
|
||||
rather than ``finish_reason``. See:
|
||||
https://platform.claude.com/docs/en/test-and-evaluate/strengthen-guardrails/handle-streaming-refusals
|
||||
"""
|
||||
|
||||
name = "anthropic_refusal"
|
||||
|
||||
def __init__(self, stop_reasons: list[str] | tuple[str, ...] | None = None) -> None:
|
||||
configured = stop_reasons if stop_reasons is not None else ("refusal",)
|
||||
self._stop_reasons: frozenset[str] = frozenset(r.lower() for r in configured)
|
||||
|
||||
def detect(self, message: AIMessage) -> SafetyTermination | None:
|
||||
value = _get_metadata_value(message, "stop_reason")
|
||||
if value is None or value.lower() not in self._stop_reasons:
|
||||
return None
|
||||
return SafetyTermination(
|
||||
detector=self.name,
|
||||
reason_field="stop_reason",
|
||||
reason_value=value,
|
||||
)
|
||||
|
||||
|
||||
class GeminiSafetyDetector:
|
||||
"""Gemini / Vertex AI safety-related finish reasons.
|
||||
|
||||
Gemini uses the same ``finish_reason`` field as OpenAI but with an
|
||||
enumerated upper-case taxonomy. The default set covers every Gemini
|
||||
finish_reason that means "the model stopped because the content/image
|
||||
tripped a safety, blocklist, recitation, or PII filter" — i.e. cases
|
||||
where any tool_calls returned alongside are likely truncated/
|
||||
unreliable. Full enum:
|
||||
https://docs.cloud.google.com/python/docs/reference/aiplatform/latest/google.cloud.aiplatform_v1.types.Candidate.FinishReason
|
||||
|
||||
Intentionally **excluded** from the default set:
|
||||
- ``STOP`` — normal termination.
|
||||
- ``MAX_TOKENS`` — output length truncation, not safety
|
||||
(same root failure mode as
|
||||
content_filter, but issue #3028
|
||||
scopes it out; expose separately if
|
||||
desired).
|
||||
- ``LANGUAGE`` / ``NO_IMAGE`` — capability mismatches, unrelated to
|
||||
safety; tool_calls would be absent
|
||||
anyway.
|
||||
- ``MALFORMED_FUNCTION_CALL`` /
|
||||
``UNEXPECTED_TOOL_CALL`` — tool-call protocol errors. The
|
||||
tool_calls are *also* unreliable
|
||||
here, but the failure category is
|
||||
distinct from safety filtering;
|
||||
handle in a dedicated detector to
|
||||
keep observability records honest.
|
||||
- ``OTHER`` / ``IMAGE_OTHER`` /
|
||||
``FINISH_REASON_UNSPECIFIED`` — too broad to enable by default;
|
||||
opt in via ``finish_reasons=`` if
|
||||
your provider abuses these.
|
||||
"""
|
||||
|
||||
name = "gemini_safety"
|
||||
|
||||
_DEFAULT_FINISH_REASONS = (
|
||||
# Text safety
|
||||
"SAFETY",
|
||||
"BLOCKLIST",
|
||||
"PROHIBITED_CONTENT",
|
||||
"SPII",
|
||||
"RECITATION",
|
||||
# Image safety (multimodal generation)
|
||||
"IMAGE_SAFETY",
|
||||
"IMAGE_PROHIBITED_CONTENT",
|
||||
"IMAGE_RECITATION",
|
||||
)
|
||||
|
||||
def __init__(self, finish_reasons: list[str] | tuple[str, ...] | None = None) -> None:
|
||||
configured = finish_reasons if finish_reasons is not None else self._DEFAULT_FINISH_REASONS
|
||||
self._finish_reasons: frozenset[str] = frozenset(r.upper() for r in configured)
|
||||
|
||||
def detect(self, message: AIMessage) -> SafetyTermination | None:
|
||||
value = _get_metadata_value(message, "finish_reason")
|
||||
if value is None or value.upper() not in self._finish_reasons:
|
||||
return None
|
||||
|
||||
extras: dict[str, Any] = {}
|
||||
response_metadata = getattr(message, "response_metadata", None) or {}
|
||||
if isinstance(response_metadata, dict):
|
||||
# Gemini surfaces per-category scoring under safety_ratings.
|
||||
ratings = response_metadata.get("safety_ratings")
|
||||
if ratings:
|
||||
extras["safety_ratings"] = ratings
|
||||
|
||||
return SafetyTermination(
|
||||
detector=self.name,
|
||||
reason_field="finish_reason",
|
||||
reason_value=value,
|
||||
extras=extras,
|
||||
)
|
||||
|
||||
|
||||
def default_detectors() -> list[SafetyTerminationDetector]:
|
||||
"""Built-in detector set used when no custom detectors are configured."""
|
||||
return [
|
||||
OpenAICompatibleContentFilterDetector(),
|
||||
AnthropicRefusalDetector(),
|
||||
GeminiSafetyDetector(),
|
||||
]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AnthropicRefusalDetector",
|
||||
"GeminiSafetyDetector",
|
||||
"OpenAICompatibleContentFilterDetector",
|
||||
"SafetyTermination",
|
||||
"SafetyTerminationDetector",
|
||||
"default_detectors",
|
||||
]
|
||||
@ -0,0 +1,289 @@
|
||||
"""Middleware for explicit slash skill activation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import html
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain.agents.middleware.types import ModelRequest, ModelResponse
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from deerflow.skills.slash import parse_slash_skill_reference, resolve_slash_skill
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
from deerflow.skills.storage.skill_storage import SkillStorage
|
||||
from deerflow.skills.types import SKILL_MD_FILE
|
||||
from deerflow.utils.messages import get_original_user_content_text
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from deerflow.config.app_config import AppConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SLASH_SKILL_ACTIVATION_KEY = "slash_skill_activation"
|
||||
_SLASH_SKILL_ACTIVATION_TARGET_ID_KEY = "slash_skill_activation_target_id"
|
||||
_SUMMARY_MESSAGE_NAME = "summary"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Activation:
|
||||
skill_name: str
|
||||
category: str
|
||||
container_file_path: str
|
||||
skill_content: str
|
||||
content_hash: str
|
||||
remaining_text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ActivationResolution:
|
||||
activation: _Activation | None = None
|
||||
failure_message: str | None = None
|
||||
|
||||
|
||||
def is_slash_skill_activation_reminder(message: object) -> bool:
|
||||
"""Return whether a message is hidden slash-skill activation context."""
|
||||
return isinstance(message, HumanMessage) and bool(message.additional_kwargs.get(_SLASH_SKILL_ACTIVATION_KEY))
|
||||
|
||||
|
||||
def _is_user_activation_target(message: object) -> bool:
|
||||
if not isinstance(message, HumanMessage):
|
||||
return False
|
||||
if message.name == _SUMMARY_MESSAGE_NAME:
|
||||
return False
|
||||
if message.additional_kwargs.get("hide_from_ui"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class SkillActivationMiddleware(AgentMiddleware):
|
||||
"""Inject full SKILL.md content when the user explicitly types /skill-name."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
available_skills: set[str] | None = None,
|
||||
app_config: AppConfig | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._available_skills = set(available_skills) if available_skills is not None else None
|
||||
self._app_config = app_config
|
||||
|
||||
def _storage(self) -> SkillStorage:
|
||||
if self._app_config is not None:
|
||||
return get_or_new_skill_storage(app_config=self._app_config)
|
||||
return get_or_new_skill_storage()
|
||||
|
||||
@staticmethod
|
||||
def _read_skill_content(skill_file: Path, skills_root: Path) -> str:
|
||||
if skill_file.name != SKILL_MD_FILE:
|
||||
raise ValueError(f"Expected {SKILL_MD_FILE}, got {skill_file.name}")
|
||||
resolved_root = skills_root.resolve()
|
||||
resolved_file = skill_file.resolve()
|
||||
try:
|
||||
resolved_file.relative_to(resolved_root)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Resolved skill file must stay within the configured skills root.") from exc
|
||||
if not resolved_file.is_file():
|
||||
raise FileNotFoundError(resolved_file)
|
||||
return resolved_file.read_text(encoding="utf-8")
|
||||
|
||||
def _resolve_activation(self, text: str) -> _ActivationResolution | None:
|
||||
reference = parse_slash_skill_reference(text)
|
||||
if reference is None:
|
||||
return None
|
||||
|
||||
storage = self._storage()
|
||||
skills = storage.load_skills(enabled_only=False)
|
||||
skill = next((candidate for candidate in skills if candidate.name == reference.name), None)
|
||||
if skill is None:
|
||||
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` is not installed.")
|
||||
if not skill.enabled:
|
||||
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` is installed but disabled. Enable it before using slash activation.")
|
||||
if self._available_skills is not None and reference.name not in self._available_skills:
|
||||
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` is not available for this agent.")
|
||||
|
||||
resolved = resolve_slash_skill(
|
||||
text,
|
||||
skills,
|
||||
available_skills=self._available_skills,
|
||||
container_base_path=storage.get_container_root(),
|
||||
)
|
||||
if resolved is None:
|
||||
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` could not be resolved.")
|
||||
|
||||
try:
|
||||
skill_content = self._read_skill_content(resolved.skill.skill_file, storage.get_skills_root_path())
|
||||
except (OSError, ValueError):
|
||||
logger.exception("Failed to read slash-activated skill %s", resolved.skill.name)
|
||||
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` could not be loaded safely. Please check the skill installation.")
|
||||
|
||||
content_hash = hashlib.sha256(skill_content.encode("utf-8")).hexdigest()
|
||||
return _ActivationResolution(
|
||||
activation=_Activation(
|
||||
skill_name=resolved.skill.name,
|
||||
category=str(resolved.skill.category),
|
||||
container_file_path=resolved.container_file_path,
|
||||
skill_content=skill_content,
|
||||
content_hash=content_hash,
|
||||
remaining_text=resolved.remaining_text,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_activation_reminder(activation: _Activation) -> str:
|
||||
user_request = activation.remaining_text or ("No additional task text was provided after the slash skill command. Ask the user what they want to do with this skill if the next step is unclear.")
|
||||
escaped_user_request = html.escape(user_request, quote=False)
|
||||
escaped_skill_content = html.escape(activation.skill_content, quote=False)
|
||||
escaped_skill_name = html.escape(activation.skill_name, quote=True)
|
||||
escaped_category = html.escape(activation.category, quote=True)
|
||||
escaped_path = html.escape(activation.container_file_path, quote=True)
|
||||
escaped_content_hash = html.escape(activation.content_hash, quote=True)
|
||||
return f"""<slash_skill_activation>
|
||||
The user explicitly activated the `{activation.skill_name}` skill for this turn.
|
||||
Treat the task text as:
|
||||
<user_request>
|
||||
{escaped_user_request}
|
||||
</user_request>
|
||||
|
||||
Follow this skill before choosing a general workflow. Load supporting resources from the same skill directory only when needed.
|
||||
|
||||
<skill name="{escaped_skill_name}" category="{escaped_category}" path="{escaped_path}" sha256="{escaped_content_hash}">
|
||||
<skill_content encoding="xml-escaped">
|
||||
{escaped_skill_content}
|
||||
</skill_content>
|
||||
</skill>
|
||||
</slash_skill_activation>"""
|
||||
|
||||
@staticmethod
|
||||
def _has_existing_activation_for_target(messages: list, target_index: int, target: HumanMessage) -> bool:
|
||||
if target_index <= 0:
|
||||
return False
|
||||
|
||||
if target.id:
|
||||
for previous in messages[:target_index]:
|
||||
if not is_slash_skill_activation_reminder(previous):
|
||||
continue
|
||||
target_id = previous.additional_kwargs.get(_SLASH_SKILL_ACTIVATION_TARGET_ID_KEY)
|
||||
if target_id == target.id or previous.id == f"{target.id}__slash_activation":
|
||||
return True
|
||||
|
||||
previous = messages[target_index - 1]
|
||||
return is_slash_skill_activation_reminder(previous)
|
||||
|
||||
def _find_activation_target(self, messages: list) -> tuple[int, HumanMessage, _ActivationResolution] | None:
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
target_index = next((idx for idx in range(len(messages) - 1, -1, -1) if _is_user_activation_target(messages[idx])), None)
|
||||
if target_index is None:
|
||||
return None
|
||||
|
||||
target = messages[target_index]
|
||||
if target is None:
|
||||
return None
|
||||
if self._has_existing_activation_for_target(messages, target_index, target):
|
||||
return None
|
||||
|
||||
content = get_original_user_content_text(target.content, target.additional_kwargs)
|
||||
resolution = self._resolve_activation(content)
|
||||
if resolution is None:
|
||||
return None
|
||||
return target_index, target, resolution
|
||||
|
||||
@staticmethod
|
||||
def _record_activation(request: ModelRequest, activation: _Activation, *, hook: str) -> None:
|
||||
runtime = getattr(request, "runtime", None)
|
||||
context = getattr(runtime, "context", None)
|
||||
journal = context.get("__run_journal") if isinstance(context, dict) else None
|
||||
if journal is None:
|
||||
return
|
||||
try:
|
||||
journal.record_middleware(
|
||||
"skill_activation",
|
||||
name="SkillActivationMiddleware",
|
||||
hook=hook,
|
||||
action="activate",
|
||||
changes={
|
||||
"skill_name": activation.skill_name,
|
||||
"category": activation.category,
|
||||
"path": activation.container_file_path,
|
||||
"content_hash": activation.content_hash,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to record slash skill activation audit event", exc_info=True)
|
||||
|
||||
def _prepare_model_request(self, request: ModelRequest, *, hook: str) -> ModelRequest | AIMessage | None:
|
||||
target_and_resolution = self._find_activation_target(list(request.messages))
|
||||
if target_and_resolution is None:
|
||||
return None
|
||||
|
||||
target_index, target, resolution = target_and_resolution
|
||||
if resolution.failure_message:
|
||||
return AIMessage(content=resolution.failure_message)
|
||||
|
||||
activation = resolution.activation
|
||||
if activation is None:
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"SkillActivationMiddleware: activating slash skill %s category=%s path=%s hash=%s",
|
||||
activation.skill_name,
|
||||
activation.category,
|
||||
activation.container_file_path,
|
||||
activation.content_hash,
|
||||
)
|
||||
self._record_activation(request, activation, hook=hook)
|
||||
activation_msg = self._make_activation_message(target, self._build_activation_reminder(activation))
|
||||
messages = list(request.messages)
|
||||
messages.insert(target_index, activation_msg)
|
||||
return request.override(messages=messages)
|
||||
|
||||
@staticmethod
|
||||
def _make_activation_message(target: HumanMessage, activation_content: str) -> HumanMessage:
|
||||
stable_id = target.id or str(uuid.uuid4())
|
||||
additional_kwargs = {
|
||||
"hide_from_ui": True,
|
||||
_SLASH_SKILL_ACTIVATION_KEY: True,
|
||||
}
|
||||
if target.id:
|
||||
additional_kwargs[_SLASH_SKILL_ACTIVATION_TARGET_ID_KEY] = target.id
|
||||
return HumanMessage(
|
||||
content=activation_content,
|
||||
id=f"{stable_id}__slash_activation",
|
||||
additional_kwargs=additional_kwargs,
|
||||
)
|
||||
|
||||
@override
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelResponse | AIMessage:
|
||||
prepared = self._prepare_model_request(request, hook="wrap_model_call")
|
||||
if prepared is None:
|
||||
return handler(request)
|
||||
if isinstance(prepared, AIMessage):
|
||||
return prepared
|
||||
return handler(prepared)
|
||||
|
||||
@override
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelResponse | AIMessage:
|
||||
prepared = await asyncio.to_thread(self._prepare_model_request, request, hook="awrap_model_call")
|
||||
if prepared is None:
|
||||
return await handler(request)
|
||||
if isinstance(prepared, AIMessage):
|
||||
return prepared
|
||||
return await handler(prepared)
|
||||
@ -9,8 +9,9 @@ from typing import Any, Protocol, override, runtime_checkable
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import SummarizationMiddleware
|
||||
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, RemoveMessage, ToolMessage
|
||||
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, RemoveMessage, ToolMessage, get_buffer_string
|
||||
from langgraph.config import get_config
|
||||
from langgraph.constants import TAG_NOSTREAM
|
||||
from langgraph.graph.message import REMOVE_ALL_MESSAGES
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
@ -116,6 +117,74 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
||||
self._preserve_recent_skill_count = max(0, preserve_recent_skill_count)
|
||||
self._preserve_recent_skill_tokens = max(0, preserve_recent_skill_tokens)
|
||||
self._preserve_recent_skill_tokens_per_skill = max(0, preserve_recent_skill_tokens_per_skill)
|
||||
# The summary LLM call runs inside a LangGraph middleware hook, so its token
|
||||
# stream would otherwise be captured by the messages-tuple stream callback and
|
||||
# broadcast to the frontend as a phantom AI message. Tag a dedicated model copy
|
||||
# with TAG_NOSTREAM so the streaming handler skips it.
|
||||
# Keep self.model untagged so the parent's profile / ls_params inspection still works.
|
||||
#
|
||||
# Preserve any tags already bound on the model (e.g. "middleware:summarize" set in
|
||||
# lead_agent/agent.py for RunJournal attribution): RunnableBinding.with_config does a
|
||||
# shallow merge that would otherwise overwrite the existing tags list entirely.
|
||||
existing_tags = list((getattr(self.model, "config", None) or {}).get("tags") or [])
|
||||
merged_tags = [*existing_tags, TAG_NOSTREAM] if TAG_NOSTREAM not in existing_tags else existing_tags
|
||||
self._summary_model = self.model.with_config(tags=merged_tags)
|
||||
|
||||
@override
|
||||
def _create_summary(self, messages_to_summarize: list[AnyMessage]) -> str:
|
||||
return self._summarize_with(messages_to_summarize)
|
||||
|
||||
@override
|
||||
async def _acreate_summary(self, messages_to_summarize: list[AnyMessage]) -> str:
|
||||
return await self._asummarize_with(messages_to_summarize)
|
||||
|
||||
def _summarize_with(self, messages_to_summarize: list[AnyMessage]) -> str:
|
||||
"""Mirror the parent ``_create_summary`` but invoke the nostream-tagged model.
|
||||
|
||||
We do not swap ``self.model`` at the instance level: the agent/middleware is
|
||||
cached and reused across concurrent runs, so a temporary swap would leak the
|
||||
``RunnableBinding`` to other coroutines during ``await`` and break parent logic
|
||||
that inspects the raw model (``profile`` / ``_get_ls_params``).
|
||||
"""
|
||||
if not messages_to_summarize:
|
||||
return "No previous conversation history."
|
||||
prompt = self._build_summary_prompt(messages_to_summarize)
|
||||
if prompt is None:
|
||||
return "Previous conversation was too long to summarize."
|
||||
try:
|
||||
response = self._summary_model.invoke(
|
||||
prompt,
|
||||
config={"metadata": {"lc_source": "summarization"}},
|
||||
)
|
||||
return response.text.strip()
|
||||
except Exception as e:
|
||||
return f"Error generating summary: {e!s}"
|
||||
|
||||
async def _asummarize_with(self, messages_to_summarize: list[AnyMessage]) -> str:
|
||||
"""Async counterpart of :meth:`_summarize_with` using the nostream model."""
|
||||
if not messages_to_summarize:
|
||||
return "No previous conversation history."
|
||||
prompt = self._build_summary_prompt(messages_to_summarize)
|
||||
if prompt is None:
|
||||
return "Previous conversation was too long to summarize."
|
||||
try:
|
||||
response = await self._summary_model.ainvoke(
|
||||
prompt,
|
||||
config={"metadata": {"lc_source": "summarization"}},
|
||||
)
|
||||
return response.text.strip()
|
||||
except Exception as e:
|
||||
return f"Error generating summary: {e!s}"
|
||||
|
||||
def _build_summary_prompt(self, messages_to_summarize: list[AnyMessage]) -> str | None:
|
||||
"""Build the summary prompt, returning ``None`` when trimming leaves nothing."""
|
||||
trimmed_messages = self._trim_messages_for_summary(messages_to_summarize)
|
||||
if not trimmed_messages:
|
||||
return None
|
||||
# Format messages to avoid token inflation from metadata when str() is called on
|
||||
# message objects.
|
||||
formatted_messages = get_buffer_string(trimmed_messages)
|
||||
return self.summary_prompt.format(messages=formatted_messages).rstrip()
|
||||
|
||||
def before_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
return self._maybe_summarize(state, runtime)
|
||||
|
||||
@ -160,7 +160,11 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
||||
prompt, user_msg = self._build_title_prompt(state)
|
||||
|
||||
try:
|
||||
model_kwargs = {"thinking_enabled": False}
|
||||
# attach_tracing=False because ``_get_runnable_config()`` inherits
|
||||
# the graph-level RunnableConfig (set in ``_make_lead_agent``) whose
|
||||
# callbacks already carry tracing handlers; binding them again at
|
||||
# the model level would emit duplicate spans.
|
||||
model_kwargs = {"thinking_enabled": False, "attach_tracing": False}
|
||||
if self._app_config is not None:
|
||||
model_kwargs["app_config"] = self._app_config
|
||||
if config.model_name:
|
||||
|
||||
@ -20,11 +20,13 @@ from collections.abc import Awaitable, Callable
|
||||
from typing import Any, override
|
||||
|
||||
from langchain.agents.middleware import TodoListMiddleware
|
||||
from langchain.agents.middleware.todo import PlanningState, Todo
|
||||
from langchain.agents.middleware.todo import Todo
|
||||
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse, hook_config
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from deerflow.agents.thread_state import ThreadState
|
||||
|
||||
|
||||
def _todos_in_messages(messages: list[Any]) -> bool:
|
||||
"""Return True if any AIMessage in *messages* contains a write_todos tool call."""
|
||||
@ -113,10 +115,12 @@ class TodoMiddleware(TodoListMiddleware):
|
||||
and injects a reminder message so the model can continue tracking progress.
|
||||
"""
|
||||
|
||||
state_schema = ThreadState
|
||||
|
||||
@override
|
||||
def before_model(
|
||||
self,
|
||||
state: PlanningState,
|
||||
state: ThreadState,
|
||||
runtime: Runtime,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Inject a todo-list reminder when write_todos has left the context window."""
|
||||
@ -154,7 +158,7 @@ class TodoMiddleware(TodoListMiddleware):
|
||||
@override
|
||||
async def abefore_model(
|
||||
self,
|
||||
state: PlanningState,
|
||||
state: ThreadState,
|
||||
runtime: Runtime,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Async version of before_model."""
|
||||
@ -249,12 +253,12 @@ class TodoMiddleware(TodoListMiddleware):
|
||||
self._drop_completion_reminder_key_locked(key)
|
||||
|
||||
@override
|
||||
def before_agent(self, state: PlanningState, runtime: Runtime) -> dict[str, Any] | None:
|
||||
def before_agent(self, state: ThreadState, runtime: Runtime) -> dict[str, Any] | None:
|
||||
self._clear_other_run_completion_reminders(runtime)
|
||||
return None
|
||||
|
||||
@override
|
||||
async def abefore_agent(self, state: PlanningState, runtime: Runtime) -> dict[str, Any] | None:
|
||||
async def abefore_agent(self, state: ThreadState, runtime: Runtime) -> dict[str, Any] | None:
|
||||
self._clear_other_run_completion_reminders(runtime)
|
||||
return None
|
||||
|
||||
@ -262,7 +266,7 @@ class TodoMiddleware(TodoListMiddleware):
|
||||
@override
|
||||
def after_model(
|
||||
self,
|
||||
state: PlanningState,
|
||||
state: ThreadState,
|
||||
runtime: Runtime,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Prevent premature agent exit when todo items are still incomplete.
|
||||
@ -308,7 +312,7 @@ class TodoMiddleware(TodoListMiddleware):
|
||||
@hook_config(can_jump_to=["model"])
|
||||
async def aafter_model(
|
||||
self,
|
||||
state: PlanningState,
|
||||
state: ThreadState,
|
||||
runtime: Runtime,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Async version of after_model."""
|
||||
@ -349,11 +353,11 @@ class TodoMiddleware(TodoListMiddleware):
|
||||
return await handler(self._augment_request(request))
|
||||
|
||||
@override
|
||||
def after_agent(self, state: PlanningState, runtime: Runtime) -> dict[str, Any] | None:
|
||||
def after_agent(self, state: ThreadState, runtime: Runtime) -> dict[str, Any] | None:
|
||||
self._clear_current_run_completion_reminders(runtime)
|
||||
return None
|
||||
|
||||
@override
|
||||
async def aafter_agent(self, state: PlanningState, runtime: Runtime) -> dict[str, Any] | None:
|
||||
async def aafter_agent(self, state: ThreadState, runtime: Runtime) -> dict[str, Any] | None:
|
||||
self._clear_current_run_completion_reminders(runtime)
|
||||
return None
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import override
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
@ -12,10 +12,48 @@ from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||
from langgraph.types import Command
|
||||
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.subagents.status_contract import (
|
||||
extract_subagent_status,
|
||||
make_subagent_additional_kwargs,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from deerflow.tools.builtins.tool_search import DeferredToolSetup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MISSING_TOOL_CALL_ID = "missing_tool_call_id"
|
||||
_TASK_TOOL_NAME = "task"
|
||||
|
||||
|
||||
def _stamp_task_subagent_status(message: ToolMessage, *, tool_name: str, error: str | None = None) -> ToolMessage:
|
||||
"""Centralised stamping of ``additional_kwargs.subagent_status``.
|
||||
|
||||
Bytedance/deer-flow issue #3146: the frontend now reads the subagent
|
||||
status from a structured field instead of parsing the leading text of
|
||||
the task tool's return string. That contract is enforced here, in the
|
||||
one place every task tool result flows through, rather than at the 5
|
||||
normal-return + 3 ``Error:`` pre-execution branches inside
|
||||
``task_tool.py``. Centralisation prevents the "added a new return
|
||||
path, forgot the stamp" drift mode.
|
||||
|
||||
For non-``task`` tools this is a no-op so other tools' additional_kwargs
|
||||
conventions are untouched.
|
||||
"""
|
||||
if tool_name != _TASK_TOOL_NAME:
|
||||
return message
|
||||
content = message.content if isinstance(message.content, str) else ""
|
||||
status = extract_subagent_status(content)
|
||||
if status is None:
|
||||
# Non-terminal streaming chunks or unrecognised shapes leave the
|
||||
# field unset so the frontend can keep the card on its in-progress
|
||||
# placeholder until a real terminal frame arrives.
|
||||
return message
|
||||
stamp = make_subagent_additional_kwargs(status, error=error)
|
||||
existing = dict(message.additional_kwargs or {})
|
||||
existing.update(stamp)
|
||||
message.additional_kwargs = existing
|
||||
return message
|
||||
|
||||
|
||||
class ToolErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
@ -29,12 +67,31 @@ class ToolErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
detail = detail[:497] + "..."
|
||||
|
||||
content = f"Error: Tool '{tool_name}' failed with {exc.__class__.__name__}: {detail}. Continue with available context, or choose an alternative tool."
|
||||
return ToolMessage(
|
||||
message = ToolMessage(
|
||||
content=content,
|
||||
tool_call_id=tool_call_id,
|
||||
name=tool_name,
|
||||
status="error",
|
||||
)
|
||||
# Stamp the structured subagent status on the wrapper too: the
|
||||
# frontend would otherwise have to fall back to prefix-matching
|
||||
# ``Error: Tool 'task' failed ...`` on the wire. The ``subagent_error``
|
||||
# carries the same ``ExcClass: detail`` shape the wrapper string
|
||||
# uses so debugging artifacts stay aligned.
|
||||
structured_error = f"{exc.__class__.__name__}: {detail}"
|
||||
return _stamp_task_subagent_status(message, tool_name=tool_name, error=structured_error)
|
||||
|
||||
@staticmethod
|
||||
def _maybe_stamp(result: ToolMessage | Command, request: ToolCallRequest) -> ToolMessage | Command:
|
||||
"""Apply the subagent stamp to successful task tool returns.
|
||||
|
||||
``Command`` results bypass the stamp — they encode LangGraph
|
||||
control flow rather than user-facing tool output.
|
||||
"""
|
||||
if not isinstance(result, ToolMessage):
|
||||
return result
|
||||
tool_name = str(request.tool_call.get("name") or "")
|
||||
return _stamp_task_subagent_status(result, tool_name=tool_name)
|
||||
|
||||
@override
|
||||
def wrap_tool_call(
|
||||
@ -43,13 +100,14 @@ class ToolErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
try:
|
||||
return handler(request)
|
||||
result = handler(request)
|
||||
except GraphBubbleUp:
|
||||
# Preserve LangGraph control-flow signals (interrupt/pause/resume).
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("Tool execution failed (sync): name=%s id=%s", request.tool_call.get("name"), request.tool_call.get("id"))
|
||||
return self._build_error_message(request, exc)
|
||||
return self._maybe_stamp(result, request)
|
||||
|
||||
@override
|
||||
async def awrap_tool_call(
|
||||
@ -58,13 +116,14 @@ class ToolErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
|
||||
) -> ToolMessage | Command:
|
||||
try:
|
||||
return await handler(request)
|
||||
result = await handler(request)
|
||||
except GraphBubbleUp:
|
||||
# Preserve LangGraph control-flow signals (interrupt/pause/resume).
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("Tool execution failed (async): name=%s id=%s", request.tool_call.get("name"), request.tool_call.get("id"))
|
||||
return self._build_error_message(request, exc)
|
||||
return self._maybe_stamp(result, request)
|
||||
|
||||
|
||||
def _build_runtime_middlewares(
|
||||
@ -77,9 +136,11 @@ def _build_runtime_middlewares(
|
||||
"""Build shared base middlewares for agent execution."""
|
||||
from deerflow.agents.middlewares.llm_error_handling_middleware import LLMErrorHandlingMiddleware
|
||||
from deerflow.agents.middlewares.thread_data_middleware import ThreadDataMiddleware
|
||||
from deerflow.agents.middlewares.tool_output_budget_middleware import ToolOutputBudgetMiddleware
|
||||
from deerflow.sandbox.middleware import SandboxMiddleware
|
||||
|
||||
middlewares: list[AgentMiddleware] = [
|
||||
ToolOutputBudgetMiddleware.from_app_config(app_config),
|
||||
ThreadDataMiddleware(lazy_init=lazy_init),
|
||||
SandboxMiddleware(lazy_init=lazy_init),
|
||||
]
|
||||
@ -87,7 +148,7 @@ def _build_runtime_middlewares(
|
||||
if include_uploads:
|
||||
from deerflow.agents.middlewares.uploads_middleware import UploadsMiddleware
|
||||
|
||||
middlewares.insert(1, UploadsMiddleware())
|
||||
middlewares.insert(2, UploadsMiddleware())
|
||||
|
||||
if include_dangling_tool_call_patch:
|
||||
from deerflow.agents.middlewares.dangling_tool_call_middleware import DanglingToolCallMiddleware
|
||||
@ -141,6 +202,7 @@ def build_subagent_runtime_middlewares(
|
||||
app_config: AppConfig | None = None,
|
||||
model_name: str | None = None,
|
||||
lazy_init: bool = True,
|
||||
deferred_setup: "DeferredToolSetup | None" = None,
|
||||
) -> list[AgentMiddleware]:
|
||||
"""Middlewares shared by subagent runtime before subagent-only middlewares."""
|
||||
if app_config is None:
|
||||
@ -164,4 +226,24 @@ def build_subagent_runtime_middlewares(
|
||||
|
||||
middlewares.append(ViewImageMiddleware())
|
||||
|
||||
# Hide deferred (MCP) tool schemas from the subagent's model binding until
|
||||
# tool_search promotes them. This is the same wiring the lead agent gets. The deferred
|
||||
# set + catalog hash come from the build-time setup (assembled after
|
||||
# tool-policy filtering); promotion is read from graph state. Empty/None
|
||||
# setup (deferral disabled or no MCP tool survived) is a pure no-op.
|
||||
if deferred_setup is not None and deferred_setup.deferred_names:
|
||||
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
|
||||
|
||||
middlewares.append(DeferredToolFilterMiddleware(deferred_setup.deferred_names, deferred_setup.catalog_hash))
|
||||
|
||||
# Same provider safety-termination guard the lead agent uses — subagents
|
||||
# are equally exposed to truncated tool_calls returned with
|
||||
# finish_reason=content_filter (and friends), and the bad call would then
|
||||
# propagate back to the lead agent via the task tool result.
|
||||
safety_config = app_config.safety_finish_reason
|
||||
if safety_config.enabled:
|
||||
from deerflow.agents.middlewares.safety_finish_reason_middleware import SafetyFinishReasonMiddleware
|
||||
|
||||
middlewares.append(SafetyFinishReasonMiddleware.from_config(safety_config))
|
||||
|
||||
return middlewares
|
||||
|
||||
@ -0,0 +1,643 @@
|
||||
"""Middleware that enforces a per-result budget on tool outputs.
|
||||
|
||||
Oversized tool results are persisted to disk and replaced with a compact
|
||||
preview containing a file reference. When disk persistence is
|
||||
unavailable the middleware falls back to head+tail truncation so the
|
||||
model context is never blown by a single large tool return.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import replace as dc_replace
|
||||
from typing import TYPE_CHECKING, Any, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||
from langgraph.types import Command
|
||||
|
||||
from deerflow.config.tool_output_config import ToolOutputConfig
|
||||
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from deerflow.sandbox.sandbox import Sandbox
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Virtual outputs root inside the sandbox. Host-mounted sandboxes map this to
|
||||
# the thread outputs dir on the host; for non-mounted (remote) sandboxes the
|
||||
# same path is written directly into the sandbox filesystem so the model's
|
||||
# ``read_file`` tool can read it back (issue #3416).
|
||||
_VIRTUAL_OUTPUTS_BASE = "/mnt/user-data/outputs"
|
||||
|
||||
|
||||
def _default_config() -> ToolOutputConfig:
|
||||
return ToolOutputConfig()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _message_text(content: Any) -> str | None:
|
||||
"""Extract a plain-text representation from a ToolMessage content field.
|
||||
|
||||
Returns ``None`` for non-string / multimodal content so the caller
|
||||
can skip budget enforcement (images, structured blocks, etc.).
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if content is None:
|
||||
return None
|
||||
if isinstance(content, list):
|
||||
pieces: list[str] = []
|
||||
for part in content:
|
||||
if isinstance(part, str):
|
||||
pieces.append(part)
|
||||
elif isinstance(part, dict) and isinstance(part.get("text"), str):
|
||||
pieces.append(part["text"])
|
||||
else:
|
||||
return None
|
||||
return "\n".join(pieces) if pieces else None
|
||||
return None
|
||||
|
||||
|
||||
def _snap_to_line_boundary(text: str, pos: int) -> int:
|
||||
"""Return *pos* or the nearest preceding newline+1, whichever is closer.
|
||||
|
||||
Used so that previews and truncations end on a complete line when
|
||||
possible. If no newline exists in the second half of ``text[:pos]``
|
||||
the original *pos* is returned unchanged.
|
||||
"""
|
||||
if pos <= 0 or pos >= len(text):
|
||||
return pos
|
||||
half = pos // 2
|
||||
nl = text.rfind("\n", half, pos)
|
||||
if nl >= 0:
|
||||
return nl + 1
|
||||
return pos
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Disk persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_EXT_MAP: dict[str, str] = {
|
||||
"bash": "log",
|
||||
"bash_tool": "log",
|
||||
"web_fetch": "log",
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_tool_name(name: str) -> str:
|
||||
"""Strip path separators and traversal components from a tool name."""
|
||||
base = os.path.basename(name)
|
||||
safe = base.replace("..", "").replace("/", "_").replace("\\", "_")
|
||||
return safe or "unknown"
|
||||
|
||||
|
||||
def _build_externalized_filename(*, tool_name: str, tool_call_id: str) -> str:
|
||||
"""Build the on-disk filename for an externalized tool output.
|
||||
|
||||
Shared by the host-disk and sandbox externalization paths so both
|
||||
produce the identical naming scheme.
|
||||
"""
|
||||
safe_name = _sanitize_tool_name(tool_name)
|
||||
ext = _EXT_MAP.get(tool_name, "txt")
|
||||
short_id = uuid.uuid4().hex[:12]
|
||||
return f"{safe_name}-{short_id}.{ext}"
|
||||
|
||||
|
||||
def _externalize(
|
||||
content: str,
|
||||
*,
|
||||
tool_name: str,
|
||||
tool_call_id: str,
|
||||
outputs_path: str,
|
||||
storage_subdir: str,
|
||||
) -> str | None:
|
||||
"""Write *content* to disk and return the virtual path, or ``None`` on failure."""
|
||||
if os.path.isabs(storage_subdir) or ".." in storage_subdir:
|
||||
return None
|
||||
storage_dir = os.path.join(outputs_path, storage_subdir)
|
||||
try:
|
||||
os.makedirs(storage_dir, exist_ok=True)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
filename = _build_externalized_filename(tool_name=tool_name, tool_call_id=tool_call_id)
|
||||
filepath = os.path.join(storage_dir, filename)
|
||||
|
||||
if not os.path.abspath(filepath).startswith(os.path.abspath(storage_dir)):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
return f"{_VIRTUAL_OUTPUTS_BASE}/{storage_subdir}/{filename}"
|
||||
|
||||
|
||||
def _externalize_to_sandbox(
|
||||
content: str,
|
||||
*,
|
||||
tool_name: str,
|
||||
tool_call_id: str,
|
||||
storage_subdir: str,
|
||||
sandbox: Sandbox,
|
||||
) -> str | None:
|
||||
"""Write *content* into the sandbox filesystem and return the virtual path.
|
||||
|
||||
Used when the sandbox does not use thread-data mounts (e.g. a remote AIO
|
||||
sandbox): the host-side :func:`_externalize` virtual path would not exist
|
||||
inside the sandbox, so the model's ``read_file`` tool could not read it
|
||||
back (issue #3416). Returns the same virtual-path contract on success, or
|
||||
``None`` to signal the caller to fall back to inline truncation.
|
||||
"""
|
||||
if os.path.isabs(storage_subdir) or ".." in storage_subdir:
|
||||
return None
|
||||
filename = _build_externalized_filename(tool_name=tool_name, tool_call_id=tool_call_id)
|
||||
virtual_dir = f"{_VIRTUAL_OUTPUTS_BASE}/{storage_subdir}"
|
||||
virtual_path = f"{virtual_dir}/{filename}"
|
||||
try:
|
||||
# AIO sandbox write_file does NOT create parent directories, so create
|
||||
# them explicitly before writing. execute_command returns its stdout
|
||||
# verbatim (including an "Error: ..." string on failure) rather than
|
||||
# raising, so we cannot rely on exception propagation here.
|
||||
sandbox.execute_command(f"mkdir -p {shlex.quote(virtual_dir)}")
|
||||
sandbox.write_file(virtual_path, content)
|
||||
# Validate the file landed: execute_command may have silently failed
|
||||
# to create the directory, and write_file backends differ. Refuse to
|
||||
# hand the model an unreadable read_file path.
|
||||
check = sandbox.execute_command(f"test -s {shlex.quote(virtual_path)} && echo OK || echo MISSING")
|
||||
if not isinstance(check, str) or check.strip() != "OK":
|
||||
logger.warning(
|
||||
"Sandbox externalize validation failed: path=%s, check=%r",
|
||||
virtual_path,
|
||||
check,
|
||||
)
|
||||
return None
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to externalize %s output to sandbox (call_id=%s)",
|
||||
tool_name,
|
||||
tool_call_id,
|
||||
)
|
||||
return None
|
||||
return virtual_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preview / fallback builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_preview(
|
||||
content: str,
|
||||
*,
|
||||
tool_name: str,
|
||||
virtual_path: str,
|
||||
head_chars: int,
|
||||
tail_chars: int,
|
||||
) -> str:
|
||||
"""Build a preview with a file reference for externalized output."""
|
||||
total = len(content)
|
||||
head_end = _snap_to_line_boundary(content, min(head_chars, total))
|
||||
tail_start = max(head_end, total - tail_chars)
|
||||
tail_start_snapped = _snap_to_line_boundary(content, tail_start)
|
||||
if tail_start_snapped > head_end:
|
||||
tail_start = tail_start_snapped
|
||||
|
||||
head = content[:head_end]
|
||||
tail = content[tail_start:] if tail_start < total else ""
|
||||
|
||||
omitted = total - len(head) - len(tail)
|
||||
ref = f"\n\n[Full {tool_name} output saved to {virtual_path} ({total} chars, ~{total // 4} tokens). Use read_file with start_line and end_line to access specific sections. {omitted} chars omitted from this preview.]\n\n"
|
||||
|
||||
parts = [head, ref]
|
||||
if tail:
|
||||
parts.append(tail)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _build_fallback(
|
||||
content: str,
|
||||
*,
|
||||
tool_name: str,
|
||||
max_chars: int,
|
||||
head_chars: int,
|
||||
tail_chars: int,
|
||||
) -> str:
|
||||
"""Build a head+tail truncation when disk persistence is unavailable.
|
||||
|
||||
The returned string is guaranteed to be no longer than *max_chars*.
|
||||
"""
|
||||
total = len(content)
|
||||
if max_chars <= 0 or total <= max_chars:
|
||||
return content
|
||||
|
||||
marker_template = "\n\n[... {n} chars omitted from {tn} output. Persistent storage unavailable. Consider narrowing the query or using more specific parameters.]\n\n"
|
||||
marker_overhead = len(marker_template.format(n=total, tn=tool_name))
|
||||
|
||||
if marker_overhead >= max_chars:
|
||||
return content[:max_chars]
|
||||
|
||||
budget = max_chars - marker_overhead
|
||||
effective_head = min(head_chars, budget)
|
||||
effective_tail = min(tail_chars, max(0, budget - effective_head))
|
||||
|
||||
head_end = _snap_to_line_boundary(content, min(effective_head, total))
|
||||
tail_start = max(head_end, total - effective_tail)
|
||||
tail_start_snapped = _snap_to_line_boundary(content, tail_start)
|
||||
if tail_start_snapped > head_end:
|
||||
tail_start = tail_start_snapped
|
||||
|
||||
head = content[:head_end]
|
||||
tail = content[tail_start:] if tail_start < total else ""
|
||||
omitted = total - len(head) - len(tail)
|
||||
|
||||
marker = marker_template.format(n=omitted, tn=tool_name)
|
||||
|
||||
parts = [head, marker]
|
||||
if tail:
|
||||
parts.append(tail)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core budget logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_outputs_path(request: ToolCallRequest) -> str | None:
|
||||
"""Best-effort extraction of the thread outputs path."""
|
||||
runtime = getattr(request, "runtime", None)
|
||||
if runtime is None:
|
||||
return None
|
||||
state = getattr(runtime, "state", None)
|
||||
if state is None:
|
||||
return None
|
||||
thread_data = state.get("thread_data")
|
||||
if not isinstance(thread_data, dict):
|
||||
return None
|
||||
outputs_path = thread_data.get("outputs_path")
|
||||
return outputs_path if isinstance(outputs_path, str) else None
|
||||
|
||||
|
||||
def _resolve_sandbox(request: ToolCallRequest) -> Sandbox | None:
|
||||
"""Resolve the active sandbox for the current tool call, or ``None``.
|
||||
|
||||
Reads the sandbox_id that ``SandboxMiddleware`` (and the sandbox tools
|
||||
themselves) write into ``runtime.state["sandbox"]``. We intentionally do
|
||||
NOT call ``provider.acquire`` here: acquiring a sandbox can trigger
|
||||
blocking remote I/O, and this resolver runs on every tool call. Tools
|
||||
that do not use a sandbox (``web_search``, MCP, ...) will return ``None``
|
||||
here, which is fine -- the caller falls back to inline truncation.
|
||||
"""
|
||||
runtime = getattr(request, "runtime", None)
|
||||
state = getattr(runtime, "state", None)
|
||||
if not isinstance(state, dict):
|
||||
return None
|
||||
sandbox_state = state.get("sandbox")
|
||||
if not isinstance(sandbox_state, dict):
|
||||
return None
|
||||
sandbox_id = sandbox_state.get("sandbox_id")
|
||||
if not sandbox_id:
|
||||
return None
|
||||
try:
|
||||
return get_sandbox_provider().get(sandbox_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to look up sandbox %s for tool-output externalization", sandbox_id)
|
||||
return None
|
||||
|
||||
|
||||
def _budget_content(
|
||||
content: str,
|
||||
*,
|
||||
tool_name: str,
|
||||
tool_call_id: str,
|
||||
outputs_path: str | None,
|
||||
config: ToolOutputConfig,
|
||||
sandbox: Sandbox | None = None,
|
||||
) -> str | None:
|
||||
"""Apply budget to *content*. Returns ``None`` if no change needed."""
|
||||
threshold = config.tool_overrides.get(tool_name, config.externalize_min_chars)
|
||||
if threshold <= 0 and config.fallback_max_chars <= 0:
|
||||
return None
|
||||
if len(content) <= threshold and len(content) <= config.fallback_max_chars:
|
||||
return None
|
||||
|
||||
if threshold > 0 and len(content) > threshold:
|
||||
virtual_path: str | None = None
|
||||
# Decide persistence target based on what's available, without touching
|
||||
# the sandbox provider unless a sandbox was actually resolved for this
|
||||
# call. This keeps the legacy host-disk path provider-free, so callers
|
||||
# without a configured sandbox (and CI environments without a
|
||||
# config.yaml) continue to externalize to the host as before.
|
||||
if sandbox is not None:
|
||||
provider = None
|
||||
try:
|
||||
provider = get_sandbox_provider()
|
||||
except Exception:
|
||||
logger.exception("Failed to get sandbox provider for tool-output externalization; falling back to inline truncation")
|
||||
if provider is not None and getattr(provider, "uses_thread_data_mounts", False):
|
||||
# Host-mounted sandbox: host outputs path is bind-mounted into
|
||||
# the sandbox at the same virtual path, so writing host-side is
|
||||
# equivalent. Preserve the original behavior to avoid extra
|
||||
# sandbox round-trips.
|
||||
if outputs_path:
|
||||
virtual_path = _externalize(
|
||||
content,
|
||||
tool_name=tool_name,
|
||||
tool_call_id=tool_call_id,
|
||||
outputs_path=outputs_path,
|
||||
storage_subdir=config.storage_subdir,
|
||||
)
|
||||
else:
|
||||
virtual_path = _externalize_to_sandbox(
|
||||
content,
|
||||
tool_name=tool_name,
|
||||
tool_call_id=tool_call_id,
|
||||
storage_subdir=config.storage_subdir,
|
||||
sandbox=sandbox,
|
||||
)
|
||||
elif outputs_path:
|
||||
# No sandbox in this call (legacy / non-sandbox tools): write to
|
||||
# host outputs path directly, no provider needed.
|
||||
virtual_path = _externalize(
|
||||
content,
|
||||
tool_name=tool_name,
|
||||
tool_call_id=tool_call_id,
|
||||
outputs_path=outputs_path,
|
||||
storage_subdir=config.storage_subdir,
|
||||
)
|
||||
if virtual_path is not None:
|
||||
logger.info(
|
||||
"Externalized %s output (%d chars) to %s",
|
||||
tool_name,
|
||||
len(content),
|
||||
virtual_path,
|
||||
)
|
||||
return _build_preview(
|
||||
content,
|
||||
tool_name=tool_name,
|
||||
virtual_path=virtual_path,
|
||||
head_chars=config.preview_head_chars,
|
||||
tail_chars=config.preview_tail_chars,
|
||||
)
|
||||
|
||||
if config.fallback_max_chars > 0 and len(content) > config.fallback_max_chars:
|
||||
logger.warning(
|
||||
"Fallback-truncating %s output: %d chars → %d max",
|
||||
tool_name,
|
||||
len(content),
|
||||
config.fallback_max_chars,
|
||||
)
|
||||
return _build_fallback(
|
||||
content,
|
||||
tool_name=tool_name,
|
||||
max_chars=config.fallback_max_chars,
|
||||
head_chars=config.fallback_head_chars,
|
||||
tail_chars=config.fallback_tail_chars,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result patchers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _patch_tool_message(
|
||||
msg: ToolMessage,
|
||||
config: ToolOutputConfig,
|
||||
outputs_path: str | None,
|
||||
sandbox: Sandbox | None = None,
|
||||
) -> ToolMessage:
|
||||
"""Apply budget to a single ToolMessage. Returns the original if unchanged."""
|
||||
tool_name = msg.name or "unknown"
|
||||
if tool_name in config.exempt_tools:
|
||||
return msg
|
||||
|
||||
text = _message_text(msg.content)
|
||||
if text is None:
|
||||
return msg
|
||||
|
||||
replacement = _budget_content(
|
||||
text,
|
||||
tool_name=tool_name,
|
||||
tool_call_id=msg.tool_call_id or "",
|
||||
outputs_path=outputs_path,
|
||||
config=config,
|
||||
sandbox=sandbox,
|
||||
)
|
||||
if replacement is None:
|
||||
return msg
|
||||
|
||||
update: dict[str, Any] = {"content": replacement}
|
||||
if getattr(msg, "response_metadata", None):
|
||||
update["response_metadata"] = dict(msg.response_metadata)
|
||||
if getattr(msg, "additional_kwargs", None):
|
||||
update["additional_kwargs"] = dict(msg.additional_kwargs)
|
||||
return msg.model_copy(update=update)
|
||||
|
||||
|
||||
def _effective_trigger(tool_name: str, config: ToolOutputConfig) -> int:
|
||||
"""Smallest content length that could trigger budgeting for *tool_name*.
|
||||
|
||||
Mirrors the trigger conditions in :func:`_budget_content` (per-tool
|
||||
externalize threshold OR global fallback), so the pre-scan never produces
|
||||
a false negative. Returns ``-1`` when nothing could ever trigger.
|
||||
"""
|
||||
candidates: list[int] = []
|
||||
externalize = config.tool_overrides.get(tool_name, config.externalize_min_chars)
|
||||
if externalize > 0:
|
||||
candidates.append(externalize)
|
||||
if config.fallback_max_chars > 0:
|
||||
candidates.append(config.fallback_max_chars)
|
||||
return min(candidates) if candidates else -1
|
||||
|
||||
|
||||
def _tool_message_over_budget(msg: ToolMessage, config: ToolOutputConfig) -> bool:
|
||||
"""Cheap, per-tool-aware check: is this ToolMessage non-exempt and over its trigger?"""
|
||||
if (msg.name or "") in config.exempt_tools:
|
||||
return False
|
||||
trigger = _effective_trigger(msg.name or "", config)
|
||||
if trigger < 0:
|
||||
return False
|
||||
text = _message_text(msg.content)
|
||||
return text is not None and len(text) > trigger
|
||||
|
||||
|
||||
def _needs_budget(result: ToolMessage | Command, config: ToolOutputConfig) -> bool:
|
||||
"""Fast check whether *result* could need budgeting (avoids thread offload for small outputs)."""
|
||||
if isinstance(result, ToolMessage):
|
||||
return _tool_message_over_budget(result, config)
|
||||
update = getattr(result, "update", None)
|
||||
if isinstance(update, dict):
|
||||
for msg in update.get("messages", []):
|
||||
if isinstance(msg, ToolMessage) and _tool_message_over_budget(msg, config):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _patch_result(
|
||||
result: ToolMessage | Command,
|
||||
config: ToolOutputConfig,
|
||||
outputs_path: str | None,
|
||||
sandbox: Sandbox | None = None,
|
||||
) -> ToolMessage | Command:
|
||||
"""Apply budget to a tool call result (ToolMessage or Command)."""
|
||||
if isinstance(result, ToolMessage):
|
||||
return _patch_tool_message(result, config, outputs_path, sandbox)
|
||||
|
||||
update = getattr(result, "update", None)
|
||||
if not isinstance(update, dict):
|
||||
return result
|
||||
|
||||
messages = update.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
return result
|
||||
|
||||
new_messages: list[Any] = []
|
||||
changed = False
|
||||
for msg in messages:
|
||||
if isinstance(msg, ToolMessage):
|
||||
patched = _patch_tool_message(msg, config, outputs_path, sandbox)
|
||||
if patched is not msg:
|
||||
changed = True
|
||||
new_messages.append(patched)
|
||||
else:
|
||||
new_messages.append(msg)
|
||||
|
||||
if not changed:
|
||||
return result
|
||||
|
||||
return dc_replace(result, update={**update, "messages": new_messages})
|
||||
|
||||
|
||||
def _patch_model_messages(messages: list[Any], config: ToolOutputConfig) -> list[Any] | None:
|
||||
"""Apply budget to historical ToolMessages in a model request. Returns ``None`` if unchanged.
|
||||
|
||||
A cheap pre-scan bails out before allocating a new list when no historical
|
||||
ToolMessage exceeds the budget — the common case once every result has
|
||||
already been budgeted at tool-call time, so a long history is not rebuilt
|
||||
on every model call.
|
||||
|
||||
Historical messages do not get a ``sandbox`` argument: any oversized tool
|
||||
message in history was already budgeted (and possibly externalized) at
|
||||
tool-call time, so the only thing left for the history path to do is
|
||||
inline fallback truncation, which needs no sandbox.
|
||||
"""
|
||||
if not any(isinstance(msg, ToolMessage) and _tool_message_over_budget(msg, config) for msg in messages):
|
||||
return None
|
||||
|
||||
updated: list[Any] = []
|
||||
changed = False
|
||||
for msg in messages:
|
||||
if isinstance(msg, ToolMessage):
|
||||
patched = _patch_tool_message(msg, config, outputs_path=None)
|
||||
if patched is not msg:
|
||||
changed = True
|
||||
updated.append(patched)
|
||||
else:
|
||||
updated.append(msg)
|
||||
return updated if changed else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Middleware class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ToolOutputBudgetMiddleware(AgentMiddleware[AgentState]):
|
||||
"""Enforce per-result budget on tool outputs via externalization or truncation."""
|
||||
|
||||
def __init__(self, config: ToolOutputConfig | None = None) -> None:
|
||||
super().__init__()
|
||||
self._config = config if config is not None else _default_config()
|
||||
|
||||
@classmethod
|
||||
def from_app_config(cls, app_config: Any) -> ToolOutputBudgetMiddleware:
|
||||
tool_output = getattr(app_config, "tool_output", None)
|
||||
if isinstance(tool_output, ToolOutputConfig):
|
||||
return cls(config=tool_output)
|
||||
return cls()
|
||||
|
||||
# -- tool call hooks ---------------------------------------------------
|
||||
|
||||
@override
|
||||
def wrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
result = handler(request)
|
||||
if not self._config.enabled:
|
||||
return result
|
||||
if not _needs_budget(result, self._config):
|
||||
return result
|
||||
outputs_path = _resolve_outputs_path(request)
|
||||
sandbox = _resolve_sandbox(request)
|
||||
return _patch_result(result, self._config, outputs_path, sandbox)
|
||||
|
||||
@override
|
||||
async def awrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
|
||||
) -> ToolMessage | Command:
|
||||
result = await handler(request)
|
||||
if not self._config.enabled:
|
||||
return result
|
||||
if not _needs_budget(result, self._config):
|
||||
return result
|
||||
outputs_path = _resolve_outputs_path(request)
|
||||
# _resolve_sandbox only touches runtime.state and the provider's
|
||||
# in-memory sandbox registry, so it is safe to call on the event
|
||||
# loop. The actual sandbox I/O (mkdir/write/test) happens inside
|
||||
# _patch_result, which is offloaded to a worker thread below.
|
||||
sandbox = _resolve_sandbox(request)
|
||||
return await asyncio.to_thread(_patch_result, result, self._config, outputs_path, sandbox)
|
||||
|
||||
# -- model call hooks (historical message truncation) ------------------
|
||||
|
||||
@override
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelCallResult:
|
||||
if self._config.enabled:
|
||||
messages = getattr(request, "messages", None)
|
||||
if isinstance(messages, list):
|
||||
patched = _patch_model_messages(messages, self._config)
|
||||
if patched is not None:
|
||||
request = request.override(messages=patched)
|
||||
return handler(request)
|
||||
|
||||
@override
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelCallResult:
|
||||
if self._config.enabled:
|
||||
messages = getattr(request, "messages", None)
|
||||
if isinstance(messages, list):
|
||||
patched = _patch_model_messages(messages, self._config)
|
||||
if patched is not None:
|
||||
request = request.override(messages=patched)
|
||||
return await handler(request)
|
||||
@ -7,11 +7,13 @@ from typing import NotRequired, override
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain_core.runnables import run_in_executor
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from deerflow.config.paths import Paths, get_paths
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
from deerflow.utils.file_conversion import extract_outline
|
||||
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY, message_content_to_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -264,6 +266,8 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
||||
|
||||
# Extract original content - handle both string and list formats
|
||||
original_content = last_message.content
|
||||
additional_kwargs = dict(last_message.additional_kwargs or {})
|
||||
additional_kwargs.setdefault(ORIGINAL_USER_CONTENT_KEY, message_content_to_text(original_content))
|
||||
if isinstance(original_content, str):
|
||||
# Simple case: string content, just prepend files message
|
||||
updated_content = f"{files_message}\n\n{original_content}"
|
||||
@ -284,7 +288,7 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
||||
content=updated_content,
|
||||
id=last_message.id,
|
||||
name=last_message.name,
|
||||
additional_kwargs=last_message.additional_kwargs,
|
||||
additional_kwargs=additional_kwargs,
|
||||
)
|
||||
|
||||
messages[last_message_index] = updated_message
|
||||
@ -293,3 +297,16 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
||||
"uploaded_files": new_files,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
@override
|
||||
async def abefore_agent(self, state: UploadsMiddlewareState, runtime: Runtime) -> dict | None:
|
||||
"""Async hook that offloads the synchronous uploads scan off the event loop.
|
||||
|
||||
``before_agent`` performs blocking filesystem IO (directory enumeration,
|
||||
``stat``, reading sibling ``.md`` outlines). When the graph runs async,
|
||||
langgraph would otherwise execute the sync hook directly on the event
|
||||
loop, so it is dispatched to a worker thread via ``run_in_executor``.
|
||||
``run_in_executor`` copies the current context, so the ``user_id``
|
||||
contextvar read by ``get_effective_user_id()`` is preserved.
|
||||
"""
|
||||
return await run_in_executor(None, self.before_agent, state, runtime)
|
||||
|
||||
@ -179,8 +179,10 @@ class ViewImageMiddleware(AgentMiddleware[ViewImageMiddlewareState]):
|
||||
# Create the image details message with text and image content
|
||||
image_content = self._create_image_details_message(state)
|
||||
|
||||
# Create a new human message with mixed content (text + images)
|
||||
human_msg = HumanMessage(content=image_content)
|
||||
# Create a new human message with mixed content (text + images). This is
|
||||
# internal context for the model only, so hide it from the chat UI and IM
|
||||
# channels (matches the other middleware-injected context messages).
|
||||
human_msg = HumanMessage(content=image_content, additional_kwargs={"hide_from_ui": True})
|
||||
|
||||
logger.debug("Injecting image details message with images before LLM call")
|
||||
|
||||
|
||||
@ -45,11 +45,51 @@ def merge_viewed_images(existing: dict[str, ViewedImageData] | None, new: dict[s
|
||||
return {**existing, **new}
|
||||
|
||||
|
||||
def merge_todos(existing: list | None, new: list | None) -> list | None:
|
||||
"""Reducer for todos list - keeps the last non-None value.
|
||||
|
||||
Semantics:
|
||||
- If `new` is None (node didn't touch todos), preserve `existing`.
|
||||
- If `new` is provided (even empty list), it represents an explicit
|
||||
update and wins over `existing`.
|
||||
"""
|
||||
if new is None:
|
||||
return existing
|
||||
return new
|
||||
|
||||
|
||||
class PromotedTools(TypedDict):
|
||||
catalog_hash: str
|
||||
names: list[str]
|
||||
|
||||
|
||||
def merge_promoted(existing: PromotedTools | None, new: PromotedTools | None) -> PromotedTools | None:
|
||||
"""Reducer for deferred-tool promotions, scoped by catalog hash.
|
||||
|
||||
- new None/empty -> preserve existing (node didn't touch promotions).
|
||||
- catalog_hash changed -> replace wholesale, dropping stale names (prevents a
|
||||
persisted bare name from exposing a different tool after catalog drift).
|
||||
- same catalog_hash -> union names, dedupe, preserve order.
|
||||
"""
|
||||
if not new:
|
||||
return existing
|
||||
if existing is None or existing.get("catalog_hash") != new["catalog_hash"]:
|
||||
return {
|
||||
"catalog_hash": new["catalog_hash"],
|
||||
"names": list(dict.fromkeys(new["names"])),
|
||||
}
|
||||
return {
|
||||
"catalog_hash": existing["catalog_hash"],
|
||||
"names": list(dict.fromkeys(existing["names"] + new["names"])),
|
||||
}
|
||||
|
||||
|
||||
class ThreadState(AgentState):
|
||||
sandbox: NotRequired[SandboxState | None]
|
||||
thread_data: NotRequired[ThreadDataState | None]
|
||||
title: NotRequired[str | None]
|
||||
artifacts: Annotated[list[str], merge_artifacts]
|
||||
todos: NotRequired[list | None]
|
||||
todos: Annotated[list | None, merge_todos]
|
||||
uploaded_files: NotRequired[list[dict] | None]
|
||||
viewed_images: Annotated[dict[str, ViewedImageData], merge_viewed_images] # image_path -> {base64, mime_type}
|
||||
promoted: Annotated[PromotedTools | None, merge_promoted]
|
||||
|
||||
@ -19,6 +19,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
@ -32,7 +33,7 @@ from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from deerflow.agents.lead_agent.agent import _build_middlewares
|
||||
from deerflow.agents.lead_agent.agent import build_middlewares
|
||||
from deerflow.agents.lead_agent.prompt import apply_prompt_template
|
||||
from deerflow.agents.thread_state import ThreadState
|
||||
from deerflow.config.agents_config import AGENT_NAME_PATTERN
|
||||
@ -42,6 +43,8 @@ from deerflow.config.paths import get_paths
|
||||
from deerflow.models import create_chat_model
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
from deerflow.tools.builtins.tool_search import assemble_deferred_tools
|
||||
from deerflow.tracing import build_tracing_callbacks, inject_langfuse_metadata
|
||||
from deerflow.uploads.manager import (
|
||||
claim_unique_filename,
|
||||
delete_file_safe,
|
||||
@ -123,6 +126,7 @@ class DeerFlowClient:
|
||||
agent_name: str | None = None,
|
||||
available_skills: set[str] | None = None,
|
||||
middlewares: Sequence[AgentMiddleware] | None = None,
|
||||
environment: str | None = None,
|
||||
):
|
||||
"""Initialize the client.
|
||||
|
||||
@ -140,6 +144,12 @@ class DeerFlowClient:
|
||||
agent_name: Name of the agent to use.
|
||||
available_skills: Optional set of skill names to make available. If None (default), all scanned skills are available.
|
||||
middlewares: Optional list of custom middlewares to inject into the agent.
|
||||
environment: Deployment environment label that ends up in
|
||||
``langfuse_tags`` (e.g. ``"production"`` / ``"staging"``).
|
||||
When ``None`` the worker/client falls back to the
|
||||
``DEER_FLOW_ENV`` or ``ENVIRONMENT`` env vars. Pass an
|
||||
explicit value for programmatic callers that do not want
|
||||
env-var coupling.
|
||||
"""
|
||||
if config_path is not None:
|
||||
reload_app_config(config_path)
|
||||
@ -156,6 +166,7 @@ class DeerFlowClient:
|
||||
self._agent_name = agent_name
|
||||
self._available_skills = set(available_skills) if available_skills is not None else None
|
||||
self._middlewares = list(middlewares) if middlewares else []
|
||||
self._environment = environment
|
||||
|
||||
# Lazy agent — created on first call, recreated when config changes.
|
||||
self._agent = None
|
||||
@ -227,15 +238,30 @@ class DeerFlowClient:
|
||||
subagent_enabled = cfg.get("subagent_enabled", False)
|
||||
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
||||
|
||||
tools = self._get_tools(model_name=model_name, subagent_enabled=subagent_enabled)
|
||||
final_tools, deferred_setup = assemble_deferred_tools(tools, enabled=self._app_config.tool_search.enabled)
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": create_chat_model(name=model_name, thinking_enabled=thinking_enabled),
|
||||
"tools": self._get_tools(model_name=model_name, subagent_enabled=subagent_enabled),
|
||||
"middleware": _build_middlewares(config, model_name=model_name, agent_name=self._agent_name, custom_middlewares=self._middlewares),
|
||||
# attach_tracing=False because ``stream()`` injects tracing
|
||||
# callbacks at the graph invocation root so a single embedded run
|
||||
# produces one trace with correct session_id / user_id propagation.
|
||||
# Attaching them again on the model would emit duplicate spans.
|
||||
"model": create_chat_model(name=model_name, thinking_enabled=thinking_enabled, attach_tracing=False),
|
||||
"tools": final_tools,
|
||||
"middleware": build_middlewares(
|
||||
config,
|
||||
model_name=model_name,
|
||||
agent_name=self._agent_name,
|
||||
available_skills=self._available_skills,
|
||||
custom_middlewares=self._middlewares,
|
||||
app_config=self._app_config,
|
||||
deferred_setup=deferred_setup,
|
||||
),
|
||||
"system_prompt": apply_prompt_template(
|
||||
subagent_enabled=subagent_enabled,
|
||||
max_concurrent_subagents=max_concurrent_subagents,
|
||||
agent_name=self._agent_name,
|
||||
available_skills=self._available_skills,
|
||||
deferred_names=deferred_setup.deferred_names,
|
||||
),
|
||||
"state_schema": ThreadState,
|
||||
}
|
||||
@ -571,6 +597,28 @@ class DeerFlowClient:
|
||||
thread_id = str(uuid.uuid4())
|
||||
|
||||
config = self._get_runnable_config(thread_id, **kwargs)
|
||||
|
||||
# Inject tracing callbacks and Langfuse trace metadata at the graph
|
||||
# invocation root so the embedded client matches the gateway worker's
|
||||
# behaviour: a single ``stream()`` produces one trace with all node /
|
||||
# LLM / tool calls nested under it, and the trace carries the reserved
|
||||
# ``langfuse_session_id`` / ``langfuse_user_id`` keys that the Langfuse
|
||||
# CallbackHandler lifts onto the root trace's ``sessionId`` / ``userId``.
|
||||
tracing_callbacks = build_tracing_callbacks()
|
||||
if tracing_callbacks:
|
||||
existing_callbacks = list(config.get("callbacks") or [])
|
||||
config["callbacks"] = [*existing_callbacks, *tracing_callbacks]
|
||||
|
||||
configurable = config.get("configurable") or {}
|
||||
inject_langfuse_metadata(
|
||||
config,
|
||||
thread_id=thread_id,
|
||||
user_id=get_effective_user_id(),
|
||||
assistant_id=self._agent_name or "lead-agent",
|
||||
model_name=configurable.get("model_name") or self._model_name,
|
||||
environment=self._environment or os.environ.get("DEER_FLOW_ENV") or os.environ.get("ENVIRONMENT"),
|
||||
)
|
||||
|
||||
self._ensure_agent(config)
|
||||
|
||||
state: dict[str, Any] = {"messages": [HumanMessage(content=message)]}
|
||||
@ -1170,7 +1218,7 @@ class DeerFlowClient:
|
||||
|
||||
info: dict[str, Any] = {
|
||||
"filename": dest_name,
|
||||
"size": str(dest.stat().st_size),
|
||||
"size": dest.stat().st_size,
|
||||
"path": str(dest),
|
||||
"virtual_path": upload_virtual_path(dest_name),
|
||||
"artifact_url": upload_artifact_url(thread_id, dest_name),
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import errno
|
||||
import logging
|
||||
import shlex
|
||||
import threading
|
||||
@ -6,11 +7,14 @@ import uuid
|
||||
|
||||
from agent_sandbox import Sandbox as AioSandboxClient
|
||||
|
||||
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
|
||||
from deerflow.sandbox.sandbox import Sandbox
|
||||
from deerflow.sandbox.search import GrepMatch, path_matches, should_ignore_path, truncate_line
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024 # 100 MB
|
||||
|
||||
_ERROR_OBSERVATION_SIGNATURE = "'ErrorObservation' object has no attribute 'exit_code'"
|
||||
|
||||
|
||||
@ -35,11 +39,63 @@ class AioSandbox(Sandbox):
|
||||
self._client = AioSandboxClient(base_url=base_url, timeout=600)
|
||||
self._home_dir = home_dir
|
||||
self._lock = threading.Lock()
|
||||
self._closed = False
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return self._base_url
|
||||
|
||||
def close(self) -> None:
|
||||
"""Best-effort close of the host-side HTTP client owned by this sandbox.
|
||||
|
||||
The agent_sandbox SDK is Fern-generated and exposes no ``close()`` /
|
||||
``__exit__``, so we reach the socket-owning ``httpx.Client`` explicitly
|
||||
through its attribute chain::
|
||||
|
||||
Sandbox._client_wrapper -> SyncClientWrapper
|
||||
.httpx_client -> Fern HttpClient (a wrapper, NOT httpx.Client)
|
||||
.httpx_client -> httpx.Client <- the real socket owner
|
||||
|
||||
Closing it releases pooled sockets so long-running provider lifecycles
|
||||
do not accumulate unreclaimed host-side resources (#2872).
|
||||
|
||||
Resolution is most-specific-first with graceful degradation: if a future
|
||||
SDK adds a top-level ``Sandbox.close()`` it is picked up automatically
|
||||
without changing this code. Idempotent, thread-safe, and non-fatal:
|
||||
failures during teardown are logged and swallowed so provider/backend
|
||||
cleanup is never blocked.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
client = self._client
|
||||
# Drop the reference under the lock for use-after-close safety: any
|
||||
# later command on this instance fails loudly instead of reusing a
|
||||
# half-closed client.
|
||||
self._client = None
|
||||
|
||||
if client is None:
|
||||
return
|
||||
|
||||
# Walk from the real httpx.Client up to the top-level client, picking the
|
||||
# first object that actually exposes close().
|
||||
wrapper = getattr(client, "_client_wrapper", None)
|
||||
fern_http = getattr(wrapper, "httpx_client", None)
|
||||
real_httpx = getattr(fern_http, "httpx_client", None)
|
||||
target = next(
|
||||
(c for c in (real_httpx, fern_http, client) if c is not None and hasattr(c, "close")),
|
||||
None,
|
||||
)
|
||||
if target is None:
|
||||
logger.debug("AioSandbox %s: no closable client found, nothing to release", self.id)
|
||||
return
|
||||
|
||||
try:
|
||||
target.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing AioSandbox client for {self.id}: {e}")
|
||||
|
||||
@property
|
||||
def home_dir(self) -> str:
|
||||
"""Get the home directory inside the sandbox."""
|
||||
@ -102,6 +158,49 @@ class AioSandbox(Sandbox):
|
||||
logger.error(f"Failed to read file in sandbox: {e}")
|
||||
return f"Error: {e}"
|
||||
|
||||
def download_file(self, path: str) -> bytes:
|
||||
"""Download file bytes from the sandbox.
|
||||
|
||||
Raises:
|
||||
PermissionError: If the path contains '..' traversal segments or is
|
||||
outside ``VIRTUAL_PATH_PREFIX``.
|
||||
OSError: If the file cannot be retrieved from the sandbox.
|
||||
"""
|
||||
# Reject path traversal before sending to the container API.
|
||||
# LocalSandbox gets this implicitly via _resolve_path;
|
||||
# here the path is forwarded verbatim so we must check explicitly.
|
||||
normalised = path.replace("\\", "/")
|
||||
for segment in normalised.split("/"):
|
||||
if segment == "..":
|
||||
logger.error(f"Refused download due to path traversal: {path}")
|
||||
raise PermissionError(f"Access denied: path traversal detected in '{path}'")
|
||||
|
||||
stripped_path = normalised.lstrip("/")
|
||||
allowed_prefix = VIRTUAL_PATH_PREFIX.lstrip("/")
|
||||
if stripped_path != allowed_prefix and not stripped_path.startswith(f"{allowed_prefix}/"):
|
||||
logger.error("Refused download outside allowed directory: path=%s, allowed_prefix=%s", path, VIRTUAL_PATH_PREFIX)
|
||||
raise PermissionError(f"Access denied: path must be under '{VIRTUAL_PATH_PREFIX}': '{path}'")
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
for chunk in self._client.file.download_file(path=path):
|
||||
total += len(chunk)
|
||||
if total > _MAX_DOWNLOAD_SIZE:
|
||||
raise OSError(
|
||||
errno.EFBIG,
|
||||
f"File exceeds maximum download size of {_MAX_DOWNLOAD_SIZE} bytes",
|
||||
path,
|
||||
)
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
except OSError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download file in sandbox: {e}")
|
||||
raise OSError(f"Failed to download file '{path}' from sandbox: {e}") from e
|
||||
|
||||
def list_dir(self, path: str, max_depth: int = 2) -> list[str]:
|
||||
"""List the contents of a directory in the sandbox.
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ The provider itself handles:
|
||||
- Mount computation (thread-specific, skills)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import atexit
|
||||
import hashlib
|
||||
import logging
|
||||
@ -18,6 +19,7 @@ import signal
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
@ -32,7 +34,7 @@ from deerflow.sandbox.sandbox import Sandbox
|
||||
from deerflow.sandbox.sandbox_provider import SandboxProvider
|
||||
|
||||
from .aio_sandbox import AioSandbox
|
||||
from .backend import SandboxBackend, wait_for_sandbox_ready
|
||||
from .backend import SandboxBackend, wait_for_sandbox_ready, wait_for_sandbox_ready_async
|
||||
from .local_backend import LocalContainerBackend
|
||||
from .remote_backend import RemoteSandboxBackend
|
||||
from .sandbox_info import SandboxInfo
|
||||
@ -46,6 +48,9 @@ DEFAULT_CONTAINER_PREFIX = "deer-flow-sandbox"
|
||||
DEFAULT_IDLE_TIMEOUT = 600 # 10 minutes in seconds
|
||||
DEFAULT_REPLICAS = 3 # Maximum concurrent sandbox containers
|
||||
IDLE_CHECK_INTERVAL = 60 # Check every 60 seconds
|
||||
THREAD_LOCK_EXECUTOR_WORKERS = min(32, (os.cpu_count() or 1) + 4)
|
||||
_THREAD_LOCK_EXECUTOR = ThreadPoolExecutor(max_workers=THREAD_LOCK_EXECUTOR_WORKERS, thread_name_prefix="sandbox-lock-wait")
|
||||
atexit.register(_THREAD_LOCK_EXECUTOR.shutdown, wait=False, cancel_futures=True)
|
||||
|
||||
|
||||
def _lock_file_exclusive(lock_file) -> None:
|
||||
@ -66,6 +71,40 @@ def _unlock_file(lock_file) -> None:
|
||||
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
|
||||
|
||||
def _open_lock_file(lock_path):
|
||||
return open(lock_path, "a", encoding="utf-8")
|
||||
|
||||
|
||||
async def _acquire_thread_lock_async(lock: threading.Lock) -> None:
|
||||
"""Acquire a threading.Lock without polling or using the default executor."""
|
||||
loop = asyncio.get_running_loop()
|
||||
acquire_future = loop.run_in_executor(_THREAD_LOCK_EXECUTOR, lock.acquire, True)
|
||||
|
||||
try:
|
||||
acquired = await asyncio.shield(acquire_future)
|
||||
except asyncio.CancelledError:
|
||||
acquire_future.add_done_callback(lambda task: _release_cancelled_lock_acquire(lock, task))
|
||||
raise
|
||||
|
||||
if not acquired:
|
||||
raise RuntimeError("Failed to acquire sandbox thread lock")
|
||||
|
||||
|
||||
def _release_cancelled_lock_acquire(lock: threading.Lock, task: asyncio.Future[bool]) -> None:
|
||||
"""Release a lock acquired after its awaiting coroutine was cancelled."""
|
||||
if task.cancelled():
|
||||
return
|
||||
|
||||
try:
|
||||
acquired = task.result()
|
||||
except Exception as e:
|
||||
logger.warning(f"Cancelled sandbox lock acquisition finished with error: {e}")
|
||||
return
|
||||
|
||||
if acquired:
|
||||
lock.release()
|
||||
|
||||
|
||||
class AioSandboxProvider(SandboxProvider):
|
||||
"""Sandbox provider that manages containers running the AIO sandbox.
|
||||
|
||||
@ -416,6 +455,96 @@ class AioSandboxProvider(SandboxProvider):
|
||||
self._thread_locks[thread_id] = threading.Lock()
|
||||
return self._thread_locks[thread_id]
|
||||
|
||||
def _sandbox_id_for_thread(self, thread_id: str | None) -> str:
|
||||
"""Return deterministic IDs for thread sandboxes and random IDs otherwise."""
|
||||
return self._deterministic_sandbox_id(thread_id) if thread_id else str(uuid.uuid4())[:8]
|
||||
|
||||
def _reuse_in_process_sandbox(self, thread_id: str | None, *, post_lock: bool = False) -> str | None:
|
||||
"""Reuse an active in-process sandbox for a thread if one is still tracked."""
|
||||
if thread_id is None:
|
||||
return None
|
||||
|
||||
with self._lock:
|
||||
if thread_id not in self._thread_sandboxes:
|
||||
return None
|
||||
|
||||
existing_id = self._thread_sandboxes[thread_id]
|
||||
if existing_id in self._sandboxes:
|
||||
suffix = " (post-lock check)" if post_lock else ""
|
||||
logger.info(f"Reusing in-process sandbox {existing_id} for thread {thread_id}{suffix}")
|
||||
self._last_activity[existing_id] = time.time()
|
||||
return existing_id
|
||||
|
||||
del self._thread_sandboxes[thread_id]
|
||||
return None
|
||||
|
||||
def _reclaim_warm_pool_sandbox(self, thread_id: str | None, sandbox_id: str, *, post_lock: bool = False) -> str | None:
|
||||
"""Promote a warm-pool sandbox back to active tracking if available."""
|
||||
if thread_id is None:
|
||||
return None
|
||||
|
||||
with self._lock:
|
||||
if sandbox_id not in self._warm_pool:
|
||||
return None
|
||||
|
||||
info, _ = self._warm_pool.pop(sandbox_id)
|
||||
sandbox = AioSandbox(id=sandbox_id, base_url=info.sandbox_url)
|
||||
self._sandboxes[sandbox_id] = sandbox
|
||||
self._sandbox_infos[sandbox_id] = info
|
||||
self._last_activity[sandbox_id] = time.time()
|
||||
self._thread_sandboxes[thread_id] = sandbox_id
|
||||
|
||||
suffix = " (post-lock check)" if post_lock else f" at {info.sandbox_url}"
|
||||
logger.info(f"Reclaimed warm-pool sandbox {sandbox_id} for thread {thread_id}{suffix}")
|
||||
return sandbox_id
|
||||
|
||||
def _recheck_cached_sandbox(self, thread_id: str, sandbox_id: str) -> str | None:
|
||||
"""Re-check in-memory caches after acquiring the cross-process file lock."""
|
||||
return self._reuse_in_process_sandbox(thread_id, post_lock=True) or self._reclaim_warm_pool_sandbox(thread_id, sandbox_id, post_lock=True)
|
||||
|
||||
def _register_discovered_sandbox(self, thread_id: str, info: SandboxInfo) -> str:
|
||||
"""Track a sandbox discovered through the backend."""
|
||||
sandbox = AioSandbox(id=info.sandbox_id, base_url=info.sandbox_url)
|
||||
with self._lock:
|
||||
self._sandboxes[info.sandbox_id] = sandbox
|
||||
self._sandbox_infos[info.sandbox_id] = info
|
||||
self._last_activity[info.sandbox_id] = time.time()
|
||||
self._thread_sandboxes[thread_id] = info.sandbox_id
|
||||
|
||||
logger.info(f"Discovered existing sandbox {info.sandbox_id} for thread {thread_id} at {info.sandbox_url}")
|
||||
return info.sandbox_id
|
||||
|
||||
def _register_created_sandbox(self, thread_id: str | None, sandbox_id: str, info: SandboxInfo) -> str:
|
||||
"""Track a newly-created sandbox in the active maps."""
|
||||
sandbox = AioSandbox(id=sandbox_id, base_url=info.sandbox_url)
|
||||
with self._lock:
|
||||
self._sandboxes[sandbox_id] = sandbox
|
||||
self._sandbox_infos[sandbox_id] = info
|
||||
self._last_activity[sandbox_id] = time.time()
|
||||
if thread_id:
|
||||
self._thread_sandboxes[thread_id] = sandbox_id
|
||||
|
||||
logger.info(f"Created sandbox {sandbox_id} for thread {thread_id} at {info.sandbox_url}")
|
||||
return sandbox_id
|
||||
|
||||
def _replica_count(self) -> tuple[int, int]:
|
||||
"""Return configured replicas and currently tracked sandbox count."""
|
||||
replicas = self._config.get("replicas", DEFAULT_REPLICAS)
|
||||
with self._lock:
|
||||
total = len(self._sandboxes) + len(self._warm_pool)
|
||||
return replicas, total
|
||||
|
||||
def _log_replicas_soft_cap(self, replicas: int, sandbox_id: str, evicted: str | None) -> None:
|
||||
"""Log the result of enforcing the warm-pool replica budget."""
|
||||
if evicted:
|
||||
logger.info(f"Evicted warm-pool sandbox {evicted} to stay within replicas={replicas}")
|
||||
return
|
||||
|
||||
# All slots are occupied by active sandboxes — proceed anyway and log.
|
||||
# The replicas limit is a soft cap; we never forcibly stop a container
|
||||
# that is actively serving a thread.
|
||||
logger.warning(f"All {replicas} replica slots are in active use; creating sandbox {sandbox_id} beyond the soft limit")
|
||||
|
||||
# ── Core: acquire / get / release / shutdown ─────────────────────────
|
||||
|
||||
def acquire(self, thread_id: str | None = None) -> str:
|
||||
@ -440,6 +569,23 @@ class AioSandboxProvider(SandboxProvider):
|
||||
else:
|
||||
return self._acquire_internal(thread_id)
|
||||
|
||||
async def acquire_async(self, thread_id: str | None = None) -> str:
|
||||
"""Acquire a sandbox environment without blocking the event loop.
|
||||
|
||||
Mirrors ``acquire()`` while keeping blocking backend operations off the
|
||||
event loop and using async-native readiness polling for newly created
|
||||
sandboxes.
|
||||
"""
|
||||
if thread_id:
|
||||
thread_lock = self._get_thread_lock(thread_id)
|
||||
await _acquire_thread_lock_async(thread_lock)
|
||||
try:
|
||||
return await self._acquire_internal_async(thread_id)
|
||||
finally:
|
||||
thread_lock.release()
|
||||
|
||||
return await self._acquire_internal_async(thread_id)
|
||||
|
||||
def _acquire_internal(self, thread_id: str | None) -> str:
|
||||
"""Internal sandbox acquisition with two-layer consistency.
|
||||
|
||||
@ -448,33 +594,17 @@ class AioSandboxProvider(SandboxProvider):
|
||||
sandbox_id is deterministic from thread_id so no shared state file
|
||||
is needed — any process can derive the same container name)
|
||||
"""
|
||||
# ── Layer 1: In-process cache (fast path) ──
|
||||
if thread_id:
|
||||
with self._lock:
|
||||
if thread_id in self._thread_sandboxes:
|
||||
existing_id = self._thread_sandboxes[thread_id]
|
||||
if existing_id in self._sandboxes:
|
||||
logger.info(f"Reusing in-process sandbox {existing_id} for thread {thread_id}")
|
||||
self._last_activity[existing_id] = time.time()
|
||||
return existing_id
|
||||
else:
|
||||
del self._thread_sandboxes[thread_id]
|
||||
cached_id = self._reuse_in_process_sandbox(thread_id)
|
||||
if cached_id is not None:
|
||||
return cached_id
|
||||
|
||||
# Deterministic ID for thread-specific, random for anonymous
|
||||
sandbox_id = self._deterministic_sandbox_id(thread_id) if thread_id else str(uuid.uuid4())[:8]
|
||||
sandbox_id = self._sandbox_id_for_thread(thread_id)
|
||||
|
||||
# ── Layer 1.5: Warm pool (container still running, no cold-start) ──
|
||||
if thread_id:
|
||||
with self._lock:
|
||||
if sandbox_id in self._warm_pool:
|
||||
info, _ = self._warm_pool.pop(sandbox_id)
|
||||
sandbox = AioSandbox(id=sandbox_id, base_url=info.sandbox_url)
|
||||
self._sandboxes[sandbox_id] = sandbox
|
||||
self._sandbox_infos[sandbox_id] = info
|
||||
self._last_activity[sandbox_id] = time.time()
|
||||
self._thread_sandboxes[thread_id] = sandbox_id
|
||||
logger.info(f"Reclaimed warm-pool sandbox {sandbox_id} for thread {thread_id} at {info.sandbox_url}")
|
||||
return sandbox_id
|
||||
reclaimed_id = self._reclaim_warm_pool_sandbox(thread_id, sandbox_id)
|
||||
if reclaimed_id is not None:
|
||||
return reclaimed_id
|
||||
|
||||
# ── Layer 2: Backend discovery + create (protected by cross-process lock) ──
|
||||
# Use a file lock so that two processes racing to create the same sandbox
|
||||
@ -485,6 +615,26 @@ class AioSandboxProvider(SandboxProvider):
|
||||
|
||||
return self._create_sandbox(thread_id, sandbox_id)
|
||||
|
||||
async def _acquire_internal_async(self, thread_id: str | None) -> str:
|
||||
"""Async counterpart to ``_acquire_internal``."""
|
||||
cached_id = self._reuse_in_process_sandbox(thread_id)
|
||||
if cached_id is not None:
|
||||
return cached_id
|
||||
|
||||
# Deterministic ID for thread-specific, random for anonymous
|
||||
sandbox_id = self._sandbox_id_for_thread(thread_id)
|
||||
|
||||
# ── Layer 1.5: Warm pool (container still running, no cold-start) ──
|
||||
reclaimed_id = self._reclaim_warm_pool_sandbox(thread_id, sandbox_id)
|
||||
if reclaimed_id is not None:
|
||||
return reclaimed_id
|
||||
|
||||
# ── Layer 2: Backend discovery + create (protected by cross-process lock) ──
|
||||
if thread_id:
|
||||
return await self._discover_or_create_with_lock_async(thread_id, sandbox_id)
|
||||
|
||||
return await self._create_sandbox_async(thread_id, sandbox_id)
|
||||
|
||||
def _discover_or_create_with_lock(self, thread_id: str, sandbox_id: str) -> str:
|
||||
"""Discover an existing sandbox or create a new one under a cross-process file lock.
|
||||
|
||||
@ -503,40 +653,50 @@ class AioSandboxProvider(SandboxProvider):
|
||||
locked = True
|
||||
# Re-check in-process caches under the file lock in case another
|
||||
# thread in this process won the race while we were waiting.
|
||||
with self._lock:
|
||||
if thread_id in self._thread_sandboxes:
|
||||
existing_id = self._thread_sandboxes[thread_id]
|
||||
if existing_id in self._sandboxes:
|
||||
logger.info(f"Reusing in-process sandbox {existing_id} for thread {thread_id} (post-lock check)")
|
||||
self._last_activity[existing_id] = time.time()
|
||||
return existing_id
|
||||
if sandbox_id in self._warm_pool:
|
||||
info, _ = self._warm_pool.pop(sandbox_id)
|
||||
sandbox = AioSandbox(id=sandbox_id, base_url=info.sandbox_url)
|
||||
self._sandboxes[sandbox_id] = sandbox
|
||||
self._sandbox_infos[sandbox_id] = info
|
||||
self._last_activity[sandbox_id] = time.time()
|
||||
self._thread_sandboxes[thread_id] = sandbox_id
|
||||
logger.info(f"Reclaimed warm-pool sandbox {sandbox_id} for thread {thread_id} (post-lock check)")
|
||||
return sandbox_id
|
||||
cached_id = self._recheck_cached_sandbox(thread_id, sandbox_id)
|
||||
if cached_id is not None:
|
||||
return cached_id
|
||||
|
||||
# Backend discovery: another process may have created the container.
|
||||
discovered = self._backend.discover(sandbox_id)
|
||||
if discovered is not None:
|
||||
sandbox = AioSandbox(id=discovered.sandbox_id, base_url=discovered.sandbox_url)
|
||||
with self._lock:
|
||||
self._sandboxes[discovered.sandbox_id] = sandbox
|
||||
self._sandbox_infos[discovered.sandbox_id] = discovered
|
||||
self._last_activity[discovered.sandbox_id] = time.time()
|
||||
self._thread_sandboxes[thread_id] = discovered.sandbox_id
|
||||
logger.info(f"Discovered existing sandbox {discovered.sandbox_id} for thread {thread_id} at {discovered.sandbox_url}")
|
||||
return discovered.sandbox_id
|
||||
return self._register_discovered_sandbox(thread_id, discovered)
|
||||
|
||||
return self._create_sandbox(thread_id, sandbox_id)
|
||||
finally:
|
||||
if locked:
|
||||
_unlock_file(lock_file)
|
||||
|
||||
async def _discover_or_create_with_lock_async(self, thread_id: str, sandbox_id: str) -> str:
|
||||
"""Async counterpart to ``_discover_or_create_with_lock``."""
|
||||
paths = get_paths()
|
||||
user_id = get_effective_user_id()
|
||||
await asyncio.to_thread(paths.ensure_thread_dirs, thread_id, user_id=user_id)
|
||||
lock_path = paths.thread_dir(thread_id, user_id=user_id) / f"{sandbox_id}.lock"
|
||||
|
||||
lock_file = await asyncio.to_thread(_open_lock_file, lock_path)
|
||||
locked = False
|
||||
try:
|
||||
await asyncio.to_thread(_lock_file_exclusive, lock_file)
|
||||
locked = True
|
||||
# Re-check in-process caches under the file lock in case another
|
||||
# thread in this process won the race while we were waiting.
|
||||
cached_id = self._recheck_cached_sandbox(thread_id, sandbox_id)
|
||||
if cached_id is not None:
|
||||
return cached_id
|
||||
|
||||
# Backend discovery is sync because local discovery may inspect
|
||||
# Docker and perform a health check; keep it off the event loop.
|
||||
discovered = await asyncio.to_thread(self._backend.discover, sandbox_id)
|
||||
if discovered is not None:
|
||||
return self._register_discovered_sandbox(thread_id, discovered)
|
||||
|
||||
return await self._create_sandbox_async(thread_id, sandbox_id)
|
||||
finally:
|
||||
if locked:
|
||||
await asyncio.to_thread(_unlock_file, lock_file)
|
||||
await asyncio.to_thread(lock_file.close)
|
||||
|
||||
def _evict_oldest_warm(self) -> str | None:
|
||||
"""Destroy the oldest container in the warm pool to free capacity.
|
||||
|
||||
@ -574,18 +734,10 @@ class AioSandboxProvider(SandboxProvider):
|
||||
|
||||
# Enforce replicas: only warm-pool containers count toward eviction budget.
|
||||
# Active sandboxes are in use by live threads and must not be forcibly stopped.
|
||||
replicas = self._config.get("replicas", DEFAULT_REPLICAS)
|
||||
with self._lock:
|
||||
total = len(self._sandboxes) + len(self._warm_pool)
|
||||
replicas, total = self._replica_count()
|
||||
if total >= replicas:
|
||||
evicted = self._evict_oldest_warm()
|
||||
if evicted:
|
||||
logger.info(f"Evicted warm-pool sandbox {evicted} to stay within replicas={replicas}")
|
||||
else:
|
||||
# All slots are occupied by active sandboxes — proceed anyway and log.
|
||||
# The replicas limit is a soft cap; we never forcibly stop a container
|
||||
# that is actively serving a thread.
|
||||
logger.warning(f"All {replicas} replica slots are in active use; creating sandbox {sandbox_id} beyond the soft limit")
|
||||
self._log_replicas_soft_cap(replicas, sandbox_id, evicted)
|
||||
|
||||
info = self._backend.create(thread_id, sandbox_id, extra_mounts=extra_mounts or None)
|
||||
|
||||
@ -594,16 +746,27 @@ class AioSandboxProvider(SandboxProvider):
|
||||
self._backend.destroy(info)
|
||||
raise RuntimeError(f"Sandbox {sandbox_id} failed to become ready within timeout at {info.sandbox_url}")
|
||||
|
||||
sandbox = AioSandbox(id=sandbox_id, base_url=info.sandbox_url)
|
||||
with self._lock:
|
||||
self._sandboxes[sandbox_id] = sandbox
|
||||
self._sandbox_infos[sandbox_id] = info
|
||||
self._last_activity[sandbox_id] = time.time()
|
||||
if thread_id:
|
||||
self._thread_sandboxes[thread_id] = sandbox_id
|
||||
return self._register_created_sandbox(thread_id, sandbox_id, info)
|
||||
|
||||
logger.info(f"Created sandbox {sandbox_id} for thread {thread_id} at {info.sandbox_url}")
|
||||
return sandbox_id
|
||||
async def _create_sandbox_async(self, thread_id: str | None, sandbox_id: str) -> str:
|
||||
"""Async counterpart to ``_create_sandbox``."""
|
||||
extra_mounts = await asyncio.to_thread(self._get_extra_mounts, thread_id)
|
||||
|
||||
# Enforce replicas: only warm-pool containers count toward eviction budget.
|
||||
# Active sandboxes are in use by live threads and must not be forcibly stopped.
|
||||
replicas, total = self._replica_count()
|
||||
if total >= replicas:
|
||||
evicted = await asyncio.to_thread(self._evict_oldest_warm)
|
||||
self._log_replicas_soft_cap(replicas, sandbox_id, evicted)
|
||||
|
||||
info = await asyncio.to_thread(self._backend.create, thread_id, sandbox_id, extra_mounts=extra_mounts or None)
|
||||
|
||||
# Wait for sandbox to be ready without blocking the event loop.
|
||||
if not await wait_for_sandbox_ready_async(info.sandbox_url, timeout=60):
|
||||
await asyncio.to_thread(self._backend.destroy, info)
|
||||
raise RuntimeError(f"Sandbox {sandbox_id} failed to become ready within timeout at {info.sandbox_url}")
|
||||
|
||||
return self._register_created_sandbox(thread_id, sandbox_id, info)
|
||||
|
||||
def get(self, sandbox_id: str) -> Sandbox | None:
|
||||
"""Get a sandbox by ID. Updates last activity timestamp.
|
||||
@ -627,14 +790,20 @@ class AioSandboxProvider(SandboxProvider):
|
||||
thread on its next turn without a cold-start. The container will only be
|
||||
stopped when the replicas limit forces eviction or during shutdown.
|
||||
|
||||
The host-side HTTP client owned by the cached ``AioSandbox`` instance is
|
||||
closed before the instance is dropped (#2872). The warm-pool entry only
|
||||
stores ``SandboxInfo``, so a fresh ``AioSandbox`` (and a fresh client)
|
||||
is constructed if the container is later reclaimed.
|
||||
|
||||
Args:
|
||||
sandbox_id: The ID of the sandbox to release.
|
||||
"""
|
||||
info = None
|
||||
sandbox = None
|
||||
thread_ids_to_remove: list[str] = []
|
||||
|
||||
with self._lock:
|
||||
self._sandboxes.pop(sandbox_id, None)
|
||||
sandbox = self._sandboxes.pop(sandbox_id, None)
|
||||
info = self._sandbox_infos.pop(sandbox_id, None)
|
||||
thread_ids_to_remove = [tid for tid, sid in self._thread_sandboxes.items() if sid == sandbox_id]
|
||||
for tid in thread_ids_to_remove:
|
||||
@ -644,6 +813,15 @@ class AioSandboxProvider(SandboxProvider):
|
||||
if info and sandbox_id not in self._warm_pool:
|
||||
self._warm_pool[sandbox_id] = (info, time.time())
|
||||
|
||||
if sandbox is not None:
|
||||
# Defense-in-depth: close() already swallows its own errors; this
|
||||
# guard only protects against a future close() that misbehaves, so
|
||||
# host-side client cleanup can never block parking in the warm pool.
|
||||
try:
|
||||
sandbox.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing sandbox {sandbox_id} during release: {e}")
|
||||
|
||||
logger.info(f"Released sandbox {sandbox_id} to warm pool (container still running)")
|
||||
|
||||
def destroy(self, sandbox_id: str) -> None:
|
||||
@ -652,14 +830,19 @@ class AioSandboxProvider(SandboxProvider):
|
||||
Unlike release(), this actually stops the container. Use this for
|
||||
explicit cleanup, capacity-driven eviction, or shutdown.
|
||||
|
||||
The host-side HTTP client owned by the cached ``AioSandbox`` instance is
|
||||
closed alongside backend/container destruction so no client/socket
|
||||
resources leak (#2872).
|
||||
|
||||
Args:
|
||||
sandbox_id: The ID of the sandbox to destroy.
|
||||
"""
|
||||
info = None
|
||||
sandbox = None
|
||||
thread_ids_to_remove: list[str] = []
|
||||
|
||||
with self._lock:
|
||||
self._sandboxes.pop(sandbox_id, None)
|
||||
sandbox = self._sandboxes.pop(sandbox_id, None)
|
||||
info = self._sandbox_infos.pop(sandbox_id, None)
|
||||
thread_ids_to_remove = [tid for tid, sid in self._thread_sandboxes.items() if sid == sandbox_id]
|
||||
for tid in thread_ids_to_remove:
|
||||
@ -671,6 +854,15 @@ class AioSandboxProvider(SandboxProvider):
|
||||
else:
|
||||
self._warm_pool.pop(sandbox_id, None)
|
||||
|
||||
if sandbox is not None:
|
||||
# Defense-in-depth: close() already swallows its own errors; this
|
||||
# guard only protects against a future close() that misbehaves, so
|
||||
# host-side client cleanup can never block container destruction.
|
||||
try:
|
||||
sandbox.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing sandbox {sandbox_id} during destroy: {e}")
|
||||
|
||||
if info:
|
||||
self._backend.destroy(info)
|
||||
logger.info(f"Destroyed sandbox {sandbox_id}")
|
||||
|
||||
@ -2,10 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import httpx
|
||||
import requests
|
||||
|
||||
from .sandbox_info import SandboxInfo
|
||||
@ -35,6 +37,34 @@ def wait_for_sandbox_ready(sandbox_url: str, timeout: int = 30) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def wait_for_sandbox_ready_async(sandbox_url: str, timeout: int = 30, poll_interval: float = 1.0) -> bool:
|
||||
"""Async variant of sandbox readiness polling.
|
||||
|
||||
Use this from async runtime paths so sandbox startup waits do not block the
|
||||
event loop. The synchronous ``wait_for_sandbox_ready`` function remains for
|
||||
existing synchronous backend/provider call sites.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
while True:
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
response = await client.get(f"{sandbox_url}/v1/sandbox", timeout=min(5.0, remaining))
|
||||
if response.status_code == 200:
|
||||
return True
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
await asyncio.sleep(min(poll_interval, remaining))
|
||||
return False
|
||||
|
||||
|
||||
class SandboxBackend(ABC):
|
||||
"""Abstract base for sandbox provisioning backends.
|
||||
|
||||
@ -44,7 +74,7 @@ class SandboxBackend(ABC):
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def create(self, thread_id: str, sandbox_id: str, extra_mounts: list[tuple[str, str, bool]] | None = None) -> SandboxInfo:
|
||||
def create(self, thread_id: str | None, sandbox_id: str, extra_mounts: list[tuple[str, str, bool]] | None = None) -> SandboxInfo:
|
||||
"""Create/provision a new sandbox.
|
||||
|
||||
Args:
|
||||
|
||||
@ -241,7 +241,7 @@ class LocalContainerBackend(SandboxBackend):
|
||||
|
||||
# ── SandboxBackend interface ──────────────────────────────────────────
|
||||
|
||||
def create(self, thread_id: str, sandbox_id: str, extra_mounts: list[tuple[str, str, bool]] | None = None) -> SandboxInfo:
|
||||
def create(self, thread_id: str | None, sandbox_id: str, extra_mounts: list[tuple[str, str, bool]] | None = None) -> SandboxInfo:
|
||||
"""Start a new container and return its connection info.
|
||||
|
||||
Args:
|
||||
|
||||
@ -59,7 +59,7 @@ class RemoteSandboxBackend(SandboxBackend):
|
||||
|
||||
def create(
|
||||
self,
|
||||
thread_id: str,
|
||||
thread_id: str | None,
|
||||
sandbox_id: str,
|
||||
extra_mounts: list[tuple[str, str, bool]] | None = None,
|
||||
) -> SandboxInfo:
|
||||
@ -132,7 +132,7 @@ class RemoteSandboxBackend(SandboxBackend):
|
||||
logger.warning("Provisioner list_running failed: %s", exc)
|
||||
return []
|
||||
|
||||
def _provisioner_create(self, thread_id: str, sandbox_id: str, extra_mounts: list[tuple[str, str, bool]] | None = None) -> SandboxInfo:
|
||||
def _provisioner_create(self, thread_id: str | None, sandbox_id: str, extra_mounts: list[tuple[str, str, bool]] | None = None) -> SandboxInfo:
|
||||
"""POST /api/sandboxes → create Pod + Service."""
|
||||
try:
|
||||
resp = requests.post(
|
||||
|
||||
@ -11,12 +11,85 @@ from deerflow.config import get_app_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_BACKEND = "auto"
|
||||
DEFAULT_REGION = "wt-wt"
|
||||
DEFAULT_SAFESEARCH = "moderate"
|
||||
DEFAULT_WIKIPEDIA_REGION = "us-en"
|
||||
|
||||
WIKIPEDIA_BACKENDS = {"auto", "all", "wikipedia"}
|
||||
WIKIPEDIA_LANGUAGE_ALIASES = {
|
||||
"jp": "ja",
|
||||
"kr": "ko",
|
||||
"tzh": "zh",
|
||||
"wt": "en",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_backend(backend: str | list[str] | tuple[str, ...] | None) -> str:
|
||||
if backend is None:
|
||||
return DEFAULT_BACKEND
|
||||
if isinstance(backend, (list, tuple)):
|
||||
return ",".join(str(part).strip() for part in backend if str(part).strip()) or DEFAULT_BACKEND
|
||||
return str(backend).strip() or DEFAULT_BACKEND
|
||||
|
||||
|
||||
def _normalize_setting(value: str | None, default: str) -> str:
|
||||
return str(value).strip() if value else default
|
||||
|
||||
|
||||
def _backend_includes_wikipedia(backend: str | list[str] | tuple[str, ...] | None) -> bool:
|
||||
backend = _normalize_backend(backend)
|
||||
return any(part.strip().lower() in WIKIPEDIA_BACKENDS for part in backend.split(","))
|
||||
|
||||
|
||||
def _contains_codepoint(query: str, ranges: tuple[tuple[int, int], ...]) -> bool:
|
||||
return any(start <= ord(char) <= end for char in query for start, end in ranges)
|
||||
|
||||
|
||||
def _infer_wikipedia_region(query: str) -> str:
|
||||
"""Pick a valid Wikipedia language region when DDGS' worldwide region is used."""
|
||||
if _contains_codepoint(query, ((0x3040, 0x30FF), (0x31F0, 0x31FF))):
|
||||
return "jp-ja"
|
||||
if _contains_codepoint(query, ((0xAC00, 0xD7AF), (0x1100, 0x11FF), (0x3130, 0x318F))):
|
||||
return "kr-ko"
|
||||
if _contains_codepoint(query, ((0x3400, 0x9FFF),)):
|
||||
return "cn-zh"
|
||||
if _contains_codepoint(query, ((0x0400, 0x04FF),)):
|
||||
return "ru-ru"
|
||||
if _contains_codepoint(query, ((0x0370, 0x03FF),)):
|
||||
return "gr-el"
|
||||
if _contains_codepoint(query, ((0x0590, 0x05FF),)):
|
||||
return "il-he"
|
||||
if _contains_codepoint(query, ((0x0600, 0x06FF),)):
|
||||
return "xa-ar"
|
||||
return DEFAULT_WIKIPEDIA_REGION
|
||||
|
||||
|
||||
def _resolve_ddgs_region(query: str, region: str | None, backend: str | list[str] | tuple[str, ...] | None) -> str:
|
||||
"""
|
||||
DDGS' wikipedia engine treats the second part of region as a Wikipedia
|
||||
subdomain. Its default worldwide region, wt-wt, becomes wt.wikipedia.org.
|
||||
"""
|
||||
normalized_region = _normalize_setting(region, DEFAULT_REGION).lower()
|
||||
if not _backend_includes_wikipedia(backend):
|
||||
return normalized_region
|
||||
|
||||
if normalized_region == DEFAULT_REGION:
|
||||
return _infer_wikipedia_region(query)
|
||||
|
||||
if "-" not in normalized_region:
|
||||
return DEFAULT_WIKIPEDIA_REGION
|
||||
|
||||
country, language = normalized_region.split("-", 1)
|
||||
return f"{country}-{WIKIPEDIA_LANGUAGE_ALIASES.get(language, language)}"
|
||||
|
||||
|
||||
def _search_text(
|
||||
query: str,
|
||||
max_results: int = 5,
|
||||
region: str = "wt-wt",
|
||||
safesearch: str = "moderate",
|
||||
region: str | None = DEFAULT_REGION,
|
||||
safesearch: str | None = DEFAULT_SAFESEARCH,
|
||||
backend: str | list[str] | tuple[str, ...] | None = DEFAULT_BACKEND,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Execute text search using DuckDuckGo.
|
||||
@ -26,6 +99,7 @@ def _search_text(
|
||||
max_results: Maximum number of results
|
||||
region: Search region
|
||||
safesearch: Safe search level
|
||||
backend: DDGS backend(s), e.g. "auto", "duckduckgo", or "duckduckgo,brave"
|
||||
|
||||
Returns:
|
||||
List of search results
|
||||
@ -39,11 +113,15 @@ def _search_text(
|
||||
ddgs = DDGS(timeout=30)
|
||||
|
||||
try:
|
||||
backend = _normalize_backend(backend)
|
||||
safesearch = _normalize_setting(safesearch, DEFAULT_SAFESEARCH)
|
||||
effective_region = _resolve_ddgs_region(query, region, backend)
|
||||
results = ddgs.text(
|
||||
query,
|
||||
region=region,
|
||||
region=effective_region,
|
||||
safesearch=safesearch,
|
||||
max_results=max_results,
|
||||
backend=backend,
|
||||
)
|
||||
return list(results) if results else []
|
||||
|
||||
@ -64,14 +142,23 @@ def web_search_tool(
|
||||
max_results: Maximum number of results to return. Default is 5.
|
||||
"""
|
||||
config = get_app_config().get_tool_config("web_search")
|
||||
region = DEFAULT_REGION
|
||||
safesearch = DEFAULT_SAFESEARCH
|
||||
backend = DEFAULT_BACKEND
|
||||
|
||||
# Override max_results from config if set
|
||||
if config is not None and "max_results" in config.model_extra:
|
||||
if config is not None:
|
||||
# Override tool call defaults from config if set.
|
||||
max_results = config.model_extra.get("max_results", max_results)
|
||||
region = config.model_extra.get("region", region)
|
||||
safesearch = config.model_extra.get("safesearch", safesearch)
|
||||
backend = config.model_extra.get("backend", backend)
|
||||
|
||||
results = _search_text(
|
||||
query=query,
|
||||
max_results=max_results,
|
||||
region=region,
|
||||
safesearch=safesearch,
|
||||
backend=backend,
|
||||
)
|
||||
|
||||
if not results:
|
||||
|
||||
@ -9,7 +9,7 @@ _api_key_warned = False
|
||||
|
||||
|
||||
class JinaClient:
|
||||
async def crawl(self, url: str, return_format: str = "html", timeout: int = 10) -> str:
|
||||
async def crawl(self, url: str, return_format: str = "html", timeout: int = 10, proxy: str | None = None, trust_env: bool = True) -> str:
|
||||
global _api_key_warned
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
@ -23,7 +23,10 @@ class JinaClient:
|
||||
logger.warning("Jina API key is not set. Provide your own key to access a higher rate limit. See https://jina.ai/reader for more information.")
|
||||
data = {"url": url}
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
client_kwargs: dict[str, object] = {"trust_env": trust_env}
|
||||
if proxy:
|
||||
client_kwargs["proxy"] = proxy
|
||||
async with httpx.AsyncClient(**client_kwargs) as client:
|
||||
response = await client.post("https://r.jina.ai/", headers=headers, json=data, timeout=timeout)
|
||||
|
||||
if response.status_code != 200:
|
||||
|
||||
@ -9,6 +9,38 @@ from deerflow.utils.readability import ReadabilityExtractor
|
||||
readability_extractor = ReadabilityExtractor()
|
||||
|
||||
|
||||
def _coerce_bool(value: object, default: bool) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _coerce_timeout(value: object, default: int) -> int:
|
||||
if isinstance(value, bool):
|
||||
return default
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def _coerce_proxy(value: object) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
proxy = value.strip()
|
||||
return proxy or None
|
||||
|
||||
|
||||
@tool("web_fetch", parse_docstring=True)
|
||||
async def web_fetch_tool(url: str) -> str:
|
||||
"""Fetch the contents of a web page at a given URL.
|
||||
@ -22,10 +54,14 @@ async def web_fetch_tool(url: str) -> str:
|
||||
"""
|
||||
jina_client = JinaClient()
|
||||
timeout = 10
|
||||
proxy = None
|
||||
trust_env = True
|
||||
config = get_app_config().get_tool_config("web_fetch")
|
||||
if config is not None and "timeout" in config.model_extra:
|
||||
timeout = config.model_extra.get("timeout")
|
||||
html_content = await jina_client.crawl(url, return_format="html", timeout=timeout)
|
||||
if config is not None:
|
||||
timeout = _coerce_timeout(config.model_extra.get("timeout"), timeout)
|
||||
proxy = _coerce_proxy(config.model_extra.get("proxy"))
|
||||
trust_env = _coerce_bool(config.model_extra.get("trust_env"), trust_env)
|
||||
html_content = await jina_client.crawl(url, return_format="html", timeout=timeout, proxy=proxy, trust_env=trust_env)
|
||||
if isinstance(html_content, str) and html_content.startswith("Error:"):
|
||||
return html_content
|
||||
article = await asyncio.to_thread(readability_extractor.extract_article, html_content)
|
||||
|
||||
@ -7,7 +7,7 @@ from typing import Any, Self
|
||||
|
||||
import yaml
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from deerflow.config.acp_config import ACPAgentConfig, load_acp_config_from_dict
|
||||
from deerflow.config.agents_api_config import AgentsApiConfig, load_agents_api_config_from_dict
|
||||
@ -18,8 +18,10 @@ from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_
|
||||
from deerflow.config.loop_detection_config import LoopDetectionConfig
|
||||
from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict
|
||||
from deerflow.config.model_config import ModelConfig
|
||||
from deerflow.config.reload_boundary import format_field_description
|
||||
from deerflow.config.run_events_config import RunEventsConfig
|
||||
from deerflow.config.runtime_paths import existing_project_file
|
||||
from deerflow.config.safety_finish_reason_config import SafetyFinishReasonConfig
|
||||
from deerflow.config.sandbox_config import SandboxConfig
|
||||
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
|
||||
from deerflow.config.skills_config import SkillsConfig
|
||||
@ -29,6 +31,7 @@ from deerflow.config.summarization_config import SummarizationConfig, load_summa
|
||||
from deerflow.config.title_config import TitleConfig, load_title_config_from_dict
|
||||
from deerflow.config.token_usage_config import TokenUsageConfig
|
||||
from deerflow.config.tool_config import ToolConfig, ToolGroupConfig
|
||||
from deerflow.config.tool_output_config import ToolOutputConfig
|
||||
from deerflow.config.tool_search_config import ToolSearchConfig, load_tool_search_config_from_dict
|
||||
|
||||
load_dotenv()
|
||||
@ -83,15 +86,27 @@ def apply_logging_level(name: str | None) -> None:
|
||||
class AppConfig(BaseModel):
|
||||
"""Config for the DeerFlow application"""
|
||||
|
||||
log_level: str = Field(default="info", description="Logging level for deerflow and app modules (debug/info/warning/error); third-party libraries are not affected")
|
||||
log_level: str = Field(
|
||||
default="info",
|
||||
description=format_field_description(
|
||||
"log_level",
|
||||
field_doc="Logging level for deerflow and app modules (debug/info/warning/error); third-party libraries are not affected.",
|
||||
),
|
||||
)
|
||||
token_usage: TokenUsageConfig = Field(default_factory=TokenUsageConfig, description="Token usage tracking configuration")
|
||||
models: list[ModelConfig] = Field(default_factory=list, description="Available models")
|
||||
sandbox: SandboxConfig = Field(description="Sandbox configuration")
|
||||
sandbox: SandboxConfig = Field(
|
||||
description=format_field_description(
|
||||
"sandbox",
|
||||
field_doc="Sandbox provider configuration (local filesystem or Docker-based aio sandbox).",
|
||||
),
|
||||
)
|
||||
tools: list[ToolConfig] = Field(default_factory=list, description="Available tools")
|
||||
tool_groups: list[ToolGroupConfig] = Field(default_factory=list, description="Available tool groups")
|
||||
skills: SkillsConfig = Field(default_factory=SkillsConfig, description="Skills configuration")
|
||||
skill_evolution: SkillEvolutionConfig = Field(default_factory=SkillEvolutionConfig, description="Agent-managed skill evolution configuration")
|
||||
extensions: ExtensionsConfig = Field(default_factory=ExtensionsConfig, description="Extensions configuration (MCP servers and skills state)")
|
||||
tool_output: ToolOutputConfig = Field(default_factory=ToolOutputConfig, description="Tool output budget protection configuration")
|
||||
tool_search: ToolSearchConfig = Field(default_factory=ToolSearchConfig, description="Tool search / deferred loading configuration")
|
||||
title: TitleConfig = Field(default_factory=TitleConfig, description="Automatic title generation configuration")
|
||||
summarization: SummarizationConfig = Field(default_factory=SummarizationConfig, description="Conversation summarization configuration")
|
||||
@ -102,11 +117,51 @@ class AppConfig(BaseModel):
|
||||
guardrails: GuardrailsConfig = Field(default_factory=GuardrailsConfig, description="Guardrail middleware configuration")
|
||||
circuit_breaker: CircuitBreakerConfig = Field(default_factory=CircuitBreakerConfig, description="LLM circuit breaker configuration")
|
||||
loop_detection: LoopDetectionConfig = Field(default_factory=LoopDetectionConfig, description="Loop detection middleware configuration")
|
||||
safety_finish_reason: SafetyFinishReasonConfig = Field(default_factory=SafetyFinishReasonConfig, description="Provider safety-filter finish_reason interception middleware configuration")
|
||||
model_config = ConfigDict(extra="allow")
|
||||
database: DatabaseConfig = Field(default_factory=DatabaseConfig, description="Unified database backend configuration")
|
||||
run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration")
|
||||
checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration")
|
||||
stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration")
|
||||
database: DatabaseConfig = Field(
|
||||
default_factory=DatabaseConfig,
|
||||
description=format_field_description(
|
||||
"database",
|
||||
field_doc="Unified database backend for run/feedback metadata (memory, sqlite, or postgres).",
|
||||
),
|
||||
)
|
||||
run_events: RunEventsConfig = Field(
|
||||
default_factory=RunEventsConfig,
|
||||
description=format_field_description(
|
||||
"run_events",
|
||||
field_doc="Run-event store backend (memory for dev, db for production queries, jsonl for lightweight single-node persistence).",
|
||||
),
|
||||
)
|
||||
checkpointer: CheckpointerConfig | None = Field(
|
||||
default=None,
|
||||
description=format_field_description(
|
||||
"checkpointer",
|
||||
field_doc="LangGraph state-persistence checkpointer configuration.",
|
||||
),
|
||||
)
|
||||
stream_bridge: StreamBridgeConfig | None = Field(
|
||||
default=None,
|
||||
description=format_field_description(
|
||||
"stream_bridge",
|
||||
field_doc="Stream bridge connecting agent workers to SSE endpoints.",
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("models", "tools", "tool_groups", mode="before")
|
||||
@classmethod
|
||||
def _coerce_null_list_sections(cls, value: Any) -> Any:
|
||||
"""Treat a present-but-empty config section as an empty list.
|
||||
|
||||
Commenting out every entry under a top-level YAML key — e.g. ``models:``
|
||||
with only comments beneath it, exactly as shipped in
|
||||
``config.example.yaml`` — makes PyYAML parse the value as ``None``.
|
||||
Without this, the documented ``cp config.example.yaml config.yaml``
|
||||
first-run flow crashes with an opaque ``Input should be a valid list``
|
||||
pydantic error. Coercing ``None`` to ``[]`` keeps that flow working and
|
||||
matches the field's own ``default_factory=list``.
|
||||
"""
|
||||
return [] if value is None else value
|
||||
|
||||
@classmethod
|
||||
def resolve_config_path(cls, config_path: str | None = None) -> Path:
|
||||
@ -169,6 +224,11 @@ class AppConfig(BaseModel):
|
||||
config_data["extensions"] = extensions_config.model_dump()
|
||||
|
||||
result = cls.model_validate(config_data)
|
||||
if not result.models:
|
||||
logger.warning(
|
||||
"No models are configured in %s. Add at least one entry under `models:` (see the commented examples in config.example.yaml) or run `make setup`.",
|
||||
resolved_path,
|
||||
)
|
||||
acp_agents = cls._validate_acp_agents(config_data.get("acp_agents", {}))
|
||||
cls._apply_singleton_configs(result, acp_agents)
|
||||
return result
|
||||
|
||||
@ -41,6 +41,20 @@ def set_checkpointer_config(config: CheckpointerConfig | None) -> None:
|
||||
_checkpointer_config = config
|
||||
|
||||
|
||||
def ensure_config_loaded() -> None:
|
||||
"""Lazily load app config when checkpointer config has not been initialized."""
|
||||
from deerflow.config.app_config import _app_config, get_app_config
|
||||
|
||||
config = get_checkpointer_config()
|
||||
if config is not None or _app_config is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
get_app_config()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def load_checkpointer_config_from_dict(config_dict: dict | None) -> None:
|
||||
"""Load checkpointer configuration from a dictionary."""
|
||||
global _checkpointer_config
|
||||
|
||||
@ -5,7 +5,7 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from deerflow.config.runtime_paths import existing_project_file
|
||||
|
||||
@ -47,6 +47,24 @@ class McpServerConfig(BaseModel):
|
||||
description: str = Field(default="", description="Human-readable description of what this MCP server provides")
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _accept_transport_alias(cls, data: Any) -> Any:
|
||||
"""Accept the MCP-spec ``transport`` field as an alias for ``type``.
|
||||
|
||||
The official MCP configuration schema uses ``transport`` to indicate
|
||||
the transport mechanism (``stdio``/``sse``/``http``). Earlier versions
|
||||
of this project only honored ``type``, which caused remote SSE/HTTP
|
||||
servers configured with just ``transport`` to be incorrectly treated as
|
||||
``stdio`` (the default). This validator normalizes the two so either
|
||||
spelling works, with ``type`` taking precedence when both are provided.
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
transport = data.get("transport")
|
||||
if transport and not data.get("type"):
|
||||
data = {**data, "type": transport}
|
||||
return data
|
||||
|
||||
|
||||
class SkillStateConfig(BaseModel):
|
||||
"""Configuration for a single skill's state."""
|
||||
@ -141,7 +159,7 @@ class ExtensionsConfig(BaseModel):
|
||||
try:
|
||||
with open(resolved_path, encoding="utf-8") as f:
|
||||
config_data = json.load(f)
|
||||
cls.resolve_env_variables(config_data)
|
||||
config_data = cls.resolve_env_variables(config_data)
|
||||
return cls.model_validate(config_data)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Extensions config file at {resolved_path} is not valid JSON: {e}") from e
|
||||
@ -149,7 +167,7 @@ class ExtensionsConfig(BaseModel):
|
||||
raise RuntimeError(f"Failed to load extensions config from {resolved_path}: {e}") from e
|
||||
|
||||
@classmethod
|
||||
def resolve_env_variables(cls, config: dict[str, Any]) -> dict[str, Any]:
|
||||
def resolve_env_variables(cls, config: Any) -> Any:
|
||||
"""Recursively resolve environment variables in the config.
|
||||
|
||||
Environment variables are resolved using the `os.getenv` function. Example: $OPENAI_API_KEY
|
||||
@ -160,23 +178,26 @@ class ExtensionsConfig(BaseModel):
|
||||
Returns:
|
||||
The config with environment variables resolved.
|
||||
"""
|
||||
for key, value in config.items():
|
||||
if isinstance(value, str):
|
||||
if value.startswith("$"):
|
||||
env_value = os.getenv(value[1:])
|
||||
if env_value is None:
|
||||
# Unresolved placeholder — store empty string so downstream
|
||||
# consumers (e.g. MCP servers) don't receive the literal "$VAR"
|
||||
# token as an actual environment value.
|
||||
config[key] = ""
|
||||
else:
|
||||
config[key] = env_value
|
||||
else:
|
||||
config[key] = value
|
||||
elif isinstance(value, dict):
|
||||
config[key] = cls.resolve_env_variables(value)
|
||||
elif isinstance(value, list):
|
||||
config[key] = [cls.resolve_env_variables(item) if isinstance(item, dict) else item for item in value]
|
||||
if isinstance(config, str):
|
||||
if not config.startswith("$"):
|
||||
return config
|
||||
env_value = os.getenv(config[1:])
|
||||
if env_value is None:
|
||||
# Unresolved placeholder — store empty string so downstream
|
||||
# consumers (e.g. MCP servers) don't receive the literal "$VAR"
|
||||
# token as an actual environment value.
|
||||
return ""
|
||||
return env_value
|
||||
|
||||
if isinstance(config, dict):
|
||||
return {key: cls.resolve_env_variables(value) for key, value in config.items()}
|
||||
|
||||
if isinstance(config, list):
|
||||
return [cls.resolve_env_variables(item) for item in config]
|
||||
|
||||
if isinstance(config, tuple):
|
||||
return tuple(cls.resolve_env_variables(item) for item in config)
|
||||
|
||||
return config
|
||||
|
||||
def get_enabled_mcp_servers(self) -> dict[str, McpServerConfig]:
|
||||
|
||||
@ -32,6 +32,16 @@ class ModelConfig(BaseModel):
|
||||
description="Extra settings to be passed to the model when thinking is disabled",
|
||||
)
|
||||
supports_vision: bool = Field(default_factory=lambda: False, description="Whether the model supports vision/image inputs")
|
||||
stream_chunk_timeout: float | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Maximum seconds to wait between successive streaming chunks before "
|
||||
"langchain-openai raises StreamChunkTimeoutError. None means use the "
|
||||
"factory default (240s for OpenAI-compatible clients). Tune higher for "
|
||||
"reasoning models with long thinking pauses; lower for latency-sensitive "
|
||||
"interactive endpoints. Has no effect on non-OpenAI-compatible providers."
|
||||
),
|
||||
)
|
||||
thinking: dict | None = Field(
|
||||
default_factory=lambda: None,
|
||||
description=(
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
@ -10,6 +11,8 @@ VIRTUAL_PATH_PREFIX = "/mnt/user-data"
|
||||
|
||||
_SAFE_THREAD_ID_RE = re.compile(r"^[A-Za-z0-9_\-]+$")
|
||||
_SAFE_USER_ID_RE = re.compile(r"^[A-Za-z0-9_\-]+$")
|
||||
_UNSAFE_USER_ID_CHAR_RE = re.compile(r"[^A-Za-z0-9_\-]")
|
||||
_SAFE_USER_ID_DIGEST_HEX_LEN = 16
|
||||
|
||||
|
||||
def _default_local_base_dir() -> Path:
|
||||
@ -31,6 +34,23 @@ def _validate_user_id(user_id: str) -> str:
|
||||
return user_id
|
||||
|
||||
|
||||
def make_safe_user_id(raw: str) -> str:
|
||||
"""Normalize an external identity into the user-id charset (``[A-Za-z0-9_-]``).
|
||||
|
||||
IM channel ids (Feishu/Slack/Telegram) may contain characters that
|
||||
:func:`_validate_user_id` rejects. Already-safe ids pass through unchanged;
|
||||
lossy ones get a short digest suffix so two distinct inputs never share a
|
||||
storage bucket.
|
||||
"""
|
||||
if not raw:
|
||||
raise ValueError("user_id must be a non-empty string.")
|
||||
sanitized = _UNSAFE_USER_ID_CHAR_RE.sub("-", raw)
|
||||
if sanitized == raw:
|
||||
return raw
|
||||
digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:_SAFE_USER_ID_DIGEST_HEX_LEN]
|
||||
return f"{sanitized}-{digest}"
|
||||
|
||||
|
||||
def _join_host_path(base: str, *parts: str) -> str:
|
||||
"""Join host filesystem path segments while preserving native style.
|
||||
|
||||
|
||||
104
backend/packages/harness/deerflow/config/reload_boundary.py
Normal file
104
backend/packages/harness/deerflow/config/reload_boundary.py
Normal file
@ -0,0 +1,104 @@
|
||||
"""Single source of truth for the config hot-reload boundary.
|
||||
|
||||
Bytedance/deer-flow issue #3144: gateway request dependencies resolve
|
||||
``AppConfig`` through ``get_app_config()`` on every request, so per-run
|
||||
fields take effect on the next message without restarting the gateway.
|
||||
The fields listed in this module are the **infrastructure** subset that
|
||||
the gateway captures once at startup — engines, singletons, IM clients,
|
||||
the logging handler — and that therefore require a process restart to
|
||||
change at runtime.
|
||||
|
||||
The registry covers two kinds of entries:
|
||||
|
||||
- Top-level ``AppConfig`` fields (``database``, ``checkpointer``,
|
||||
``run_events``, ``stream_bridge``, ``sandbox``, ``log_level``). For
|
||||
these, :func:`format_field_description` produces the standardised
|
||||
``"startup-only: ..."`` prefix that the matching Pydantic
|
||||
``Field(description=...)`` carries, so the boundary surfaces in IDE
|
||||
hover next to the field itself.
|
||||
- Top-level ``config.yaml`` sections that are not part of the
|
||||
``AppConfig`` schema (``channels``). These cannot be standardised at
|
||||
the schema level, so the registry is their only canonical location.
|
||||
|
||||
Any future "needs restart" scanner — operator tooling, lint hooks, doc
|
||||
generators — should drive off this registry rather than re-parsing
|
||||
prose.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
#: The standardised prefix every restart-required field description starts
|
||||
#: with. ``test_reload_boundary`` enforces both directions: registered
|
||||
#: fields must use this prefix in the schema, and any schema field using
|
||||
#: this prefix must be in the registry.
|
||||
STARTUP_ONLY_PREFIX = "startup-only:"
|
||||
|
||||
|
||||
#: Restart-required field paths mapped to the human-readable reason.
|
||||
#:
|
||||
#: The reason text is what surfaces in ``Field(description=...)``, so it
|
||||
#: must explain *what* code captures the snapshot — not just that the
|
||||
#: field is restart-required — so an operator changing the value knows
|
||||
#: which subsystem to restart.
|
||||
STARTUP_ONLY_FIELDS: dict[str, str] = {
|
||||
"database": ("init_engine_from_config() runs once during langgraph_runtime() startup; the SQLAlchemy engine holds the connection pool and is not rebuilt on config.yaml edits."),
|
||||
"checkpointer": ("make_checkpointer() binds the persistent checkpointer once at startup, including SQLite WAL / busy_timeout settings."),
|
||||
"run_events": ("make_run_event_store() picks the memory- vs SQL-backed implementation at startup and is frozen onto app.state.run_events_config to stay paired with the underlying event store."),
|
||||
"stream_bridge": ("make_stream_bridge() constructs the stream-bridge singleton once during startup."),
|
||||
"sandbox": ("get_sandbox_provider() caches the provider singleton (``_default_sandbox_provider``); a different ``sandbox.use`` class path only takes effect on next process start."),
|
||||
"log_level": (
|
||||
"apply_logging_level() runs only during app.py startup; it sets the deerflow/app logger levels and may lower root handler thresholds so configured messages can propagate. A freshly reloaded AppConfig does not retrigger it."
|
||||
),
|
||||
# Not part of the AppConfig Pydantic schema — channel credentials are
|
||||
# consumed directly by ``start_channel_service()`` once at lifespan
|
||||
# startup and the live channel clients are not rebuilt on
|
||||
# config.yaml edits.
|
||||
"channels": ("start_channel_service() is invoked once during startup; the live IM channel clients (Feishu, Slack, Telegram, DingTalk) are not rebuilt when channels.* changes."),
|
||||
}
|
||||
|
||||
|
||||
def iter_startup_only_field_paths() -> Iterator[str]:
|
||||
"""Yield every registered restart-required field path."""
|
||||
return iter(STARTUP_ONLY_FIELDS)
|
||||
|
||||
|
||||
def is_startup_only_field(field_path: str) -> bool:
|
||||
"""Return ``True`` when *field_path* is registered as restart-required.
|
||||
|
||||
Accepts only top-level paths (``"database"``, ``"sandbox"`` etc.);
|
||||
nested keys like ``"database.url"`` are not modelled here because the
|
||||
boundary is per-section, not per-leaf.
|
||||
"""
|
||||
return field_path in STARTUP_ONLY_FIELDS
|
||||
|
||||
|
||||
def format_field_description(field_path: str, *, field_doc: str | None = None) -> str:
|
||||
"""Build the standardised description for a registered field.
|
||||
|
||||
Used inside ``AppConfig`` ``Field(description=...)`` so the hover
|
||||
text in IDEs matches the registry and the drift tests can pin one
|
||||
side against the other.
|
||||
|
||||
Args:
|
||||
field_path: A registered top-level field path (e.g. ``"log_level"``).
|
||||
field_doc: Optional human-facing description for the field itself
|
||||
(allowed values, semantics, etc.). When supplied, it is
|
||||
appended after the ``startup-only:`` marker block separated by
|
||||
a blank line so IDE hover shows both the restart-required
|
||||
reason *and* the field's normal documentation. Composition
|
||||
keeps the marker as the leading token machine-readable tooling
|
||||
pivots on while restoring the prose that ``Field(description=)``
|
||||
used to carry before the registry took over.
|
||||
|
||||
Raises:
|
||||
KeyError: when *field_path* is not registered. This is deliberate
|
||||
— silently returning a placeholder would let a typo bypass
|
||||
the drift coverage.
|
||||
"""
|
||||
reason = STARTUP_ONLY_FIELDS[field_path]
|
||||
header = f"{STARTUP_ONLY_PREFIX} {reason}"
|
||||
if field_doc is None:
|
||||
return header
|
||||
return f"{header}\n\n{field_doc.strip()}"
|
||||
@ -0,0 +1,47 @@
|
||||
"""Configuration for SafetyFinishReasonMiddleware.
|
||||
|
||||
Mirrors the shape of GuardrailsConfig: detectors are loaded by class path
|
||||
through ``deerflow.reflection.resolve_variable`` (same loader the
|
||||
``guardrails.provider`` config uses) so users can drop in custom provider
|
||||
detectors without modifying core code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SafetyDetectorConfig(BaseModel):
|
||||
"""One detector entry under ``safety_finish_reason.detectors``."""
|
||||
|
||||
use: str = Field(
|
||||
description=("Class path of a SafetyTerminationDetector implementation (e.g. 'deerflow.agents.middlewares.safety_termination_detectors:OpenAICompatibleContentFilterDetector')."),
|
||||
)
|
||||
config: dict = Field(
|
||||
default_factory=dict,
|
||||
description="Constructor kwargs passed to the detector class.",
|
||||
)
|
||||
|
||||
|
||||
class SafetyFinishReasonConfig(BaseModel):
|
||||
"""Configuration for the SafetyFinishReasonMiddleware.
|
||||
|
||||
The middleware intercepts AIMessages where the provider signaled a
|
||||
safety-related termination (e.g. OpenAI ``finish_reason='content_filter'``)
|
||||
while still returning tool calls, and suppresses those tool calls so the
|
||||
half-truncated arguments never execute.
|
||||
"""
|
||||
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
description="Master switch for the SafetyFinishReasonMiddleware.",
|
||||
)
|
||||
detectors: list[SafetyDetectorConfig] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Custom detector list. Leave unset (None) to use the built-in "
|
||||
"set covering OpenAI-compatible content_filter, Anthropic "
|
||||
"refusal, and Gemini SAFETY/BLOCKLIST/PROHIBITED_CONTENT/SPII/"
|
||||
"RECITATION. Provide a non-null list to fully override."
|
||||
),
|
||||
)
|
||||
@ -4,7 +4,20 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
class VolumeMountConfig(BaseModel):
|
||||
"""Configuration for a volume mount."""
|
||||
|
||||
host_path: str = Field(..., description="Path on the host machine")
|
||||
host_path: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Source path for the mount. Resolution depends on the active provider: "
|
||||
"``LocalSandboxProvider`` checks this path from the gateway process — in "
|
||||
"``make dev`` that is the host machine, but in Docker deployments "
|
||||
"(``make up`` / docker-compose) it is the path *inside* the "
|
||||
"``deer-flow-gateway`` container, so the host directory must also be "
|
||||
"bind-mounted into the gateway service for the mount to take effect. "
|
||||
"``AioSandboxProvider`` (DooD) passes this value straight to ``docker -v`` "
|
||||
"for the sandbox container, where it is resolved by the host Docker daemon "
|
||||
"from the host machine's perspective."
|
||||
),
|
||||
)
|
||||
container_path: str = Field(..., description="Path inside the container")
|
||||
read_only: bool = Field(default=False, description="Whether the mount is read-only")
|
||||
|
||||
|
||||
@ -51,3 +51,16 @@ def load_title_config_from_dict(config_dict: dict) -> None:
|
||||
"""Load title configuration from a dictionary."""
|
||||
global _title_config
|
||||
_title_config = TitleConfig(**config_dict)
|
||||
|
||||
|
||||
def reset_title_config() -> None:
|
||||
"""Restore the title configuration to its pristine ``TitleConfig()`` default.
|
||||
|
||||
Public API so that tests do not have to reach into the private
|
||||
``_title_config`` module attribute. ``AppConfig.from_file()`` calls
|
||||
:func:`load_title_config_from_dict`, which permanently mutates the
|
||||
singleton; tests that need a clean slate between cases should call
|
||||
this between tests.
|
||||
"""
|
||||
global _title_config
|
||||
_title_config = TitleConfig()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user