Compare commits
60 Commits
v2.0-m1-rc0
...
fix-2965
| Author | SHA1 | Date | |
|---|---|---|---|
| e5c7328cf5 | |||
| ba864112a3 | |||
| 6e8e6a969b | |||
| eab7ae3d62 | |||
| f1a0ab699a | |||
| 2a1ac06bf4 | |||
| e9deb6c2f2 | |||
| 68d8caec1f | |||
| 506be8bffd | |||
| f734e14d8b | |||
| 84f88b6610 | |||
| 20d2d2b373 | |||
| 0009655454 | |||
| 1f978393ec | |||
| bedbf2291e | |||
| de253e4a0a | |||
| 2eb11f97ab | |||
| c3bc6c7cd5 | |||
| 813d3c94ef | |||
| 2b5bece744 | |||
| e82b2fb4d0 | |||
| 30a5846219 | |||
| 9892a7d468 | |||
| 94da8f67d7 | |||
| 5127f08e1a | |||
| dfa4eb0c1a | |||
| 08ee7adeba | |||
| 1c96a6afc8 | |||
| 417416087b | |||
| 881ff71252 | |||
| f76e4e35c8 | |||
| 0d1053ca44 | |||
| 4063dd7157 | |||
| 7a3c58a733 | |||
| 1edc9d9fae | |||
| 7caf03e97c | |||
| 41b04a556f | |||
| c1b7f1d189 | |||
| 109490da25 | |||
| 14c0a32ee6 | |||
| 70737af7cd | |||
| 2b1fcb3e43 | |||
| 7de9b5828b | |||
| 37db689349 | |||
| bd45cb2846 | |||
| 5fd0e6ac89 | |||
| 530bda7107 | |||
| 6c220a9aef | |||
| daa3ffc29b | |||
| 27559f3675 | |||
| cef4224381 | |||
| 2b0e62f679 | |||
| 1336872b15 | |||
| 4ead2c6b19 | |||
| 59c4a3f0a4 | |||
| e8675f266d | |||
| 680187ddc2 | |||
| aded753de3 | |||
| 028493bfd8 | |||
| 8e48b7e85c |
+14
-2
@@ -9,8 +9,9 @@ JINA_API_KEY=your-jina-api-key
|
|||||||
|
|
||||||
# InfoQuest API Key
|
# InfoQuest API Key
|
||||||
INFOQUEST_API_KEY=your-infoquest-api-key
|
INFOQUEST_API_KEY=your-infoquest-api-key
|
||||||
# CORS Origins (comma-separated) - e.g., http://localhost:3000,http://localhost:3001
|
# Browser CORS allowlist for split-origin or port-forwarded deployments (comma-separated exact origins).
|
||||||
# CORS_ORIGINS=http://localhost:3000
|
# Leave unset when using the unified nginx endpoint, e.g. http://localhost:2026.
|
||||||
|
# GATEWAY_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||||
|
|
||||||
# Optional:
|
# Optional:
|
||||||
# FIRECRAWL_API_KEY=your-firecrawl-api-key
|
# FIRECRAWL_API_KEY=your-firecrawl-api-key
|
||||||
@@ -48,3 +49,14 @@ INFOQUEST_API_KEY=your-infoquest-api-key
|
|||||||
|
|
||||||
# Set to "false" to disable Swagger UI, ReDoc, and OpenAPI schema in production
|
# Set to "false" to disable Swagger UI, ReDoc, and OpenAPI schema in production
|
||||||
# GATEWAY_ENABLE_DOCS=false
|
# GATEWAY_ENABLE_DOCS=false
|
||||||
|
|
||||||
|
# ── 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
|
||||||
|
# `make start`, so most local users do not need to set them.
|
||||||
|
#
|
||||||
|
# Override only when the Gateway is not on localhost:8001 (e.g. when the
|
||||||
|
# frontend and gateway run on different hosts, in containers with a service
|
||||||
|
# alias, or behind a different port). docker-compose already sets these.
|
||||||
|
# DEER_FLOW_INTERNAL_GATEWAY_BASE_URL=http://localhost:8001
|
||||||
|
# DEER_FLOW_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:2026
|
||||||
|
|||||||
+13
-19
@@ -46,12 +46,12 @@ Docker provides a consistent, isolated environment with all dependencies pre-con
|
|||||||
All services will start with hot-reload enabled:
|
All services will start with hot-reload enabled:
|
||||||
- Frontend changes are automatically reloaded
|
- Frontend changes are automatically reloaded
|
||||||
- Backend changes trigger automatic restart
|
- Backend changes trigger automatic restart
|
||||||
- LangGraph server supports hot-reload
|
- Gateway-hosted LangGraph-compatible runtime supports hot-reload
|
||||||
|
|
||||||
4. **Access the application**:
|
4. **Access the application**:
|
||||||
- Web Interface: http://localhost:2026
|
- Web Interface: http://localhost:2026
|
||||||
- API Gateway: http://localhost:2026/api/*
|
- API Gateway: http://localhost:2026/api/*
|
||||||
- LangGraph: http://localhost:2026/api/langgraph/*
|
- LangGraph-compatible API: http://localhost:2026/api/langgraph/*
|
||||||
|
|
||||||
#### Docker Commands
|
#### Docker Commands
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ Use these as practical starting points for development and review environments:
|
|||||||
If `make docker-init`, `make docker-start`, or `make docker-stop` fails on Linux with an error like below, your current user likely does not have permission to access the Docker daemon socket:
|
If `make docker-init`, `make docker-start`, or `make docker-stop` fails on Linux with an error like below, your current user likely does not have permission to access the Docker daemon socket:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
unable to get image 'deer-flow-dev-langgraph': permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock
|
unable to get image 'deer-flow-gateway': permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock
|
||||||
```
|
```
|
||||||
|
|
||||||
Recommended fix: add your current user to the `docker` group so Docker commands work without `sudo`.
|
Recommended fix: add your current user to the `docker` group so Docker commands work without `sudo`.
|
||||||
@@ -131,9 +131,8 @@ Host Machine
|
|||||||
Docker Compose (deer-flow-dev)
|
Docker Compose (deer-flow-dev)
|
||||||
├→ nginx (port 2026) ← Reverse proxy
|
├→ nginx (port 2026) ← Reverse proxy
|
||||||
├→ web (port 3000) ← Frontend with hot-reload
|
├→ web (port 3000) ← Frontend with hot-reload
|
||||||
├→ api (port 8001) ← Gateway API with hot-reload
|
├→ gateway (port 8001) ← Gateway API + LangGraph-compatible runtime with hot-reload
|
||||||
├→ langgraph (port 2024) ← LangGraph server with hot-reload
|
└→ provisioner (optional, port 8002) ← Started only in provisioner/K8s sandbox mode
|
||||||
└→ provisioner (optional, port 8002) ← Started only in provisioner/K8s sandbox mode
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Benefits of Docker Development**:
|
**Benefits of Docker Development**:
|
||||||
@@ -184,17 +183,13 @@ Required tools:
|
|||||||
|
|
||||||
If you need to start services individually:
|
If you need to start services individually:
|
||||||
|
|
||||||
1. **Start backend services**:
|
1. **Start backend service**:
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1: Start LangGraph Server (port 2024)
|
# Terminal 1: Start Gateway API + embedded agent runtime (port 8001)
|
||||||
cd backend
|
cd backend
|
||||||
make dev
|
make dev
|
||||||
|
|
||||||
# Terminal 2: Start Gateway API (port 8001)
|
# Terminal 2: Start Frontend (port 3000)
|
||||||
cd backend
|
|
||||||
make gateway
|
|
||||||
|
|
||||||
# Terminal 3: Start Frontend (port 3000)
|
|
||||||
cd frontend
|
cd frontend
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
@@ -212,10 +207,10 @@ If you need to start services individually:
|
|||||||
|
|
||||||
The nginx configuration provides:
|
The nginx configuration provides:
|
||||||
- Unified entry point on port 2026
|
- Unified entry point on port 2026
|
||||||
- Routes `/api/langgraph/*` to LangGraph Server (2024)
|
- Rewrites `/api/langgraph/*` to Gateway's LangGraph-compatible API (8001)
|
||||||
- Routes other `/api/*` endpoints to Gateway API (8001)
|
- Routes other `/api/*` endpoints to Gateway API (8001)
|
||||||
- Routes non-API requests to Frontend (3000)
|
- Routes non-API requests to Frontend (3000)
|
||||||
- Centralized CORS handling
|
- Same-origin API routing; split-origin or port-forwarded browser clients should use the Gateway `GATEWAY_CORS_ORIGINS` allowlist
|
||||||
- SSE/streaming support for real-time agent responses
|
- SSE/streaming support for real-time agent responses
|
||||||
- Optimized timeouts for long-running operations
|
- Optimized timeouts for long-running operations
|
||||||
|
|
||||||
@@ -235,8 +230,8 @@ deer-flow/
|
|||||||
│ └── nginx.local.conf # Nginx config for local dev
|
│ └── nginx.local.conf # Nginx config for local dev
|
||||||
├── backend/ # Backend application
|
├── backend/ # Backend application
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── gateway/ # Gateway API (port 8001)
|
│ │ ├── gateway/ # Gateway API and LangGraph-compatible runtime (port 8001)
|
||||||
│ │ ├── agents/ # LangGraph agents (port 2024)
|
│ │ ├── agents/ # LangGraph agent runtime used by Gateway
|
||||||
│ │ ├── mcp/ # Model Context Protocol integration
|
│ │ ├── mcp/ # Model Context Protocol integration
|
||||||
│ │ ├── skills/ # Skills system
|
│ │ ├── skills/ # Skills system
|
||||||
│ │ └── sandbox/ # Sandbox execution
|
│ │ └── sandbox/ # Sandbox execution
|
||||||
@@ -256,8 +251,7 @@ Browser
|
|||||||
↓
|
↓
|
||||||
Nginx (port 2026) ← Unified entry point
|
Nginx (port 2026) ← Unified entry point
|
||||||
├→ Frontend (port 3000) ← / (non-API requests)
|
├→ Frontend (port 3000) ← / (non-API requests)
|
||||||
├→ Gateway API (port 8001) ← /api/models, /api/mcp, /api/skills, /api/threads/*/artifacts
|
└→ Gateway API (port 8001) ← /api/* and /api/langgraph/* (LangGraph-compatible agent interactions)
|
||||||
└→ LangGraph Server (port 2024) ← /api/langgraph/* (agent interactions)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|||||||
@@ -245,6 +245,8 @@ make down # Stop and remove containers
|
|||||||
|
|
||||||
Access: http://localhost:2026
|
Access: http://localhost:2026
|
||||||
|
|
||||||
|
The unified nginx endpoint is same-origin by default and does not emit browser CORS headers. If you run a split-origin or port-forwarded browser client, set `GATEWAY_CORS_ORIGINS` to comma-separated exact origins such as `http://localhost:3000`; the Gateway then applies the CORS allowlist and matching CSRF origin checks.
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed Docker development guide.
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed Docker development guide.
|
||||||
|
|
||||||
#### Option 2: Local Development
|
#### Option 2: Local Development
|
||||||
@@ -626,7 +628,7 @@ See [`skills/public/claude-to-deerflow/SKILL.md`](skills/public/claude-to-deerfl
|
|||||||
|
|
||||||
Complex tasks rarely fit in a single pass. DeerFlow decomposes them.
|
Complex tasks rarely fit in a single pass. DeerFlow decomposes them.
|
||||||
|
|
||||||
The lead agent can spawn sub-agents on the fly — each with its own scoped context, tools, and termination conditions. Sub-agents run in parallel when possible, report back structured results, and the lead agent synthesizes everything into a coherent output.
|
The lead agent can spawn sub-agents on the fly — each with its own scoped context, tools, and termination conditions. Sub-agents run in parallel when possible, report back structured results, and the lead agent synthesizes everything into a coherent output. When token usage tracking is enabled, completed sub-agent usage is attributed back to the dispatching step.
|
||||||
|
|
||||||
This is how DeerFlow handles tasks that take minutes to hours: a research task might fan out into a dozen sub-agents, each exploring a different angle, then converge into a single report — or a website — or a slide deck with generated visuals. One harness, many hands.
|
This is how DeerFlow handles tasks that take minutes to hours: a research task might fan out into a dozen sub-agents, each exploring a different angle, then converge into a single report — or a website — or a slide deck with generated visuals. One harness, many hands.
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -228,7 +228,7 @@ make down # Stop and remove containers
|
|||||||
```
|
```
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Le serveur d'agents LangGraph fonctionne actuellement via `langgraph dev` (le serveur CLI open source).
|
> Le runtime d'agent s'exécute actuellement dans la Gateway. nginx réécrit `/api/langgraph/*` vers l'API compatible LangGraph servie par la Gateway.
|
||||||
|
|
||||||
Accès : http://localhost:2026
|
Accès : http://localhost:2026
|
||||||
|
|
||||||
@@ -296,8 +296,8 @@ DeerFlow peut recevoir des tâches depuis des applications de messagerie. Les ca
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
channels:
|
channels:
|
||||||
# LangGraph Server URL (default: http://localhost:2024)
|
# LangGraph-compatible Gateway API base URL (default: http://localhost:8001/api)
|
||||||
langgraph_url: http://localhost:2024
|
langgraph_url: http://localhost:8001/api
|
||||||
# Gateway API URL (default: http://localhost:8001)
|
# Gateway API URL (default: http://localhost:8001)
|
||||||
gateway_url: http://localhost:8001
|
gateway_url: http://localhost:8001
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -181,7 +181,7 @@ make down # コンテナを停止して削除
|
|||||||
```
|
```
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> LangGraphエージェントサーバーは現在`langgraph dev`(オープンソースCLIサーバー)経由で実行されます。
|
> Agentランタイムは現在Gateway内で実行されます。`/api/langgraph/*`はnginxによってGatewayのLangGraph-compatible APIへ書き換えられます。
|
||||||
|
|
||||||
アクセス: http://localhost:2026
|
アクセス: http://localhost:2026
|
||||||
|
|
||||||
@@ -249,8 +249,8 @@ DeerFlowはメッセージングアプリからのタスク受信をサポート
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
channels:
|
channels:
|
||||||
# LangGraphサーバーURL(デフォルト: http://localhost:2024)
|
# LangGraph-compatible Gateway API base URL(デフォルト: http://localhost:8001/api)
|
||||||
langgraph_url: http://localhost:2024
|
langgraph_url: http://localhost:8001/api
|
||||||
# Gateway API URL(デフォルト: http://localhost:8001)
|
# Gateway API URL(デフォルト: http://localhost:8001)
|
||||||
gateway_url: http://localhost:8001
|
gateway_url: http://localhost:8001
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -184,7 +184,7 @@ make down # 停止并移除容器
|
|||||||
```
|
```
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> 当前 LangGraph agent server 通过开源 CLI 服务 `langgraph dev` 运行。
|
> 当前 Agent 运行时嵌入在 Gateway 中运行,`/api/langgraph/*` 会由 nginx 重写到 Gateway 的 LangGraph-compatible API。
|
||||||
|
|
||||||
访问地址:http://localhost:2026
|
访问地址:http://localhost:2026
|
||||||
|
|
||||||
@@ -254,8 +254,8 @@ DeerFlow 支持从即时通讯应用接收任务。只要配置完成,对应
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
channels:
|
channels:
|
||||||
# LangGraph Server URL(默认:http://localhost:2024)
|
# LangGraph-compatible Gateway API base URL(默认:http://localhost:8001/api)
|
||||||
langgraph_url: http://localhost:2024
|
langgraph_url: http://localhost:8001/api
|
||||||
# Gateway API URL(默认:http://localhost:8001)
|
# Gateway API URL(默认:http://localhost:8001)
|
||||||
gateway_url: http://localhost:8001
|
gateway_url: http://localhost:8001
|
||||||
|
|
||||||
|
|||||||
+11
-5
@@ -165,7 +165,7 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
|
|||||||
8. **ToolErrorHandlingMiddleware** - Converts tool exceptions into error `ToolMessage`s so the run can continue instead of aborting
|
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)
|
9. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
|
||||||
10. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
10. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
||||||
11. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
|
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
|
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)
|
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)
|
14. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
|
||||||
@@ -207,6 +207,8 @@ Configuration priority:
|
|||||||
|
|
||||||
FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWAY_ENABLE_DOCS=false` to disable `/docs`, `/redoc`, and `/openapi.json` in production (default: enabled).
|
FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWAY_ENABLE_DOCS=false` to disable `/docs`, `/redoc`, and `/openapi.json` in production (default: enabled).
|
||||||
|
|
||||||
|
CORS is same-origin by default when requests enter through nginx on port 2026. Split-origin or port-forwarded browser clients must opt in with `GATEWAY_CORS_ORIGINS` (comma-separated exact origins); Gateway `CORSMiddleware` and `CSRFMiddleware` both read that variable so browser CORS and auth-origin checks stay aligned.
|
||||||
|
|
||||||
**Routers**:
|
**Routers**:
|
||||||
|
|
||||||
| Router | Endpoints |
|
| Router | Endpoints |
|
||||||
@@ -223,7 +225,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
|||||||
| **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 |
|
| **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 |
|
| **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 |
|
||||||
|
|
||||||
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
|
Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runtime, all other `/api/*` → Gateway REST APIs.
|
||||||
|
|
||||||
### Sandbox System (`packages/harness/deerflow/sandbox/`)
|
### Sandbox System (`packages/harness/deerflow/sandbox/`)
|
||||||
|
|
||||||
@@ -243,7 +245,7 @@ Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` →
|
|||||||
- `bash` - Execute commands with path translation and error handling
|
- `bash` - Execute commands with path translation and error handling
|
||||||
- `ls` - Directory listing (tree format, max 2 levels)
|
- `ls` - Directory listing (tree format, max 2 levels)
|
||||||
- `read_file` - Read file contents with optional line range
|
- `read_file` - Read file contents with optional line range
|
||||||
- `write_file` - Write/append to files, creates directories
|
- `write_file` - Write/append to files, creates directories; overwrites by default and exposes the `append` argument in the model-facing schema for end-of-file writes
|
||||||
- `str_replace` - Substring replacement (single or all occurrences); same-path serialization is scoped to `(sandbox.id, path)` so isolated sandboxes do not contend on identical virtual paths inside one process
|
- `str_replace` - Substring replacement (single or all occurrences); same-path serialization is scoped to `(sandbox.id, path)` so isolated sandboxes do not contend on identical virtual paths inside one process
|
||||||
|
|
||||||
### Subagent System (`packages/harness/deerflow/subagents/`)
|
### Subagent System (`packages/harness/deerflow/subagents/`)
|
||||||
@@ -263,8 +265,10 @@ Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` →
|
|||||||
- `present_files` - Make output files visible to user (only `/mnt/user-data/outputs`)
|
- `present_files` - Make output files visible to user (only `/mnt/user-data/outputs`)
|
||||||
- `ask_clarification` - Request clarification (intercepted by ClarificationMiddleware → interrupts)
|
- `ask_clarification` - Request clarification (intercepted by ClarificationMiddleware → interrupts)
|
||||||
- `view_image` - Read image as base64 (added only if model supports vision)
|
- `view_image` - Read image as base64 (added only if model supports vision)
|
||||||
|
- `setup_agent` - Bootstrap-only: persist a brand-new custom agent's `SOUL.md` and `config.yaml`. Bound only when `is_bootstrap=True`.
|
||||||
|
- `update_agent` - Custom-agent-only: persist self-updates to the current agent's `SOUL.md` / `config.yaml` from inside a normal chat (partial update + atomic write). Bound when `agent_name` is set and `is_bootstrap=False`.
|
||||||
4. **Subagent tool** (if enabled):
|
4. **Subagent tool** (if enabled):
|
||||||
- `task` - Delegate to subagent (description, prompt, subagent_type, max_turns)
|
- `task` - Delegate to subagent (description, prompt, subagent_type)
|
||||||
|
|
||||||
**Community tools** (`packages/harness/deerflow/community/`):
|
**Community tools** (`packages/harness/deerflow/community/`):
|
||||||
- `tavily/` - Web search (5 results default) and web fetch (4KB limit)
|
- `tavily/` - Web search (5 results default) and web fetch (4KB limit)
|
||||||
@@ -354,10 +358,11 @@ Bridges external messaging platforms (Feishu, Slack, Telegram, DingTalk) to the
|
|||||||
**Per-User Isolation**:
|
**Per-User Isolation**:
|
||||||
- Memory is stored per-user at `{base_dir}/users/{user_id}/memory.json`
|
- Memory is stored per-user at `{base_dir}/users/{user_id}/memory.json`
|
||||||
- Per-agent per-user memory at `{base_dir}/users/{user_id}/agents/{agent_name}/memory.json`
|
- Per-agent per-user memory at `{base_dir}/users/{user_id}/agents/{agent_name}/memory.json`
|
||||||
|
- Custom agent definitions (`SOUL.md` + `config.yaml`) are also per-user at `{base_dir}/users/{user_id}/agents/{agent_name}/`. The legacy shared layout `{base_dir}/agents/{agent_name}/` remains read-only fallback for unmigrated installations
|
||||||
- `user_id` is resolved via `get_effective_user_id()` from `deerflow.runtime.user_context`
|
- `user_id` is resolved via `get_effective_user_id()` from `deerflow.runtime.user_context`
|
||||||
- In no-auth mode, `user_id` defaults to `"default"` (constant `DEFAULT_USER_ID`)
|
- In no-auth mode, `user_id` defaults to `"default"` (constant `DEFAULT_USER_ID`)
|
||||||
- Absolute `storage_path` in config opts out of per-user isolation
|
- Absolute `storage_path` in config opts out of per-user isolation
|
||||||
- **Migration**: Run `PYTHONPATH=. python scripts/migrate_user_isolation.py` to move legacy `memory.json` and `threads/` into per-user layout; supports `--dry-run`
|
- **Migration**: Run `PYTHONPATH=. python scripts/migrate_user_isolation.py` to move legacy `memory.json`, `threads/`, and `agents/` into per-user layout. Supports `--dry-run` (preview changes) and `--user-id USER_ID` (assign unowned legacy data to a user, defaults to `default`).
|
||||||
|
|
||||||
**Data Structure** (stored in `{base_dir}/users/{user_id}/memory.json`):
|
**Data Structure** (stored in `{base_dir}/users/{user_id}/memory.json`):
|
||||||
- **User Context**: `workContext`, `personalContext`, `topOfMind` (1-3 sentence summaries)
|
- **User Context**: `workContext`, `personalContext`, `topOfMind` (1-3 sentence summaries)
|
||||||
@@ -517,6 +522,7 @@ Multi-file upload with automatic document conversion:
|
|||||||
- Rejects directory inputs before copying so uploads stay all-or-nothing
|
- Rejects directory inputs before copying so uploads stay all-or-nothing
|
||||||
- Reuses one conversion worker per request when called from an active event loop
|
- Reuses one conversion worker per request when called from an active event loop
|
||||||
- Files stored in thread-isolated directories
|
- Files stored in thread-isolated directories
|
||||||
|
- Duplicate filenames in a single upload request are auto-renamed with `_N` suffixes so later files do not truncate earlier files
|
||||||
- Agent receives uploaded file list via `UploadsMiddleware`
|
- Agent receives uploaded file list via `UploadsMiddleware`
|
||||||
|
|
||||||
See [docs/FILE_UPLOAD.md](docs/FILE_UPLOAD.md) for details.
|
See [docs/FILE_UPLOAD.md](docs/FILE_UPLOAD.md) for details.
|
||||||
|
|||||||
@@ -56,11 +56,8 @@ export OPENAI_API_KEY="your-api-key"
|
|||||||
### Run the Development Server
|
### Run the Development Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1: LangGraph server
|
# Gateway API + embedded agent runtime
|
||||||
make dev
|
make dev
|
||||||
|
|
||||||
# Terminal 2: Gateway API
|
|
||||||
make gateway
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|||||||
+29
-33
@@ -11,31 +11,26 @@ DeerFlow is a LangGraph-based AI super agent with sandbox execution, persistent
|
|||||||
│ Nginx (Port 2026) │
|
│ Nginx (Port 2026) │
|
||||||
│ Unified reverse proxy │
|
│ Unified reverse proxy │
|
||||||
└───────┬──────────────────┬───────────┘
|
└───────┬──────────────────┬───────────┘
|
||||||
│ │
|
│
|
||||||
/api/langgraph/* │ │ /api/* (other)
|
/api/langgraph/* │ /api/* (other)
|
||||||
▼ ▼
|
rewritten to /api/* │
|
||||||
┌────────────────────┐ ┌────────────────────────┐
|
▼
|
||||||
│ LangGraph Server │ │ Gateway API (8001) │
|
┌────────────────────────────────────────┐
|
||||||
│ (Port 2024) │ │ FastAPI REST │
|
│ Gateway API (8001) │
|
||||||
│ │ │ │
|
│ FastAPI REST + agent runtime │
|
||||||
│ ┌────────────────┐ │ │ Models, MCP, Skills, │
|
│ │
|
||||||
│ │ Lead Agent │ │ │ Memory, Uploads, │
|
│ Models, MCP, Skills, Memory, Uploads, │
|
||||||
│ │ ┌──────────┐ │ │ │ Artifacts │
|
│ Artifacts, Threads, Runs, Streaming │
|
||||||
│ │ │Middleware│ │ │ └────────────────────────┘
|
│ │
|
||||||
│ │ │ Chain │ │ │
|
│ ┌────────────────────────────────────┐ │
|
||||||
│ │ └──────────┘ │ │
|
│ │ Lead Agent │ │
|
||||||
│ │ ┌──────────┐ │ │
|
│ │ Middleware Chain, Tools, Subagents │ │
|
||||||
│ │ │ Tools │ │ │
|
│ └────────────────────────────────────┘ │
|
||||||
│ │ └──────────┘ │ │
|
└────────────────────────────────────────┘
|
||||||
│ │ ┌──────────┐ │ │
|
|
||||||
│ │ │Subagents │ │ │
|
|
||||||
│ │ └──────────┘ │ │
|
|
||||||
│ └────────────────┘ │
|
|
||||||
└────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Request Routing** (via Nginx):
|
**Request Routing** (via Nginx):
|
||||||
- `/api/langgraph/*` → LangGraph Server - agent interactions, threads, streaming
|
- `/api/langgraph/*` → Gateway LangGraph-compatible API - agent interactions, threads, streaming
|
||||||
- `/api/*` (other) → Gateway API - models, MCP, skills, memory, artifacts, uploads, thread-local cleanup
|
- `/api/*` (other) → Gateway API - models, MCP, skills, memory, artifacts, uploads, thread-local cleanup
|
||||||
- `/` (non-API) → Frontend - Next.js web interface
|
- `/` (non-API) → Frontend - Next.js web interface
|
||||||
|
|
||||||
@@ -79,7 +74,7 @@ Per-thread isolated execution with virtual path translation:
|
|||||||
- **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory
|
- **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
|
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
|
||||||
- **File-write safety**: `str_replace` serializes read-modify-write per `(sandbox.id, path)` so isolated sandboxes keep concurrency even when virtual paths match
|
- **File-write safety**: `str_replace` serializes read-modify-write per `(sandbox.id, path)` so isolated sandboxes keep concurrency even when virtual paths match
|
||||||
- **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access)
|
- **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`write_file` overwrites by default and exposes `append` for end-of-file writes; `bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access)
|
||||||
|
|
||||||
### Subagent System
|
### Subagent System
|
||||||
|
|
||||||
@@ -124,7 +119,7 @@ FastAPI application providing REST endpoints for frontend integration:
|
|||||||
| `POST /api/memory/reload` | Force memory reload |
|
| `POST /api/memory/reload` | Force memory reload |
|
||||||
| `GET /api/memory/config` | Memory configuration |
|
| `GET /api/memory/config` | Memory configuration |
|
||||||
| `GET /api/memory/status` | Combined config + data |
|
| `GET /api/memory/status` | Combined config + data |
|
||||||
| `POST /api/threads/{id}/uploads` | Upload files (auto-converts PDF/PPT/Excel/Word to Markdown, rejects directory paths) |
|
| `POST /api/threads/{id}/uploads` | Upload files (auto-converts PDF/PPT/Excel/Word to Markdown, rejects directory paths, auto-renames duplicate filenames in one request) |
|
||||||
| `GET /api/threads/{id}/uploads/list` | List uploaded files |
|
| `GET /api/threads/{id}/uploads/list` | List uploaded files |
|
||||||
| `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
|
| `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
|
||||||
| `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts |
|
| `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts |
|
||||||
@@ -193,7 +188,7 @@ export OPENAI_API_KEY="your-api-key-here"
|
|||||||
**Full Application** (from project root):
|
**Full Application** (from project root):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make dev # Starts LangGraph + Gateway + Frontend + Nginx
|
make dev # Starts Gateway + Frontend + Nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
Access at: http://localhost:2026
|
Access at: http://localhost:2026
|
||||||
@@ -201,14 +196,11 @@ Access at: http://localhost:2026
|
|||||||
**Backend Only** (from backend directory):
|
**Backend Only** (from backend directory):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1: LangGraph server
|
# Gateway API + embedded agent runtime
|
||||||
make dev
|
make dev
|
||||||
|
|
||||||
# Terminal 2: Gateway API
|
|
||||||
make gateway
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Direct access: LangGraph at http://localhost:2024, Gateway at http://localhost:8001
|
Direct access: Gateway at http://localhost:8001
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -244,12 +236,16 @@ backend/
|
|||||||
│ └── utils/ # Utilities
|
│ └── utils/ # Utilities
|
||||||
├── docs/ # Documentation
|
├── docs/ # Documentation
|
||||||
├── tests/ # Test suite
|
├── tests/ # Test suite
|
||||||
├── langgraph.json # LangGraph server configuration
|
├── langgraph.json # LangGraph graph registry for tooling/Studio compatibility
|
||||||
├── pyproject.toml # Python dependencies
|
├── pyproject.toml # Python dependencies
|
||||||
├── Makefile # Development commands
|
├── Makefile # Development commands
|
||||||
└── Dockerfile # Container build
|
└── Dockerfile # Container build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`langgraph.json` is not the default service entrypoint. The scripts and Docker
|
||||||
|
deployments run the Gateway embedded runtime; the file is kept for LangGraph
|
||||||
|
tooling, Studio, or direct LangGraph Server compatibility.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
@@ -362,8 +358,8 @@ If a provider is explicitly enabled but required credentials are missing, or the
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
make install # Install dependencies
|
make install # Install dependencies
|
||||||
make dev # Run LangGraph server (port 2024)
|
make dev # Run Gateway API + embedded agent runtime (port 8001)
|
||||||
make gateway # Run Gateway API (port 8001)
|
make gateway # Run Gateway API without reload (port 8001)
|
||||||
make lint # Run linter (ruff)
|
make lint # Run linter (ruff)
|
||||||
make format # Format code (ruff)
|
make format # Format code (ruff)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -146,6 +146,13 @@ def _normalize_custom_agent_name(raw_value: str) -> str:
|
|||||||
return normalized
|
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:
|
def _extract_response_text(result: dict | list) -> str:
|
||||||
"""Extract the last AI message text from a LangGraph runs.wait result.
|
"""Extract the last AI message text from a LangGraph runs.wait result.
|
||||||
|
|
||||||
@@ -155,7 +162,7 @@ def _extract_response_text(result: dict | list) -> str:
|
|||||||
Handles special cases:
|
Handles special cases:
|
||||||
- Regular AI text responses
|
- Regular AI text responses
|
||||||
- Clarification interrupts (``ask_clarification`` tool messages)
|
- Clarification interrupts (``ask_clarification`` tool messages)
|
||||||
- AI messages with tool_calls but no text content
|
- Strips loop-detection warnings attached to tool-call AI messages
|
||||||
"""
|
"""
|
||||||
if isinstance(result, list):
|
if isinstance(result, list):
|
||||||
messages = result
|
messages = result
|
||||||
@@ -185,7 +192,12 @@ def _extract_response_text(result: dict | list) -> str:
|
|||||||
# Regular AI message with text content
|
# Regular AI message with text content
|
||||||
if msg_type == "ai":
|
if msg_type == "ai":
|
||||||
content = msg.get("content", "")
|
content = msg.get("content", "")
|
||||||
|
has_tool_calls = bool(msg.get("tool_calls"))
|
||||||
if isinstance(content, str) and content:
|
if isinstance(content, str) and content:
|
||||||
|
if has_tool_calls:
|
||||||
|
content = _strip_loop_warning_text(content)
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
return content
|
return content
|
||||||
# content can be a list of content blocks
|
# content can be a list of content blocks
|
||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
@@ -196,6 +208,8 @@ def _extract_response_text(result: dict | list) -> str:
|
|||||||
elif isinstance(block, str):
|
elif isinstance(block, str):
|
||||||
parts.append(block)
|
parts.append(block)
|
||||||
text = "".join(parts)
|
text = "".join(parts)
|
||||||
|
if has_tool_calls:
|
||||||
|
text = _strip_loop_warning_text(text)
|
||||||
if text:
|
if text:
|
||||||
return text
|
return text
|
||||||
return ""
|
return ""
|
||||||
@@ -589,6 +603,17 @@ class ChannelManager:
|
|||||||
user_layer.get("config"),
|
user_layer.get("config"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
configurable = run_config.get("configurable")
|
||||||
|
if isinstance(configurable, Mapping):
|
||||||
|
configurable = dict(configurable)
|
||||||
|
else:
|
||||||
|
configurable = {}
|
||||||
|
run_config["configurable"] = configurable
|
||||||
|
# Pin channel-triggered runs to the root graph namespace so follow-up
|
||||||
|
# turns continue from the same conversation checkpoint.
|
||||||
|
configurable["checkpoint_ns"] = ""
|
||||||
|
configurable["thread_id"] = thread_id
|
||||||
|
|
||||||
run_context = _merge_dicts(
|
run_context = _merge_dicts(
|
||||||
DEFAULT_RUN_CONTEXT,
|
DEFAULT_RUN_CONTEXT,
|
||||||
self._default_session.get("context"),
|
self._default_session.get("context"),
|
||||||
@@ -972,7 +997,11 @@ class ChannelManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as http:
|
async with httpx.AsyncClient() as http:
|
||||||
resp = await http.get(f"{self._gateway_url}{path}", timeout=10)
|
resp = await http.get(
|
||||||
|
f"{self._gateway_url}{path}",
|
||||||
|
timeout=10,
|
||||||
|
headers=create_internal_auth_headers(),
|
||||||
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
+24
-28
@@ -1,6 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
@@ -9,7 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from app.gateway.auth_middleware import AuthMiddleware
|
from app.gateway.auth_middleware import AuthMiddleware
|
||||||
from app.gateway.config import get_gateway_config
|
from app.gateway.config import get_gateway_config
|
||||||
from app.gateway.csrf_middleware import CSRFMiddleware
|
from app.gateway.csrf_middleware import CSRFMiddleware, get_configured_cors_origins
|
||||||
from app.gateway.deps import langgraph_runtime
|
from app.gateway.deps import langgraph_runtime
|
||||||
from app.gateway.routers import (
|
from app.gateway.routers import (
|
||||||
agents,
|
agents,
|
||||||
@@ -63,7 +62,7 @@ async def _ensure_admin_user(app: FastAPI) -> None:
|
|||||||
|
|
||||||
Subsequent boots (admin already exists):
|
Subsequent boots (admin already exists):
|
||||||
- Runs the one-time "no-auth → with-auth" orphan thread migration for
|
- Runs the one-time "no-auth → with-auth" orphan thread migration for
|
||||||
existing LangGraph thread metadata that has no owner_id.
|
existing LangGraph thread metadata that has no user_id.
|
||||||
|
|
||||||
No SQL persistence migration is needed: the four user_id columns
|
No SQL persistence migration is needed: the four user_id columns
|
||||||
(threads_meta, runs, run_events, feedback) only come into existence
|
(threads_meta, runs, run_events, feedback) only come into existence
|
||||||
@@ -178,7 +177,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
async with langgraph_runtime(app):
|
async with langgraph_runtime(app):
|
||||||
logger.info("LangGraph runtime initialised")
|
logger.info("LangGraph runtime initialised")
|
||||||
|
|
||||||
# Ensure admin user exists (auto-create on first boot)
|
# Check admin bootstrap state and migrate orphan threads after admin exists.
|
||||||
# Must run AFTER langgraph_runtime so app.state.store is available for thread migration
|
# Must run AFTER langgraph_runtime so app.state.store is available for thread migration
|
||||||
await _ensure_admin_user(app)
|
await _ensure_admin_user(app)
|
||||||
|
|
||||||
@@ -219,7 +218,9 @@ def create_app() -> FastAPI:
|
|||||||
Configured FastAPI application instance.
|
Configured FastAPI application instance.
|
||||||
"""
|
"""
|
||||||
config = get_gateway_config()
|
config = get_gateway_config()
|
||||||
docs_kwargs = {"docs_url": "/docs", "redoc_url": "/redoc", "openapi_url": "/openapi.json"} if config.enable_docs else {"docs_url": None, "redoc_url": None, "openapi_url": None}
|
docs_url = "/docs" if config.enable_docs else None
|
||||||
|
redoc_url = "/redoc" if config.enable_docs else None
|
||||||
|
openapi_url = "/openapi.json" if config.enable_docs else None
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="DeerFlow API Gateway",
|
title="DeerFlow API Gateway",
|
||||||
@@ -239,12 +240,14 @@ API Gateway for DeerFlow - A LangGraph-based AI agent backend with sandbox execu
|
|||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
LangGraph requests are handled by nginx reverse proxy.
|
LangGraph-compatible requests are routed through nginx to this gateway.
|
||||||
This gateway provides custom endpoints for models, MCP configuration, skills, and artifacts.
|
This gateway provides runtime endpoints for agent runs plus custom endpoints for models, MCP configuration, skills, and artifacts.
|
||||||
""",
|
""",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
**docs_kwargs,
|
docs_url=docs_url,
|
||||||
|
redoc_url=redoc_url,
|
||||||
|
openapi_url=openapi_url,
|
||||||
openapi_tags=[
|
openapi_tags=[
|
||||||
{
|
{
|
||||||
"name": "models",
|
"name": "models",
|
||||||
@@ -307,25 +310,18 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
# CSRF: Double Submit Cookie pattern for state-changing requests
|
# CSRF: Double Submit Cookie pattern for state-changing requests
|
||||||
app.add_middleware(CSRFMiddleware)
|
app.add_middleware(CSRFMiddleware)
|
||||||
|
|
||||||
# CORS: when GATEWAY_CORS_ORIGINS is set (dev without nginx), add CORS middleware.
|
# CORS: the unified nginx endpoint is same-origin by default. Split-origin
|
||||||
# In production, nginx handles CORS and no middleware is needed.
|
# browser clients must opt in with this explicit Gateway allowlist so CORS
|
||||||
cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "")
|
# and CSRF origin checks share the same source of truth.
|
||||||
if cors_origins_env:
|
cors_origins = sorted(get_configured_cors_origins())
|
||||||
cors_origins = [o.strip() for o in cors_origins_env.split(",") if o.strip()]
|
if cors_origins:
|
||||||
# Validate: wildcard origin with credentials is a security misconfiguration
|
app.add_middleware(
|
||||||
for origin in cors_origins:
|
CORSMiddleware,
|
||||||
if origin == "*":
|
allow_origins=cors_origins,
|
||||||
logger.error("GATEWAY_CORS_ORIGINS contains wildcard '*' with allow_credentials=True. This is a security misconfiguration — browsers will reject the response. Use explicit scheme://host:port origins instead.")
|
allow_credentials=True,
|
||||||
cors_origins = [o for o in cors_origins if o != "*"]
|
allow_methods=["*"],
|
||||||
break
|
allow_headers=["*"],
|
||||||
if cors_origins:
|
)
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=cors_origins,
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
# Models API is mounted at /api/models
|
# Models API is mounted at /api/models
|
||||||
@@ -374,7 +370,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
app.include_router(runs.router)
|
app.include_router(runs.router)
|
||||||
|
|
||||||
@app.get("/health", tags=["health"])
|
@app.get("/health", tags=["health"])
|
||||||
async def health_check() -> dict:
|
async def health_check() -> dict[str, str]:
|
||||||
"""Health check endpoint.
|
"""Health check endpoint.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class User(BaseModel):
|
|||||||
oauth_id: str | None = Field(None, description="User ID from OAuth provider")
|
oauth_id: str | None = Field(None, description="User ID from OAuth provider")
|
||||||
|
|
||||||
# Auth lifecycle
|
# Auth lifecycle
|
||||||
needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes")
|
needs_setup: bool = Field(default=False, description="True when a reset account must complete setup")
|
||||||
token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs")
|
token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ class GatewayConfig(BaseModel):
|
|||||||
|
|
||||||
host: str = Field(default="0.0.0.0", description="Host to bind the gateway server")
|
host: str = Field(default="0.0.0.0", description="Host to bind the gateway server")
|
||||||
port: int = Field(default=8001, description="Port to bind the gateway server")
|
port: int = Field(default=8001, description="Port to bind the gateway server")
|
||||||
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:3000"], description="Allowed CORS origins")
|
|
||||||
enable_docs: bool = Field(default=True, description="Enable Swagger/ReDoc/OpenAPI endpoints")
|
enable_docs: bool = Field(default=True, description="Enable Swagger/ReDoc/OpenAPI endpoints")
|
||||||
|
|
||||||
|
|
||||||
@@ -19,11 +18,9 @@ def get_gateway_config() -> GatewayConfig:
|
|||||||
"""Get gateway config, loading from environment if available."""
|
"""Get gateway config, loading from environment if available."""
|
||||||
global _gateway_config
|
global _gateway_config
|
||||||
if _gateway_config is None:
|
if _gateway_config is None:
|
||||||
cors_origins_str = os.getenv("CORS_ORIGINS", "http://localhost:3000")
|
|
||||||
_gateway_config = GatewayConfig(
|
_gateway_config = GatewayConfig(
|
||||||
host=os.getenv("GATEWAY_HOST", "0.0.0.0"),
|
host=os.getenv("GATEWAY_HOST", "0.0.0.0"),
|
||||||
port=int(os.getenv("GATEWAY_PORT", "8001")),
|
port=int(os.getenv("GATEWAY_PORT", "8001")),
|
||||||
cors_origins=cors_origins_str.split(","),
|
|
||||||
enable_docs=os.getenv("GATEWAY_ENABLE_DOCS", "true").lower() == "true",
|
enable_docs=os.getenv("GATEWAY_ENABLE_DOCS", "true").lower() == "true",
|
||||||
)
|
)
|
||||||
return _gateway_config
|
return _gateway_config
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ Per RFC-001:
|
|||||||
State-changing operations require CSRF protection.
|
State-changing operations require CSRF protection.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from collections.abc import Callable
|
from collections.abc import Awaitable, Callable
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
@@ -19,7 +21,7 @@ CSRF_TOKEN_LENGTH = 64 # bytes
|
|||||||
|
|
||||||
def is_secure_request(request: Request) -> bool:
|
def is_secure_request(request: Request) -> bool:
|
||||||
"""Detect whether the original client request was made over HTTPS."""
|
"""Detect whether the original client request was made over HTTPS."""
|
||||||
return request.headers.get("x-forwarded-proto", request.url.scheme) == "https"
|
return _request_scheme(request) == "https"
|
||||||
|
|
||||||
|
|
||||||
def generate_csrf_token() -> str:
|
def generate_csrf_token() -> str:
|
||||||
@@ -61,15 +63,129 @@ def is_auth_endpoint(request: Request) -> bool:
|
|||||||
return request.url.path.rstrip("/") in _AUTH_EXEMPT_PATHS
|
return request.url.path.rstrip("/") in _AUTH_EXEMPT_PATHS
|
||||||
|
|
||||||
|
|
||||||
|
def _host_with_optional_port(hostname: str, port: int | None, scheme: str) -> str:
|
||||||
|
"""Return normalized host[:port], omitting default ports."""
|
||||||
|
host = hostname.lower()
|
||||||
|
if ":" in host and not host.startswith("["):
|
||||||
|
host = f"[{host}]"
|
||||||
|
|
||||||
|
if port is None or (scheme == "http" and port == 80) or (scheme == "https" and port == 443):
|
||||||
|
return host
|
||||||
|
return f"{host}:{port}"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_origin(origin: str) -> str | None:
|
||||||
|
"""Return a normalized scheme://host[:port] origin, or None for invalid input."""
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(origin.strip())
|
||||||
|
port = parsed.port
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
scheme = parsed.scheme.lower()
|
||||||
|
if scheme not in {"http", "https"} or not parsed.hostname:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Browser Origin is only scheme/host/port. Reject URL-shaped or credentialed values.
|
||||||
|
if parsed.username or parsed.password or parsed.path or parsed.query or parsed.fragment:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return f"{scheme}://{_host_with_optional_port(parsed.hostname, port, scheme)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_cors_origins() -> set[str]:
|
||||||
|
"""Return explicit configured browser origins that may call auth routes."""
|
||||||
|
origins = set()
|
||||||
|
for raw_origin in os.environ.get("GATEWAY_CORS_ORIGINS", "").split(","):
|
||||||
|
origin = raw_origin.strip()
|
||||||
|
if not origin or origin == "*":
|
||||||
|
continue
|
||||||
|
normalized = _normalize_origin(origin)
|
||||||
|
if normalized:
|
||||||
|
origins.add(normalized)
|
||||||
|
return origins
|
||||||
|
|
||||||
|
|
||||||
|
def get_configured_cors_origins() -> set[str]:
|
||||||
|
"""Return normalized explicit browser origins from GATEWAY_CORS_ORIGINS."""
|
||||||
|
return _configured_cors_origins()
|
||||||
|
|
||||||
|
|
||||||
|
def _first_header_value(value: str | None) -> str | None:
|
||||||
|
"""Return the first value from a comma-separated proxy header."""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
first = value.split(",", 1)[0].strip()
|
||||||
|
return first or None
|
||||||
|
|
||||||
|
|
||||||
|
def _forwarded_param(request: Request, name: str) -> str | None:
|
||||||
|
"""Extract a parameter from the first RFC 7239 Forwarded header entry."""
|
||||||
|
forwarded = _first_header_value(request.headers.get("forwarded"))
|
||||||
|
if not forwarded:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for part in forwarded.split(";"):
|
||||||
|
key, sep, value = part.strip().partition("=")
|
||||||
|
if sep and key.lower() == name:
|
||||||
|
return value.strip().strip('"') or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _request_scheme(request: Request) -> str:
|
||||||
|
"""Resolve the original request scheme from trusted proxy headers."""
|
||||||
|
scheme = _forwarded_param(request, "proto") or _first_header_value(request.headers.get("x-forwarded-proto")) or request.url.scheme
|
||||||
|
return scheme.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _request_origin(request: Request) -> str | None:
|
||||||
|
"""Build the origin for the URL the browser is targeting."""
|
||||||
|
scheme = _request_scheme(request)
|
||||||
|
host = _forwarded_param(request, "host") or _first_header_value(request.headers.get("x-forwarded-host")) or request.headers.get("host") or request.url.netloc
|
||||||
|
|
||||||
|
forwarded_port = _first_header_value(request.headers.get("x-forwarded-port"))
|
||||||
|
if forwarded_port and ":" not in host.rsplit("]", 1)[-1]:
|
||||||
|
host = f"{host}:{forwarded_port}"
|
||||||
|
|
||||||
|
return _normalize_origin(f"{scheme}://{host}")
|
||||||
|
|
||||||
|
|
||||||
|
def is_allowed_auth_origin(request: Request) -> bool:
|
||||||
|
"""Allow auth POSTs only from the same origin or explicit configured origins.
|
||||||
|
|
||||||
|
Login/register/initialize are exempt from the double-submit token because
|
||||||
|
first-time browser clients do not have a CSRF token yet. They still create
|
||||||
|
a session cookie, so browser requests with a hostile Origin header must be
|
||||||
|
rejected to prevent login CSRF / session fixation. Requests without Origin
|
||||||
|
are allowed for non-browser clients such as curl and mobile integrations.
|
||||||
|
"""
|
||||||
|
origin = request.headers.get("origin")
|
||||||
|
if not origin:
|
||||||
|
return True
|
||||||
|
|
||||||
|
normalized_origin = _normalize_origin(origin)
|
||||||
|
if normalized_origin is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
request_origin = _request_origin(request)
|
||||||
|
return normalized_origin in _configured_cors_origins() or (request_origin is not None and normalized_origin == request_origin)
|
||||||
|
|
||||||
|
|
||||||
class CSRFMiddleware(BaseHTTPMiddleware):
|
class CSRFMiddleware(BaseHTTPMiddleware):
|
||||||
"""Middleware that implements CSRF protection using Double Submit Cookie pattern."""
|
"""Middleware that implements CSRF protection using Double Submit Cookie pattern."""
|
||||||
|
|
||||||
def __init__(self, app: ASGIApp) -> None:
|
def __init__(self, app: ASGIApp) -> None:
|
||||||
super().__init__(app)
|
super().__init__(app)
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
|
||||||
_is_auth = is_auth_endpoint(request)
|
_is_auth = is_auth_endpoint(request)
|
||||||
|
|
||||||
|
if should_check_csrf(request) and _is_auth and not is_allowed_auth_origin(request):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content={"detail": "Cross-site auth request denied."},
|
||||||
|
)
|
||||||
|
|
||||||
if should_check_csrf(request) and not _is_auth:
|
if should_check_csrf(request) and not _is_auth:
|
||||||
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
|
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
|
||||||
header_token = request.headers.get(CSRF_HEADER_NAME)
|
header_token = request.headers.get(CSRF_HEADER_NAME)
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
"""LangGraph Server auth handler — shares JWT logic with Gateway.
|
"""LangGraph compatibility auth handler — shares JWT logic with Gateway.
|
||||||
|
|
||||||
Loaded by LangGraph Server via langgraph.json ``auth.path``.
|
The default DeerFlow runtime is embedded in the FastAPI Gateway; scripts and
|
||||||
Reuses the same ``decode_token`` / ``get_auth_config`` as Gateway,
|
Docker deployments do not load this module. It is retained for LangGraph
|
||||||
so both modes validate tokens with the same secret and rules.
|
tooling, Studio, or direct LangGraph Server compatibility through
|
||||||
|
``langgraph.json``'s ``auth.path``.
|
||||||
|
|
||||||
|
When that compatibility path is used, this module reuses the same JWT and CSRF
|
||||||
|
rules as Gateway so both modes validate sessions consistently.
|
||||||
|
|
||||||
Two layers:
|
Two layers:
|
||||||
1. @auth.authenticate — validates JWT cookie, extracts user_id,
|
1. @auth.authenticate — validates JWT cookie, extracts user_id,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from pydantic import BaseModel, Field
|
|||||||
from deerflow.config.agents_api_config import get_agents_api_config
|
from deerflow.config.agents_api_config import get_agents_api_config
|
||||||
from deerflow.config.agents_config import AgentConfig, list_custom_agents, load_agent_config, load_agent_soul
|
from deerflow.config.agents_config import AgentConfig, list_custom_agents, load_agent_config, load_agent_soul
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api", tags=["agents"])
|
router = APIRouter(prefix="/api", tags=["agents"])
|
||||||
@@ -86,11 +87,11 @@ def _require_agents_api_enabled() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False) -> AgentResponse:
|
def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False, *, user_id: str | None = None) -> AgentResponse:
|
||||||
"""Convert AgentConfig to AgentResponse."""
|
"""Convert AgentConfig to AgentResponse."""
|
||||||
soul: str | None = None
|
soul: str | None = None
|
||||||
if include_soul:
|
if include_soul:
|
||||||
soul = load_agent_soul(agent_cfg.name) or ""
|
soul = load_agent_soul(agent_cfg.name, user_id=user_id) or ""
|
||||||
|
|
||||||
return AgentResponse(
|
return AgentResponse(
|
||||||
name=agent_cfg.name,
|
name=agent_cfg.name,
|
||||||
@@ -116,9 +117,10 @@ async def list_agents() -> AgentsListResponse:
|
|||||||
"""
|
"""
|
||||||
_require_agents_api_enabled()
|
_require_agents_api_enabled()
|
||||||
|
|
||||||
|
user_id = get_effective_user_id()
|
||||||
try:
|
try:
|
||||||
agents = list_custom_agents()
|
agents = list_custom_agents(user_id=user_id)
|
||||||
return AgentsListResponse(agents=[_agent_config_to_response(a, include_soul=True) for a in agents])
|
return AgentsListResponse(agents=[_agent_config_to_response(a, include_soul=True, user_id=user_id) for a in agents])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to list agents: {e}", exc_info=True)
|
logger.error(f"Failed to list agents: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}")
|
||||||
@@ -144,7 +146,12 @@ async def check_agent_name(name: str) -> dict:
|
|||||||
_require_agents_api_enabled()
|
_require_agents_api_enabled()
|
||||||
_validate_agent_name(name)
|
_validate_agent_name(name)
|
||||||
normalized = _normalize_agent_name(name)
|
normalized = _normalize_agent_name(name)
|
||||||
available = not get_paths().agent_dir(normalized).exists()
|
user_id = get_effective_user_id()
|
||||||
|
paths = get_paths()
|
||||||
|
# Treat the name as taken if either the per-user path or the legacy shared
|
||||||
|
# path holds an agent — picking a name that collides with an unmigrated
|
||||||
|
# legacy agent would shadow the legacy entry once migration runs.
|
||||||
|
available = not paths.user_agent_dir(user_id, normalized).exists() and not paths.agent_dir(normalized).exists()
|
||||||
return {"available": available, "name": normalized}
|
return {"available": available, "name": normalized}
|
||||||
|
|
||||||
|
|
||||||
@@ -169,10 +176,11 @@ async def get_agent(name: str) -> AgentResponse:
|
|||||||
_require_agents_api_enabled()
|
_require_agents_api_enabled()
|
||||||
_validate_agent_name(name)
|
_validate_agent_name(name)
|
||||||
name = _normalize_agent_name(name)
|
name = _normalize_agent_name(name)
|
||||||
|
user_id = get_effective_user_id()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
agent_cfg = load_agent_config(name)
|
agent_cfg = load_agent_config(name, user_id=user_id)
|
||||||
return _agent_config_to_response(agent_cfg, include_soul=True)
|
return _agent_config_to_response(agent_cfg, include_soul=True, user_id=user_id)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -202,10 +210,13 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
|
|||||||
_require_agents_api_enabled()
|
_require_agents_api_enabled()
|
||||||
_validate_agent_name(request.name)
|
_validate_agent_name(request.name)
|
||||||
normalized_name = _normalize_agent_name(request.name)
|
normalized_name = _normalize_agent_name(request.name)
|
||||||
|
user_id = get_effective_user_id()
|
||||||
|
paths = get_paths()
|
||||||
|
|
||||||
agent_dir = get_paths().agent_dir(normalized_name)
|
agent_dir = paths.user_agent_dir(user_id, normalized_name)
|
||||||
|
legacy_dir = paths.agent_dir(normalized_name)
|
||||||
|
|
||||||
if agent_dir.exists():
|
if agent_dir.exists() or legacy_dir.exists():
|
||||||
raise HTTPException(status_code=409, detail=f"Agent '{normalized_name}' already exists")
|
raise HTTPException(status_code=409, detail=f"Agent '{normalized_name}' already exists")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -232,8 +243,8 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
|
|||||||
|
|
||||||
logger.info(f"Created agent '{normalized_name}' at {agent_dir}")
|
logger.info(f"Created agent '{normalized_name}' at {agent_dir}")
|
||||||
|
|
||||||
agent_cfg = load_agent_config(normalized_name)
|
agent_cfg = load_agent_config(normalized_name, user_id=user_id)
|
||||||
return _agent_config_to_response(agent_cfg, include_soul=True)
|
return _agent_config_to_response(agent_cfg, include_soul=True, user_id=user_id)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -267,13 +278,20 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
|
|||||||
_require_agents_api_enabled()
|
_require_agents_api_enabled()
|
||||||
_validate_agent_name(name)
|
_validate_agent_name(name)
|
||||||
name = _normalize_agent_name(name)
|
name = _normalize_agent_name(name)
|
||||||
|
user_id = get_effective_user_id()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
agent_cfg = load_agent_config(name)
|
agent_cfg = load_agent_config(name, user_id=user_id)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
||||||
|
|
||||||
agent_dir = get_paths().agent_dir(name)
|
paths = get_paths()
|
||||||
|
agent_dir = paths.user_agent_dir(user_id, name)
|
||||||
|
if not agent_dir.exists() and 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 updating."),
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Update config if any config fields changed
|
# Update config if any config fields changed
|
||||||
@@ -314,8 +332,8 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
|
|||||||
|
|
||||||
logger.info(f"Updated agent '{name}'")
|
logger.info(f"Updated agent '{name}'")
|
||||||
|
|
||||||
refreshed_cfg = load_agent_config(name)
|
refreshed_cfg = load_agent_config(name, user_id=user_id)
|
||||||
return _agent_config_to_response(refreshed_cfg, include_soul=True)
|
return _agent_config_to_response(refreshed_cfg, include_soul=True, user_id=user_id)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -402,15 +420,22 @@ async def delete_agent(name: str) -> None:
|
|||||||
name: The agent name.
|
name: The agent name.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: 404 if agent not found.
|
HTTPException: 404 if no per-user copy exists; 409 if only a legacy
|
||||||
|
shared copy exists (suggesting the migration script).
|
||||||
"""
|
"""
|
||||||
_require_agents_api_enabled()
|
_require_agents_api_enabled()
|
||||||
_validate_agent_name(name)
|
_validate_agent_name(name)
|
||||||
name = _normalize_agent_name(name)
|
name = _normalize_agent_name(name)
|
||||||
|
user_id = get_effective_user_id()
|
||||||
agent_dir = get_paths().agent_dir(name)
|
paths = get_paths()
|
||||||
|
agent_dir = paths.user_agent_dir(user_id, name)
|
||||||
|
|
||||||
if not agent_dir.exists():
|
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")
|
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ async def login_local(
|
|||||||
async def register(request: Request, response: Response, body: RegisterRequest):
|
async def register(request: Request, response: Response, body: RegisterRequest):
|
||||||
"""Register a new user account (always 'user' role).
|
"""Register a new user account (always 'user' role).
|
||||||
|
|
||||||
Admin is auto-created on first boot. This endpoint creates regular users.
|
The first admin is created explicitly through /initialize. This endpoint creates regular users.
|
||||||
Auto-login by setting the session cookie.
|
Auto-login by setting the session cookie.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -68,6 +68,27 @@ class RunResponse(BaseModel):
|
|||||||
updated_at: str = ""
|
updated_at: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadTokenUsageModelBreakdown(BaseModel):
|
||||||
|
tokens: int = 0
|
||||||
|
runs: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadTokenUsageCallerBreakdown(BaseModel):
|
||||||
|
lead_agent: int = 0
|
||||||
|
subagent: int = 0
|
||||||
|
middleware: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadTokenUsageResponse(BaseModel):
|
||||||
|
thread_id: str
|
||||||
|
total_tokens: int = 0
|
||||||
|
total_input_tokens: int = 0
|
||||||
|
total_output_tokens: int = 0
|
||||||
|
total_runs: int = 0
|
||||||
|
by_model: dict[str, ThreadTokenUsageModelBreakdown] = Field(default_factory=dict)
|
||||||
|
by_caller: ThreadTokenUsageCallerBreakdown = Field(default_factory=ThreadTokenUsageCallerBreakdown)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -368,10 +389,10 @@ async def list_run_events(
|
|||||||
return await event_store.list_events(thread_id, run_id, event_types=types, limit=limit)
|
return await event_store.list_events(thread_id, run_id, event_types=types, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{thread_id}/token-usage")
|
@router.get("/{thread_id}/token-usage", response_model=ThreadTokenUsageResponse)
|
||||||
@require_permission("threads", "read", owner_check=True)
|
@require_permission("threads", "read", owner_check=True)
|
||||||
async def thread_token_usage(thread_id: str, request: Request) -> dict:
|
async def thread_token_usage(thread_id: str, request: Request) -> ThreadTokenUsageResponse:
|
||||||
"""Thread-level token usage aggregation."""
|
"""Thread-level token usage aggregation."""
|
||||||
run_store = get_run_store(request)
|
run_store = get_run_store(request)
|
||||||
agg = await run_store.aggregate_tokens_by_thread(thread_id)
|
agg = await run_store.aggregate_tokens_by_thread(thread_id)
|
||||||
return {"thread_id": thread_id, **agg}
|
return ThreadTokenUsageResponse(thread_id=thread_id, **agg)
|
||||||
|
|||||||
@@ -90,6 +90,28 @@ class ThreadSearchRequest(BaseModel):
|
|||||||
offset: int = Field(default=0, ge=0, description="Pagination offset")
|
offset: int = Field(default=0, ge=0, description="Pagination offset")
|
||||||
status: str | None = Field(default=None, description="Filter by thread status")
|
status: str | None = Field(default=None, description="Filter by thread status")
|
||||||
|
|
||||||
|
@field_validator("metadata")
|
||||||
|
@classmethod
|
||||||
|
def _validate_metadata_filters(cls, v: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Reject filter entries the SQL backend cannot compile.
|
||||||
|
|
||||||
|
Enforces consistent behaviour across SQL and memory backends.
|
||||||
|
See ``deerflow.persistence.json_compat`` for the shared validators.
|
||||||
|
"""
|
||||||
|
if not v:
|
||||||
|
return v
|
||||||
|
from deerflow.persistence.json_compat import validate_metadata_filter_key, validate_metadata_filter_value
|
||||||
|
|
||||||
|
bad_entries: list[str] = []
|
||||||
|
for key, value in v.items():
|
||||||
|
if not validate_metadata_filter_key(key):
|
||||||
|
bad_entries.append(f"{key!r} (unsafe key)")
|
||||||
|
elif not validate_metadata_filter_value(value):
|
||||||
|
bad_entries.append(f"{key!r} (unsupported value type {type(value).__name__})")
|
||||||
|
if bad_entries:
|
||||||
|
raise ValueError(f"Invalid metadata filter entries: {', '.join(bad_entries)}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class ThreadStateResponse(BaseModel):
|
class ThreadStateResponse(BaseModel):
|
||||||
"""Response model for thread state."""
|
"""Response model for thread state."""
|
||||||
@@ -294,14 +316,18 @@ async def search_threads(body: ThreadSearchRequest, request: Request) -> list[Th
|
|||||||
(SQL-backed for sqlite/postgres, Store-backed for memory mode).
|
(SQL-backed for sqlite/postgres, Store-backed for memory mode).
|
||||||
"""
|
"""
|
||||||
from app.gateway.deps import get_thread_store
|
from app.gateway.deps import get_thread_store
|
||||||
|
from deerflow.persistence.thread_meta import InvalidMetadataFilterError
|
||||||
|
|
||||||
repo = get_thread_store(request)
|
repo = get_thread_store(request)
|
||||||
rows = await repo.search(
|
try:
|
||||||
metadata=body.metadata or None,
|
rows = await repo.search(
|
||||||
status=body.status,
|
metadata=body.metadata or None,
|
||||||
limit=body.limit,
|
status=body.status,
|
||||||
offset=body.offset,
|
limit=body.limit,
|
||||||
)
|
offset=body.offset,
|
||||||
|
)
|
||||||
|
except InvalidMetadataFilterError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
return [
|
return [
|
||||||
ThreadResponse(
|
ThreadResponse(
|
||||||
thread_id=r["thread_id"],
|
thread_id=r["thread_id"],
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from deerflow.sandbox.sandbox_provider import SandboxProvider, get_sandbox_provi
|
|||||||
from deerflow.uploads.manager import (
|
from deerflow.uploads.manager import (
|
||||||
PathTraversalError,
|
PathTraversalError,
|
||||||
UnsafeUploadPathError,
|
UnsafeUploadPathError,
|
||||||
|
claim_unique_filename,
|
||||||
delete_file_safe,
|
delete_file_safe,
|
||||||
enrich_file_listing,
|
enrich_file_listing,
|
||||||
ensure_uploads_dir,
|
ensure_uploads_dir,
|
||||||
@@ -192,6 +193,10 @@ async def upload_files(
|
|||||||
sandbox_sync_targets = []
|
sandbox_sync_targets = []
|
||||||
skipped_files = []
|
skipped_files = []
|
||||||
total_size = 0
|
total_size = 0
|
||||||
|
# Track filenames within this request so duplicate form parts do not
|
||||||
|
# silently truncate each other. Existing uploads keep the historical
|
||||||
|
# overwrite behavior for a single replacement upload.
|
||||||
|
seen_filenames: set[str] = set()
|
||||||
|
|
||||||
sandbox_provider = get_sandbox_provider()
|
sandbox_provider = get_sandbox_provider()
|
||||||
sync_to_sandbox = not _uses_thread_data_mounts(sandbox_provider)
|
sync_to_sandbox = not _uses_thread_data_mounts(sandbox_provider)
|
||||||
@@ -208,7 +213,8 @@ async def upload_files(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
safe_filename = normalize_filename(file.filename)
|
original_filename = normalize_filename(file.filename)
|
||||||
|
safe_filename = claim_unique_filename(original_filename, seen_filenames)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"Skipping file with unsafe filename: {file.filename!r}")
|
logger.warning(f"Skipping file with unsafe filename: {file.filename!r}")
|
||||||
continue
|
continue
|
||||||
@@ -236,6 +242,8 @@ async def upload_files(
|
|||||||
"virtual_path": virtual_path,
|
"virtual_path": virtual_path,
|
||||||
"artifact_url": upload_artifact_url(thread_id, safe_filename),
|
"artifact_url": upload_artifact_url(thread_id, safe_filename),
|
||||||
}
|
}
|
||||||
|
if safe_filename != original_filename:
|
||||||
|
file_info["original_filename"] = original_filename
|
||||||
|
|
||||||
logger.info(f"Saved file: {safe_filename} ({file_size} bytes) to {file_info['path']}")
|
logger.info(f"Saved file: {safe_filename} ({file_size} bytes) to {file_info['path']}")
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from langchain_core.messages import HumanMessage
|
|||||||
|
|
||||||
from app.gateway.deps import get_run_context, get_run_manager, get_stream_bridge
|
from app.gateway.deps import get_run_context, get_run_manager, get_stream_bridge
|
||||||
from app.gateway.utils import sanitize_log_param
|
from app.gateway.utils import sanitize_log_param
|
||||||
|
from deerflow.config.app_config import get_app_config
|
||||||
from deerflow.runtime import (
|
from deerflow.runtime import (
|
||||||
END_SENTINEL,
|
END_SENTINEL,
|
||||||
HEARTBEAT_SENTINEL,
|
HEARTBEAT_SENTINEL,
|
||||||
@@ -136,6 +137,24 @@ def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, An
|
|||||||
runtime_context.setdefault(key, context[key])
|
runtime_context.setdefault(key, context[key])
|
||||||
|
|
||||||
|
|
||||||
|
def inject_authenticated_user_context(config: dict[str, Any], request: Request) -> None:
|
||||||
|
"""Stamp the authenticated user into the run context for background tools.
|
||||||
|
|
||||||
|
Tool execution may happen after the request handler has returned, so tools
|
||||||
|
that persist user-scoped files should not rely only on ambient ContextVars.
|
||||||
|
The value comes from server-side auth state, never from client context.
|
||||||
|
"""
|
||||||
|
|
||||||
|
user = getattr(request.state, "user", None)
|
||||||
|
user_id = getattr(user, "id", None)
|
||||||
|
if user_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
runtime_context = config.setdefault("context", {})
|
||||||
|
if isinstance(runtime_context, dict):
|
||||||
|
runtime_context["user_id"] = str(user_id)
|
||||||
|
|
||||||
|
|
||||||
def resolve_agent_factory(assistant_id: str | None):
|
def resolve_agent_factory(assistant_id: str | None):
|
||||||
"""Resolve the agent factory callable from config.
|
"""Resolve the agent factory callable from config.
|
||||||
|
|
||||||
@@ -249,6 +268,23 @@ async def start_run(
|
|||||||
|
|
||||||
disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_
|
disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_
|
||||||
|
|
||||||
|
body_context = getattr(body, "context", None) or {}
|
||||||
|
model_name = body_context.get("model_name")
|
||||||
|
|
||||||
|
# Coerce non-string model_name values to str before truncation.
|
||||||
|
if model_name is not None and not isinstance(model_name, str):
|
||||||
|
model_name = str(model_name)
|
||||||
|
|
||||||
|
# Validate model against the allowlist when a model_name is provided.
|
||||||
|
if model_name:
|
||||||
|
app_config = get_app_config()
|
||||||
|
resolved = app_config.get_model_config(model_name)
|
||||||
|
if resolved is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Model {model_name!r} is not in the configured model allowlist",
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
record = await run_mgr.create_or_reject(
|
record = await run_mgr.create_or_reject(
|
||||||
thread_id,
|
thread_id,
|
||||||
@@ -257,6 +293,7 @@ async def start_run(
|
|||||||
metadata=body.metadata or {},
|
metadata=body.metadata or {},
|
||||||
kwargs={"input": body.input, "config": body.config},
|
kwargs={"input": body.input, "config": body.config},
|
||||||
multitask_strategy=body.multitask_strategy,
|
multitask_strategy=body.multitask_strategy,
|
||||||
|
model_name=model_name,
|
||||||
)
|
)
|
||||||
except ConflictError as exc:
|
except ConflictError as exc:
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
@@ -288,6 +325,7 @@ async def start_run(
|
|||||||
# that carries agent configuration (model_name, thinking_enabled, etc.).
|
# that carries agent configuration (model_name, thinking_enabled, etc.).
|
||||||
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
|
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
|
||||||
merge_run_context_overrides(config, getattr(body, "context", None))
|
merge_run_context_overrides(config, getattr(body, "context", None))
|
||||||
|
inject_authenticated_user_context(config, request)
|
||||||
|
|
||||||
stream_modes = normalize_stream_modes(body.stream_mode)
|
stream_modes = normalize_stream_modes(body.stream_mode)
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ async def main():
|
|||||||
from langgraph.runtime import Runtime
|
from langgraph.runtime import Runtime
|
||||||
|
|
||||||
from deerflow.agents import make_lead_agent
|
from deerflow.agents import make_lead_agent
|
||||||
|
from deerflow.config.paths import get_paths
|
||||||
from deerflow.mcp import initialize_mcp_tools
|
from deerflow.mcp import initialize_mcp_tools
|
||||||
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
# Initialize MCP tools at startup
|
# Initialize MCP tools at startup
|
||||||
try:
|
try:
|
||||||
@@ -113,6 +115,8 @@ async def main():
|
|||||||
print("Tip: `uv sync --group dev` to enable arrow-key & history support")
|
print("Tip: `uv sync --group dev` to enable arrow-key & history support")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
||||||
|
seen_artifacts: set[str] = set()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
if session:
|
if session:
|
||||||
@@ -134,6 +138,22 @@ async def main():
|
|||||||
last_message = result["messages"][-1]
|
last_message = result["messages"][-1]
|
||||||
print(f"\nAgent: {last_message.content}")
|
print(f"\nAgent: {last_message.content}")
|
||||||
|
|
||||||
|
# Show files presented to the user this turn (new artifacts only)
|
||||||
|
artifacts = result.get("artifacts") or []
|
||||||
|
new_artifacts = [p for p in artifacts if p not in seen_artifacts]
|
||||||
|
if new_artifacts:
|
||||||
|
thread_id = config["configurable"]["thread_id"]
|
||||||
|
user_id = get_effective_user_id()
|
||||||
|
paths = get_paths()
|
||||||
|
print("\n[Presented files]")
|
||||||
|
for virtual in new_artifacts:
|
||||||
|
try:
|
||||||
|
physical = paths.resolve_virtual_path(thread_id, virtual, user_id=user_id)
|
||||||
|
print(f" - {virtual}\n → {physical}")
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f" - {virtual} (failed to resolve physical path: {exc})")
|
||||||
|
seen_artifacts.update(new_artifacts)
|
||||||
|
|
||||||
except (KeyboardInterrupt, EOFError):
|
except (KeyboardInterrupt, EOFError):
|
||||||
print("\nGoodbye!")
|
print("\nGoodbye!")
|
||||||
break
|
break
|
||||||
|
|||||||
+52
-35
@@ -6,16 +6,16 @@ This document provides a complete reference for the DeerFlow backend APIs.
|
|||||||
|
|
||||||
DeerFlow backend exposes two sets of APIs:
|
DeerFlow backend exposes two sets of APIs:
|
||||||
|
|
||||||
1. **LangGraph API** - Agent interactions, threads, and streaming (`/api/langgraph/*`)
|
1. **LangGraph-compatible API** - Agent interactions, threads, and streaming (`/api/langgraph/*`)
|
||||||
2. **Gateway API** - Models, MCP, skills, uploads, and artifacts (`/api/*`)
|
2. **Gateway API** - Models, MCP, skills, uploads, and artifacts (`/api/*`)
|
||||||
|
|
||||||
All APIs are accessed through the Nginx reverse proxy at port 2026.
|
All APIs are accessed through the Nginx reverse proxy at port 2026.
|
||||||
|
|
||||||
## LangGraph API
|
## LangGraph-compatible API
|
||||||
|
|
||||||
Base URL: `/api/langgraph`
|
Base URL: `/api/langgraph`
|
||||||
|
|
||||||
The LangGraph API is provided by the LangGraph server and follows the LangGraph SDK conventions.
|
The public LangGraph-compatible API follows LangGraph SDK conventions. In the unified nginx deployment, Gateway owns `/api/langgraph/*` and translates those paths to its native `/api/*` run, thread, and streaming routers.
|
||||||
|
|
||||||
### Threads
|
### Threads
|
||||||
|
|
||||||
@@ -104,17 +104,11 @@ Content-Type: application/json
|
|||||||
**Recursion Limit:**
|
**Recursion Limit:**
|
||||||
|
|
||||||
`config.recursion_limit` caps the number of graph steps LangGraph will execute
|
`config.recursion_limit` caps the number of graph steps LangGraph will execute
|
||||||
in a single run. The `/api/langgraph/*` endpoints go straight to the LangGraph
|
in a single run. The unified Gateway path defaults to `100` in
|
||||||
server and therefore inherit LangGraph's native default of **25**, which is
|
`build_run_config` (see `backend/app/gateway/services.py`), which is a safer
|
||||||
too low for plan-mode or subagent-heavy runs — the agent typically errors out
|
starting point for plan-mode or subagent-heavy runs. Clients can still set
|
||||||
with `GraphRecursionError` after the first round of subagent results comes
|
`recursion_limit` explicitly in the request body; increase it if you run deeply
|
||||||
back, before the lead agent can synthesize the final answer.
|
nested subagent graphs.
|
||||||
|
|
||||||
DeerFlow's own Gateway and IM-channel paths mitigate this by defaulting to
|
|
||||||
`100` in `build_run_config` (see `backend/app/gateway/services.py`), but
|
|
||||||
clients calling the LangGraph API directly must set `recursion_limit`
|
|
||||||
explicitly in the request body. `100` matches the Gateway default and is a
|
|
||||||
safe starting point; increase it if you run deeply nested subagent graphs.
|
|
||||||
|
|
||||||
**Configurable Options:**
|
**Configurable Options:**
|
||||||
- `model_name` (string): Override the default model
|
- `model_name` (string): Override the default model
|
||||||
@@ -541,14 +535,28 @@ All APIs return errors in a consistent format:
|
|||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Currently, DeerFlow does not implement authentication. All APIs are accessible without credentials.
|
DeerFlow enforces authentication for all non-public HTTP routes. Public routes are limited to health/docs metadata and these public auth endpoints:
|
||||||
|
|
||||||
Note: This is about DeerFlow API authentication. MCP outbound connections can still use OAuth for configured HTTP/SSE MCP servers.
|
- `POST /api/v1/auth/initialize` creates the first admin account when no admin exists.
|
||||||
|
- `POST /api/v1/auth/login/local` logs in with email/password and sets an HttpOnly `access_token` cookie.
|
||||||
|
- `POST /api/v1/auth/register` creates a regular `user` account and sets the session cookie.
|
||||||
|
- `POST /api/v1/auth/logout` clears the session cookie.
|
||||||
|
- `GET /api/v1/auth/setup-status` reports whether the first admin still needs to be created.
|
||||||
|
|
||||||
For production deployments, it is recommended to:
|
The authenticated auth endpoints are:
|
||||||
1. Use Nginx for basic auth or OAuth integration
|
|
||||||
2. Deploy behind a VPN or private network
|
- `GET /api/v1/auth/me` returns the current user.
|
||||||
3. Implement custom authentication middleware
|
- `POST /api/v1/auth/change-password` changes password, optionally changes email during setup, increments `token_version`, and reissues the cookie.
|
||||||
|
|
||||||
|
Protected state-changing requests also require the CSRF double-submit token: send the `csrf_token` cookie value as the `X-CSRF-Token` header. Login/register/initialize/logout are bootstrap auth endpoints: they are exempt from the double-submit token but still reject hostile browser `Origin` headers.
|
||||||
|
|
||||||
|
User isolation is enforced from the authenticated user context:
|
||||||
|
|
||||||
|
- Thread metadata is scoped by `threads_meta.user_id`; search/read/write/delete APIs only expose the current user's threads.
|
||||||
|
- Thread files live under `{base_dir}/users/{user_id}/threads/{thread_id}/user-data/` and are exposed inside the sandbox as `/mnt/user-data/`.
|
||||||
|
- Memory and custom agents are stored under `{base_dir}/users/{user_id}/...`.
|
||||||
|
|
||||||
|
Note: MCP outbound connections can still use OAuth for configured HTTP/SSE MCP servers; that is separate from DeerFlow API authentication.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -567,12 +575,13 @@ location /api/ {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## WebSocket Support
|
## Streaming Support
|
||||||
|
|
||||||
The LangGraph server supports WebSocket connections for real-time streaming. Connect to:
|
Gateway's LangGraph-compatible API streams run events with Server-Sent Events (SSE):
|
||||||
|
|
||||||
```
|
```http
|
||||||
ws://localhost:2026/api/langgraph/threads/{thread_id}/runs/stream
|
POST /api/langgraph/threads/{thread_id}/runs/stream
|
||||||
|
Accept: text/event-stream
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -608,13 +617,21 @@ const response = await fetch('/api/models');
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log(data.models);
|
console.log(data.models);
|
||||||
|
|
||||||
// Using EventSource for streaming
|
// Create a run and stream SSE events
|
||||||
const eventSource = new EventSource(
|
const streamResponse = await fetch(`/api/langgraph/threads/${threadId}/runs/stream`, {
|
||||||
`/api/langgraph/threads/${threadId}/runs/stream`
|
method: "POST",
|
||||||
);
|
headers: {
|
||||||
eventSource.onmessage = (event) => {
|
"Content-Type": "application/json",
|
||||||
console.log(JSON.parse(event.data));
|
Accept: "text/event-stream",
|
||||||
};
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
input: { messages: [{ role: "user", content: "Hello" }] },
|
||||||
|
stream_mode: ["values", "messages-tuple", "custom"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reader = streamResponse.body?.getReader();
|
||||||
|
// Decode and parse SSE frames from reader in your client code.
|
||||||
```
|
```
|
||||||
|
|
||||||
### cURL Examples
|
### cURL Examples
|
||||||
@@ -649,7 +666,7 @@ curl -X POST http://localhost:2026/api/langgraph/threads/abc123/runs \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
> The `/api/langgraph/*` endpoints bypass DeerFlow's Gateway and inherit
|
> The unified Gateway path defaults `config.recursion_limit` to 100 for
|
||||||
> LangGraph's native `recursion_limit` default of 25, which is too low for
|
> plan-mode and subagent-heavy runs. Clients may still set
|
||||||
> plan-mode or subagent runs. Set `config.recursion_limit` explicitly — see
|
> `config.recursion_limit` explicitly — see the [Create Run](#create-run)
|
||||||
> the [Create Run](#create-run) section for details.
|
> section for details.
|
||||||
|
|||||||
@@ -14,30 +14,28 @@ This document provides a comprehensive overview of the DeerFlow backend architec
|
|||||||
│ Nginx (Port 2026) │
|
│ Nginx (Port 2026) │
|
||||||
│ Unified Reverse Proxy Entry Point │
|
│ Unified Reverse Proxy Entry Point │
|
||||||
│ ┌────────────────────────────────────────────────────────────────────┐ │
|
│ ┌────────────────────────────────────────────────────────────────────┐ │
|
||||||
│ │ /api/langgraph/* → LangGraph Server (2024) │ │
|
│ │ /api/langgraph/* → Gateway LangGraph-compatible runtime (8001) │ │
|
||||||
│ │ /api/* → Gateway API (8001) │ │
|
│ │ /api/* → Gateway REST APIs (8001) │ │
|
||||||
│ │ /* → Frontend (3000) │ │
|
│ │ /* → Frontend (3000) │ │
|
||||||
│ └────────────────────────────────────────────────────────────────────┘ │
|
│ └────────────────────────────────────────────────────────────────────┘ │
|
||||||
└─────────────────────────────────┬────────────────────────────────────────┘
|
└─────────────────────────────────┬────────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
┌───────────────────────┼───────────────────────┐
|
┌───────────────────────┴───────────────────────┐
|
||||||
│ │ │
|
│ │
|
||||||
▼ ▼ ▼
|
▼ ▼
|
||||||
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
|
┌─────────────────────────────────────────────┐ ┌─────────────────────┐
|
||||||
│ LangGraph Server │ │ Gateway API │ │ Frontend │
|
│ Gateway API │ │ Frontend │
|
||||||
│ (Port 2024) │ │ (Port 8001) │ │ (Port 3000) │
|
│ (Port 8001) │ │ (Port 3000) │
|
||||||
│ │ │ │ │ │
|
│ │ │ │
|
||||||
│ - Agent Runtime │ │ - Models API │ │ - Next.js App │
|
│ - LangGraph-compatible runs/threads API │ │ - Next.js App │
|
||||||
│ - Thread Mgmt │ │ - MCP Config │ │ - React UI │
|
│ - Embedded Agent Runtime │ │ - React UI │
|
||||||
│ - SSE Streaming │ │ - Skills Mgmt │ │ - Chat Interface │
|
│ - SSE Streaming │ │ - Chat Interface │
|
||||||
│ - Checkpointing │ │ - File Uploads │ │ │
|
│ - Checkpointing │ │ │
|
||||||
│ │ │ - Thread Cleanup │ │ │
|
│ - Models, MCP, Skills, Uploads, Artifacts │ │ │
|
||||||
│ │ │ - Artifacts │ │ │
|
│ - Thread Cleanup │ │ │
|
||||||
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘
|
└─────────────────────────────────────────────┘ └─────────────────────┘
|
||||||
│ │
|
│
|
||||||
│ ┌─────────────────┘
|
▼
|
||||||
│ │
|
|
||||||
▼ ▼
|
|
||||||
┌──────────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────┐
|
||||||
│ Shared Configuration │
|
│ Shared Configuration │
|
||||||
│ ┌─────────────────────────┐ ┌────────────────────────────────────────┐ │
|
│ ┌─────────────────────────┐ ┌────────────────────────────────────────┐ │
|
||||||
@@ -52,9 +50,9 @@ This document provides a comprehensive overview of the DeerFlow backend architec
|
|||||||
|
|
||||||
## Component Details
|
## Component Details
|
||||||
|
|
||||||
### LangGraph Server
|
### Gateway Embedded Agent Runtime
|
||||||
|
|
||||||
The LangGraph server is the core agent runtime, built on LangGraph for robust multi-agent workflow orchestration.
|
The agent runtime is embedded in the FastAPI Gateway and built on LangGraph for robust multi-agent workflow orchestration. Nginx rewrites `/api/langgraph/*` to Gateway's native `/api/*` routes, so the public API remains compatible with LangGraph SDK clients without running a separate LangGraph server.
|
||||||
|
|
||||||
**Entry Point**: `packages/harness/deerflow/agents/lead_agent/agent.py:make_lead_agent`
|
**Entry Point**: `packages/harness/deerflow/agents/lead_agent/agent.py:make_lead_agent`
|
||||||
|
|
||||||
@@ -65,7 +63,8 @@ The LangGraph server is the core agent runtime, built on LangGraph for robust mu
|
|||||||
- Tool execution orchestration
|
- Tool execution orchestration
|
||||||
- SSE streaming for real-time responses
|
- SSE streaming for real-time responses
|
||||||
|
|
||||||
**Configuration**: `langgraph.json`
|
**Graph registry**: `langgraph.json` remains available for tooling, Studio, or direct LangGraph Server compatibility.
|
||||||
|
It is not the default service entrypoint; scripts and Docker deployments run the Gateway embedded runtime.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -78,12 +77,13 @@ The LangGraph server is the core agent runtime, built on LangGraph for robust mu
|
|||||||
|
|
||||||
### Gateway API
|
### Gateway API
|
||||||
|
|
||||||
FastAPI application providing REST endpoints for non-agent operations.
|
FastAPI application providing REST endpoints plus the public LangGraph-compatible `/api/langgraph/*` runtime routes.
|
||||||
|
|
||||||
**Entry Point**: `app/gateway/app.py`
|
**Entry Point**: `app/gateway/app.py`
|
||||||
|
|
||||||
**Routers**:
|
**Routers**:
|
||||||
- `models.py` - `/api/models` - Model listing and details
|
- `models.py` - `/api/models` - Model listing and details
|
||||||
|
- `thread_runs.py` / `runs.py` - `/api/threads/{id}/runs`, `/api/runs/*` - LangGraph-compatible runs and streaming
|
||||||
- `mcp.py` - `/api/mcp` - MCP server configuration
|
- `mcp.py` - `/api/mcp` - MCP server configuration
|
||||||
- `skills.py` - `/api/skills` - Skills management
|
- `skills.py` - `/api/skills` - Skills management
|
||||||
- `uploads.py` - `/api/threads/{id}/uploads` - File upload
|
- `uploads.py` - `/api/threads/{id}/uploads` - File upload
|
||||||
@@ -91,7 +91,7 @@ FastAPI application providing REST endpoints for non-agent operations.
|
|||||||
- `artifacts.py` - `/api/threads/{id}/artifacts` - Artifact serving
|
- `artifacts.py` - `/api/threads/{id}/artifacts` - Artifact serving
|
||||||
- `suggestions.py` - `/api/threads/{id}/suggestions` - Follow-up suggestion generation
|
- `suggestions.py` - `/api/threads/{id}/suggestions` - Follow-up suggestion generation
|
||||||
|
|
||||||
The web conversation delete flow is now split across both backend surfaces: LangGraph handles `DELETE /api/langgraph/threads/{thread_id}` for thread state, then the Gateway `threads.py` router removes DeerFlow-managed filesystem data via `Paths.delete_thread_dir()`.
|
The web conversation delete flow first deletes Gateway-managed thread state through the LangGraph-compatible route, then the Gateway `threads.py` router removes DeerFlow-managed filesystem data via `Paths.delete_thread_dir()`.
|
||||||
|
|
||||||
### Agent Architecture
|
### Agent Architecture
|
||||||
|
|
||||||
@@ -353,10 +353,10 @@ SKILL.md Format:
|
|||||||
POST /api/langgraph/threads/{thread_id}/runs
|
POST /api/langgraph/threads/{thread_id}/runs
|
||||||
{"input": {"messages": [{"role": "user", "content": "Hello"}]}}
|
{"input": {"messages": [{"role": "user", "content": "Hello"}]}}
|
||||||
|
|
||||||
2. Nginx → LangGraph Server (2024)
|
2. Nginx → Gateway API (8001)
|
||||||
Proxied to LangGraph server
|
`/api/langgraph/*` is rewritten to Gateway's LangGraph-compatible `/api/*` routes
|
||||||
|
|
||||||
3. LangGraph Server
|
3. Gateway embedded runtime
|
||||||
a. Load/create thread state
|
a. Load/create thread state
|
||||||
b. Execute middleware chain:
|
b. Execute middleware chain:
|
||||||
- ThreadDataMiddleware: Set up paths
|
- ThreadDataMiddleware: Set up paths
|
||||||
@@ -412,7 +412,7 @@ SKILL.md Format:
|
|||||||
### Thread Cleanup Flow
|
### Thread Cleanup Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Client deletes conversation via LangGraph
|
1. Client deletes conversation via the LangGraph-compatible Gateway route
|
||||||
DELETE /api/langgraph/threads/{thread_id}
|
DELETE /api/langgraph/threads/{thread_id}
|
||||||
|
|
||||||
2. Web UI follows up with Gateway cleanup
|
2. Web UI follows up with Gateway cleanup
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
# 用户认证与隔离设计
|
||||||
|
|
||||||
|
本文档描述 DeerFlow 当前内置认证模块的设计,而不是历史 RFC。它覆盖浏览器登录、API 认证、CSRF、用户隔离、首次初始化、密码重置、内部调用和升级迁移。
|
||||||
|
|
||||||
|
## 设计目标
|
||||||
|
|
||||||
|
认证模块的核心目标是把 DeerFlow 从“本地单用户工具”提升为“可多用户部署的 agent runtime”,并让用户身份贯穿 HTTP API、LangGraph-compatible runtime、文件系统、memory、自定义 agent 和反馈数据。
|
||||||
|
|
||||||
|
设计约束:
|
||||||
|
|
||||||
|
- 默认强制认证:除健康检查、文档和 auth bootstrap 端点外,HTTP 路由都必须有有效 session。
|
||||||
|
- 服务端持有所有权:客户端 metadata 不能声明 `user_id` 或 `owner_id`。
|
||||||
|
- 隔离默认开启:repository(仓储)、文件路径、memory、agent 配置默认按当前用户解析。
|
||||||
|
- 旧数据可升级:无认证版本留下的 thread 可以在 admin 存在后迁移到 admin。
|
||||||
|
- 密码不进日志:首次初始化由操作者设置密码;`reset_admin` 只写 0600 凭据文件。
|
||||||
|
|
||||||
|
非目标:
|
||||||
|
|
||||||
|
- 当前 OAuth 端点只是占位,尚未实现第三方登录。
|
||||||
|
- 当前用户角色只有 `admin` 和 `user`,尚未实现细粒度 RBAC。
|
||||||
|
- 当前登录限速是进程内字典,多 worker 下不是全局精确限速。
|
||||||
|
|
||||||
|
## 核心模型
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
classDef actor fill:#D8CFC4,stroke:#6E6259,color:#2F2A26;
|
||||||
|
classDef api fill:#C9D7D2,stroke:#5D706A,color:#21302C;
|
||||||
|
classDef state fill:#D7D3E8,stroke:#6B6680,color:#29263A;
|
||||||
|
classDef data fill:#E5D2C4,stroke:#806A5B,color:#30251E;
|
||||||
|
|
||||||
|
Browser["Browser — access_token cookie and csrf_token cookie"]:::actor
|
||||||
|
AuthMiddleware["AuthMiddleware — strict session gate"]:::api
|
||||||
|
CSRFMiddleware["CSRFMiddleware — double-submit token and Origin check"]:::api
|
||||||
|
AuthRoutes["Auth routes — initialize login register logout me change-password"]:::api
|
||||||
|
UserContext["Current user ContextVar — request-scoped identity"]:::state
|
||||||
|
Repositories["Repositories — AUTO resolves user_id from context"]:::state
|
||||||
|
Files["Filesystem — users/{user_id}/threads/{thread_id}/user-data"]:::data
|
||||||
|
Memory["Memory and agents — users/{user_id}/memory.json and agents"]:::data
|
||||||
|
|
||||||
|
Browser --> AuthMiddleware
|
||||||
|
Browser --> CSRFMiddleware
|
||||||
|
AuthMiddleware --> AuthRoutes
|
||||||
|
AuthMiddleware --> UserContext
|
||||||
|
UserContext --> Repositories
|
||||||
|
UserContext --> Files
|
||||||
|
UserContext --> Memory
|
||||||
|
```
|
||||||
|
|
||||||
|
### 用户表
|
||||||
|
|
||||||
|
用户记录定义在 `app.gateway.auth.models.User`,持久化到 `users` 表。关键字段:
|
||||||
|
|
||||||
|
| 字段 | 语义 |
|
||||||
|
|---|---|
|
||||||
|
| `id` | 用户主键,JWT `sub` 使用该值 |
|
||||||
|
| `email` | 唯一登录名 |
|
||||||
|
| `password_hash` | bcrypt hash,OAuth 用户可为空 |
|
||||||
|
| `system_role` | `admin` 或 `user` |
|
||||||
|
| `needs_setup` | reset 后要求用户完成邮箱 / 密码设置 |
|
||||||
|
| `token_version` | 改密码或 reset 时递增,用于废弃旧 JWT |
|
||||||
|
|
||||||
|
### 运行时身份
|
||||||
|
|
||||||
|
认证成功后,`AuthMiddleware` 把用户同时写入:
|
||||||
|
|
||||||
|
- `request.state.user`
|
||||||
|
- `request.state.auth`
|
||||||
|
- `deerflow.runtime.user_context` 的 `ContextVar`
|
||||||
|
|
||||||
|
`ContextVar` 是这里的核心边界。上层 Gateway 负责写入身份,下层 persistence / file path 只读取结构化的当前用户,不反向依赖 `app.gateway.auth` 具体类型。
|
||||||
|
|
||||||
|
可以把 repository 调用的用户参数理解成一个三态 ADT:
|
||||||
|
|
||||||
|
```scala
|
||||||
|
enum UserScope:
|
||||||
|
case AutoFromContext
|
||||||
|
case Explicit(userId: String)
|
||||||
|
case BypassForMigration
|
||||||
|
```
|
||||||
|
|
||||||
|
对应 Python 实现是 `AUTO | str | None`:
|
||||||
|
|
||||||
|
- `AUTO`:从 `ContextVar` 解析当前用户;没有上下文则抛错。
|
||||||
|
- `str`:显式指定用户,主要用于测试或管理脚本。
|
||||||
|
- `None`:跳过用户过滤,只允许迁移脚本或 admin CLI 使用。
|
||||||
|
|
||||||
|
## 登录与初始化流程
|
||||||
|
|
||||||
|
### 首次初始化
|
||||||
|
|
||||||
|
首次启动时,如果没有 admin,服务不会自动创建账号,只记录日志提示访问 `/setup`。
|
||||||
|
|
||||||
|
流程:
|
||||||
|
|
||||||
|
1. 用户访问 `/setup`。
|
||||||
|
2. 前端调用 `GET /api/v1/auth/setup-status`。
|
||||||
|
3. 如果返回 `{"needs_setup": true}`,前端展示创建 admin 表单。
|
||||||
|
4. 表单提交 `POST /api/v1/auth/initialize`。
|
||||||
|
5. 服务端确认当前没有 admin,创建 `system_role="admin"`、`needs_setup=false` 的用户。
|
||||||
|
6. 服务端设置 `access_token` HttpOnly cookie,用户进入 workspace。
|
||||||
|
|
||||||
|
`/api/v1/auth/initialize` 只在没有 admin 时可用。并发初始化由数据库唯一约束兜底,失败方返回 409。
|
||||||
|
|
||||||
|
### 普通登录
|
||||||
|
|
||||||
|
`POST /api/v1/auth/login/local` 使用 `OAuth2PasswordRequestForm`:
|
||||||
|
|
||||||
|
- `username` 是邮箱。
|
||||||
|
- `password` 是密码。
|
||||||
|
- 成功后签发 JWT,放入 `access_token` HttpOnly cookie。
|
||||||
|
- 响应体只返回 `expires_in` 和 `needs_setup`,不返回 token。
|
||||||
|
|
||||||
|
登录失败会按客户端 IP 计数。IP 解析只在 TCP peer 属于 `AUTH_TRUSTED_PROXIES` 时信任 `X-Real-IP`,不使用 `X-Forwarded-For`。
|
||||||
|
|
||||||
|
### 注册
|
||||||
|
|
||||||
|
`POST /api/v1/auth/register` 创建普通 `user`,并自动登录。
|
||||||
|
|
||||||
|
当前实现允许在没有 admin 时注册普通用户,但 `setup-status` 仍会返回 `needs_setup=true`,因为 admin 仍不存在。这是当前产品策略边界:如果后续要求“必须先初始化 admin 才能注册普通用户”,需要在 `/register` 增加 admin-exists gate。
|
||||||
|
|
||||||
|
### 改密码与 reset setup
|
||||||
|
|
||||||
|
`POST /api/v1/auth/change-password` 需要当前密码和新密码:
|
||||||
|
|
||||||
|
- 校验当前密码。
|
||||||
|
- 更新 bcrypt hash。
|
||||||
|
- `token_version += 1`,使旧 JWT 立即失效。
|
||||||
|
- 重新签发 cookie。
|
||||||
|
- 如果 `needs_setup=true` 且传了 `new_email`,则更新邮箱并清除 `needs_setup`。
|
||||||
|
|
||||||
|
`python -m app.gateway.auth.reset_admin` 会:
|
||||||
|
|
||||||
|
- 找到 admin 或指定邮箱用户。
|
||||||
|
- 生成随机密码。
|
||||||
|
- 更新密码 hash。
|
||||||
|
- `token_version += 1`。
|
||||||
|
- 设置 `needs_setup=true`。
|
||||||
|
- 写入 `.deer-flow/admin_initial_credentials.txt`,权限 `0600`。
|
||||||
|
|
||||||
|
命令行只输出凭据文件路径,不输出明文密码。
|
||||||
|
|
||||||
|
## HTTP 认证边界
|
||||||
|
|
||||||
|
`AuthMiddleware` 是 fail-closed(默认拒绝)的全局认证门。
|
||||||
|
|
||||||
|
公开路径:
|
||||||
|
|
||||||
|
- `/health`
|
||||||
|
- `/docs`
|
||||||
|
- `/redoc`
|
||||||
|
- `/openapi.json`
|
||||||
|
- `/api/v1/auth/login/local`
|
||||||
|
- `/api/v1/auth/register`
|
||||||
|
- `/api/v1/auth/logout`
|
||||||
|
- `/api/v1/auth/setup-status`
|
||||||
|
- `/api/v1/auth/initialize`
|
||||||
|
|
||||||
|
其余路径都要求有效 `access_token` cookie。存在 cookie 但 JWT 无效、过期、用户不存在或 `token_version` 不匹配时,直接返回 401,而不是让请求穿透到业务路由。
|
||||||
|
|
||||||
|
路由级别的 owner check 由 `require_permission(..., owner_check=True)` 完成:
|
||||||
|
|
||||||
|
- 读类请求允许旧的未追踪 legacy thread 兼容读取。
|
||||||
|
- 写 / 删除类请求使用 `require_existing=True`,要求 thread row 存在且属于当前用户,避免删除后缺 row 导致其他用户误通过。
|
||||||
|
|
||||||
|
## CSRF 设计
|
||||||
|
|
||||||
|
DeerFlow 使用 Double Submit Cookie:
|
||||||
|
|
||||||
|
- 服务端设置 `csrf_token` cookie。
|
||||||
|
- 前端 state-changing 请求发送同值 `X-CSRF-Token` header。
|
||||||
|
- 服务端用 `secrets.compare_digest` 比较 cookie/header。
|
||||||
|
|
||||||
|
需要 CSRF 的方法:
|
||||||
|
|
||||||
|
- `POST`
|
||||||
|
- `PUT`
|
||||||
|
- `DELETE`
|
||||||
|
- `PATCH`
|
||||||
|
|
||||||
|
auth bootstrap 端点(login/register/initialize/logout)不要求 double-submit token,因为首次调用时浏览器还没有 token;但这些端点会校验 browser `Origin`,拒绝 hostile Origin,避免 login CSRF / session fixation。
|
||||||
|
|
||||||
|
## 用户隔离
|
||||||
|
|
||||||
|
### Thread metadata
|
||||||
|
|
||||||
|
Thread metadata 存在 `threads_meta`,关键隔离字段是 `user_id`。
|
||||||
|
|
||||||
|
创建 thread 时:
|
||||||
|
|
||||||
|
- 客户端传入的 `metadata.user_id` 和 `metadata.owner_id` 会被剥离。
|
||||||
|
- `ThreadMetaRepository.create(..., user_id=AUTO)` 从 `ContextVar` 解析真实用户。
|
||||||
|
- `/api/threads/search` 默认只返回当前用户的 thread。
|
||||||
|
|
||||||
|
读取 / 修改 / 删除时:
|
||||||
|
|
||||||
|
- `get()` 默认按当前用户过滤。
|
||||||
|
- `check_access()` 用于路由 owner check。
|
||||||
|
- 对其他用户的 thread 返回 404,避免泄露资源存在性。
|
||||||
|
|
||||||
|
### 文件系统
|
||||||
|
|
||||||
|
当前线程文件布局:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{base_dir}/users/{user_id}/threads/{thread_id}/user-data/
|
||||||
|
├── workspace/
|
||||||
|
├── uploads/
|
||||||
|
└── outputs/
|
||||||
|
```
|
||||||
|
|
||||||
|
agent 在 sandbox 内看到统一虚拟路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/mnt/user-data/workspace
|
||||||
|
/mnt/user-data/uploads
|
||||||
|
/mnt/user-data/outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
`ThreadDataMiddleware` 使用 `get_effective_user_id()` 解析当前用户并生成线程路径。没有认证上下文时会落到 `default` 用户桶,主要用于内部调用、嵌入式 client 或无 HTTP 的本地执行路径。
|
||||||
|
|
||||||
|
### Memory
|
||||||
|
|
||||||
|
默认 memory 存储:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{base_dir}/users/{user_id}/memory.json
|
||||||
|
{base_dir}/users/{user_id}/agents/{agent_name}/memory.json
|
||||||
|
```
|
||||||
|
|
||||||
|
有用户上下文时,空或相对 `memory.storage_path` 都使用上述 per-user 默认路径;只有绝对 `memory.storage_path` 会视为显式 opt-out(退出) per-user isolation,所有用户共享该路径。无用户上下文的 legacy 路径仍会把相对 `storage_path` 解析到 `Paths.base_dir` 下。
|
||||||
|
|
||||||
|
### 自定义 agent
|
||||||
|
|
||||||
|
用户自定义 agent 写入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{base_dir}/users/{user_id}/agents/{agent_name}/
|
||||||
|
├── config.yaml
|
||||||
|
├── SOUL.md
|
||||||
|
└── memory.json
|
||||||
|
```
|
||||||
|
|
||||||
|
旧布局 `{base_dir}/agents/{agent_name}/` 只作为只读兼容回退。更新或删除旧共享 agent 会要求先运行迁移脚本。
|
||||||
|
|
||||||
|
## 内部调用与 IM 渠道
|
||||||
|
|
||||||
|
IM channel worker 不是浏览器用户,不持有浏览器 cookie。它们通过 Gateway 内部认证:
|
||||||
|
|
||||||
|
- 请求带 `X-DeerFlow-Internal-Token`。
|
||||||
|
- 同时带匹配的 CSRF cookie/header。
|
||||||
|
- 服务端识别为内部用户,`id="default"`、`system_role="internal"`。
|
||||||
|
|
||||||
|
这意味着 channel 产生的数据默认进入 `default` 用户桶。这个选择适合“平台级 bot 身份”,但不是“每个 IM 用户单独隔离”。如果后续要做到外部 IM 用户隔离,需要把外部 platform user 映射到 DeerFlow user,并让 channel manager 设置对应的 scoped identity。
|
||||||
|
|
||||||
|
## LangGraph-compatible 认证
|
||||||
|
|
||||||
|
Gateway 内嵌 runtime 路径由 `AuthMiddleware` 和 `CSRFMiddleware` 保护。
|
||||||
|
|
||||||
|
仓库仍保留 `app.gateway.langgraph_auth`,用于 LangGraph Server 直连模式:
|
||||||
|
|
||||||
|
- `@auth.authenticate` 校验 JWT cookie、CSRF、用户存在性和 `token_version`。
|
||||||
|
- `@auth.on` 在写入 metadata 时注入 `user_id`,并在读路径返回 `{"user_id": current_user}` 过滤条件。
|
||||||
|
|
||||||
|
这保证 Gateway 路由和 LangGraph-compatible 直连模式使用同一 JWT 语义。
|
||||||
|
|
||||||
|
## 升级与迁移
|
||||||
|
|
||||||
|
从无认证版本升级时,可能存在没有 `user_id` 的历史 thread。
|
||||||
|
|
||||||
|
当前策略:
|
||||||
|
|
||||||
|
1. 首次启动如果没有 admin,只提示访问 `/setup`,不迁移。
|
||||||
|
2. 操作者创建 admin。
|
||||||
|
3. 后续启动时,`_ensure_admin_user()` 找到 admin,并把 LangGraph store 中缺少 `metadata.user_id` 的 thread 迁移到 admin。
|
||||||
|
|
||||||
|
文件系统旧布局迁移由脚本处理:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
PYTHONPATH=. python scripts/migrate_user_isolation.py --dry-run
|
||||||
|
PYTHONPATH=. python scripts/migrate_user_isolation.py --user-id <target-user-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
迁移脚本覆盖 legacy `memory.json`、`threads/` 和 `agents/` 到 per-user layout。
|
||||||
|
|
||||||
|
## 安全不变量
|
||||||
|
|
||||||
|
必须长期保持的不变量:
|
||||||
|
|
||||||
|
- JWT 只在 HttpOnly cookie 中传输,不出现在响应 JSON。
|
||||||
|
- 任何非 public HTTP 路由都不能只靠“cookie 存在”放行,必须严格验证 JWT。
|
||||||
|
- `token_version` 不匹配必须拒绝,保证改密码 / reset 后旧 session 失效。
|
||||||
|
- 客户端 metadata 中的 `user_id` / `owner_id` 必须剥离。
|
||||||
|
- repository 默认 `AUTO` 必须从当前用户上下文解析,不能静默退化成全局查询。
|
||||||
|
- 只有迁移脚本和 admin CLI 可以显式传 `user_id=None` 绕过隔离。
|
||||||
|
- 本地文件路径必须通过 `Paths` 和 sandbox path validation 解析,不能拼接未校验的用户输入。
|
||||||
|
- 捕获认证、迁移、后台任务异常必须记录日志;不能空 catch。
|
||||||
|
|
||||||
|
## 已知边界
|
||||||
|
|
||||||
|
| 边界 | 当前行为 | 后续方向 |
|
||||||
|
|---|---|---|
|
||||||
|
| 无 admin 时注册普通用户 | 允许注册普通 `user` | 如产品要求先初始化 admin,给 `/register` 加 gate |
|
||||||
|
| 登录限速 | 进程内 dict,单 worker 精确,多 worker 近似 | Redis / DB-backed rate limiter |
|
||||||
|
| OAuth | 端点占位,未实现 | 接入 provider 并统一 `token_version` / role 语义 |
|
||||||
|
| IM 用户隔离 | channel 使用 `default` 内部用户 | 建立外部用户到 DeerFlow user 的映射 |
|
||||||
|
| 绝对 memory path | 显式共享 memory | UI / docs 明确提示 opt-out 风险 |
|
||||||
|
|
||||||
|
## 相关文件
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|---|---|
|
||||||
|
| `app/gateway/auth_middleware.py` | 全局认证门、JWT 严格验证、写入 user context |
|
||||||
|
| `app/gateway/csrf_middleware.py` | CSRF double-submit 和 auth Origin 校验 |
|
||||||
|
| `app/gateway/routers/auth.py` | initialize/login/register/logout/me/change-password |
|
||||||
|
| `app/gateway/auth/jwt.py` | JWT 创建与解析 |
|
||||||
|
| `app/gateway/auth/reset_admin.py` | 密码 reset CLI |
|
||||||
|
| `app/gateway/auth/credential_file.py` | 0600 凭据文件写入 |
|
||||||
|
| `app/gateway/authz.py` | 路由权限与 owner check |
|
||||||
|
| `deerflow/runtime/user_context.py` | 当前用户 ContextVar 与 `AUTO` sentinel |
|
||||||
|
| `deerflow/persistence/thread_meta/` | thread metadata owner filter |
|
||||||
|
| `deerflow/config/paths.py` | per-user filesystem layout |
|
||||||
|
| `deerflow/agents/middlewares/thread_data_middleware.py` | run 时解析用户线程目录 |
|
||||||
|
| `deerflow/agents/memory/storage.py` | per-user memory storage |
|
||||||
|
| `deerflow/config/agents_config.py` | per-user custom agents |
|
||||||
|
| `app/channels/manager.py` | IM channel 内部认证调用 |
|
||||||
|
| `scripts/migrate_user_isolation.py` | legacy 数据迁移到 per-user layout |
|
||||||
|
| `.deer-flow/data/deerflow.db` | 统一 SQLite 数据库,包含 users / threads_meta / runs / feedback 等表 |
|
||||||
|
| `.deer-flow/users/{user_id}/agents/{agent_name}/` | 用户自定义 agent 配置、SOUL 和 agent memory |
|
||||||
|
| `.deer-flow/admin_initial_credentials.txt` | `reset_admin` 生成的新凭据文件(0600,读完应删除) |
|
||||||
@@ -24,11 +24,11 @@ All other test plan sections were executed against either:
|
|||||||
|
|
||||||
| Case | Title | What it covers | Why not run |
|
| Case | Title | What it covers | Why not run |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| TC-DOCKER-01 | `users.db` volume persistence | Verify the `DEER_FLOW_HOME` bind mount survives container restart | needs `docker compose up` |
|
| TC-DOCKER-01 | `deerflow.db` volume persistence | Verify the `DEER_FLOW_HOME` bind mount survives container restart | needs `docker compose up` |
|
||||||
| TC-DOCKER-02 | Session persistence across container restart | `AUTH_JWT_SECRET` env var keeps cookies valid after `docker compose down && up` | needs `docker compose down/up` |
|
| TC-DOCKER-02 | Session persistence across container restart | `AUTH_JWT_SECRET` env var keeps cookies valid after `docker compose down && up` | needs `docker compose down/up` |
|
||||||
| 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-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 skip AuthMiddleware | Verify Feishu/Slack/Telegram dispatchers run in-container against `http://langgraph:2024` without going through nginx | needs `docker logs` |
|
| 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 | Admin credentials surfacing | **Updated post-simplify** — was "log scrape", now "0600 credential file in `DEER_FLOW_HOME`". The file-based behavior is already validated by TC-1.1 + TC-UPG-13 on sg_dev (non-Docker), so the only Docker-specific gap is verifying the volume mount carries the file out to the host | needs container + host volume |
|
| 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 | 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` |
|
||||||
|
|
||||||
## Coverage already provided by non-Docker tests
|
## Coverage already provided by non-Docker tests
|
||||||
@@ -41,8 +41,8 @@ the test cases that ran on sg_dev or local:
|
|||||||
| TC-DOCKER-01 (volume persistence) | TC-REENT-01 on sg_dev (admin row survives gateway restart) — same SQLite file, just no container layer between |
|
| TC-DOCKER-01 (volume persistence) | TC-REENT-01 on sg_dev (admin row survives gateway restart) — same SQLite file, just no container layer between |
|
||||||
| TC-DOCKER-02 (session persistence) | TC-API-02/03/06 (cookie roundtrip), plus TC-REENT-04 (multi-cookie) — JWT verification is process-state-free, container restart is equivalent to `pkill uvicorn && uv run uvicorn` |
|
| TC-DOCKER-02 (session persistence) | TC-API-02/03/06 (cookie roundtrip), plus TC-REENT-04 (multi-cookie) — JWT verification is process-state-free, container restart is equivalent to `pkill uvicorn && uv run uvicorn` |
|
||||||
| 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-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 skip auth) | Code-level only: `app/channels/manager.py` uses `langgraph_sdk` directly with no cookie handling. The langgraph_auth handler is bypassed by going through SDK, not HTTP |
|
| 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) | TC-1.1 on sg_dev (file at `~/deer-flow/backend/.deer-flow/admin_initial_credentials.txt`, mode 0600, password 22 chars) — 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-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-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 |
|
||||||
|
|
||||||
## Reproduction steps when Docker becomes available
|
## Reproduction steps when Docker becomes available
|
||||||
@@ -72,6 +72,6 @@ Then run TC-DOCKER-01..06 from the test plan as written.
|
|||||||
about *container packaging* details (bind mounts, multi-worker, log
|
about *container packaging* details (bind mounts, multi-worker, log
|
||||||
collection), not about whether the auth code paths work.
|
collection), not about whether the auth code paths work.
|
||||||
- **TC-DOCKER-05 was updated in place** in `AUTH_TEST_PLAN.md` to reflect
|
- **TC-DOCKER-05 was updated in place** in `AUTH_TEST_PLAN.md` to reflect
|
||||||
the post-simplify reality (credentials file → 0600 file, no log leak).
|
the current reset flow (`reset_admin` → 0600 credentials file, no log leak).
|
||||||
The old "grep 'Password:' in docker logs" expectation would have failed
|
The old "grep 'Password:' in docker logs" expectation would have failed
|
||||||
silently and given a false sense of coverage.
|
silently and given a false sense of coverage.
|
||||||
|
|||||||
+149
-105
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 清除已有数据
|
# 清除已有数据
|
||||||
rm -f backend/.deer-flow/users.db
|
rm -f backend/.deer-flow/data/deerflow.db
|
||||||
|
|
||||||
# 选择模式启动
|
# 选择模式启动
|
||||||
make dev # 标准模式
|
make dev # 标准模式
|
||||||
@@ -28,10 +28,11 @@ make dev-pro # Gateway 模式
|
|||||||
```
|
```
|
||||||
|
|
||||||
**验证点:**
|
**验证点:**
|
||||||
- [ ] 控制台输出 admin 邮箱和随机密码
|
- [ ] 控制台不输出 admin 邮箱或明文密码
|
||||||
- [ ] 密码格式为 `secrets.token_urlsafe(16)` 的 22 字符字符串
|
- [ ] 控制台提示 `First boot detected — no admin account exists.`
|
||||||
- [ ] 邮箱为 `admin@deerflow.dev`
|
- [ ] 控制台提示访问 `/setup` 完成 admin 创建
|
||||||
- [ ] 提示 `Change it after login: Settings -> Account`
|
- [ ] `GET /api/v1/auth/setup-status` 返回 `{"needs_setup": true}`
|
||||||
|
- [ ] 前端访问 `/login` 会跳转 `/setup`
|
||||||
|
|
||||||
### 1.2 非首次启动
|
### 1.2 非首次启动
|
||||||
|
|
||||||
@@ -42,7 +43,8 @@ make dev
|
|||||||
|
|
||||||
**验证点:**
|
**验证点:**
|
||||||
- [ ] 控制台不输出密码
|
- [ ] 控制台不输出密码
|
||||||
- [ ] 如果 admin 仍 `needs_setup=True`,控制台有 warning 提示
|
- [ ] `GET /api/v1/auth/setup-status` 返回 `{"needs_setup": false}`
|
||||||
|
- [ ] 已登录用户如果 `needs_setup=True`,访问 workspace 会被引导到 `/setup` 完成改邮箱 / 改密码流程
|
||||||
|
|
||||||
### 1.3 环境变量配置
|
### 1.3 环境变量配置
|
||||||
|
|
||||||
@@ -76,19 +78,22 @@ make dev
|
|||||||
curl -s $BASE/api/v1/auth/setup-status | jq .
|
curl -s $BASE/api/v1/auth/setup-status | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:** 返回 `{"needs_setup": false}`(admin 在启动时已自动创建,`count_users() > 0`)。仅在启动完成前的极短窗口内可能返回 `true`。
|
**预期:**
|
||||||
|
- 干净数据库且尚未初始化 admin:返回 `{"needs_setup": true}`
|
||||||
|
- 已存在 admin:返回 `{"needs_setup": false}`
|
||||||
|
|
||||||
#### TC-API-02: Admin 首次登录
|
#### TC-API-02: 首次初始化 Admin
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST $BASE/api/v1/auth/login/local \
|
curl -s -X POST $BASE/api/v1/auth/initialize \
|
||||||
-d "username=admin@deerflow.dev&password=<控制台密码>" \
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"admin@example.com","password":"AdminPass1!"}' \
|
||||||
-c cookies.txt | jq .
|
-c cookies.txt | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- 状态码 200
|
- 状态码 201
|
||||||
- Body: `{"expires_in": 604800, "needs_setup": true}`
|
- Body: `{"id": "...", "email": "admin@example.com", "system_role": "admin", "needs_setup": false}`
|
||||||
- `cookies.txt` 包含 `access_token`(HttpOnly)和 `csrf_token`(非 HttpOnly)
|
- `cookies.txt` 包含 `access_token`(HttpOnly)和 `csrf_token`(非 HttpOnly)
|
||||||
|
|
||||||
#### TC-API-03: 获取当前用户
|
#### TC-API-03: 获取当前用户
|
||||||
@@ -97,9 +102,9 @@ curl -s -X POST $BASE/api/v1/auth/login/local \
|
|||||||
curl -s $BASE/api/v1/auth/me -b cookies.txt | jq .
|
curl -s $BASE/api/v1/auth/me -b cookies.txt | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:** `{"id": "...", "email": "admin@deerflow.dev", "system_role": "admin", "needs_setup": true}`
|
**预期:** `{"id": "...", "email": "admin@example.com", "system_role": "admin", "needs_setup": false}`
|
||||||
|
|
||||||
#### TC-API-04: Setup 流程(改邮箱 + 改密码)
|
#### TC-API-04: 改密码流程
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
|
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
|
||||||
@@ -107,13 +112,36 @@ curl -s -X POST $BASE/api/v1/auth/change-password \
|
|||||||
-b cookies.txt \
|
-b cookies.txt \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "X-CSRF-Token: $CSRF" \
|
-H "X-CSRF-Token: $CSRF" \
|
||||||
-d '{"current_password":"<控制台密码>","new_password":"NewPass123!","new_email":"admin@example.com"}' | jq .
|
-d '{"current_password":"AdminPass1!","new_password":"NewPass123!"}' | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- 状态码 200
|
- 状态码 200
|
||||||
- `{"message": "Password changed successfully"}`
|
- `{"message": "Password changed successfully"}`
|
||||||
- 再调 `/auth/me` 邮箱变为 `admin@example.com`,`needs_setup` 变为 `false`
|
- 再调 `/auth/me` 仍为 `admin@example.com`,`needs_setup` 仍为 `false`
|
||||||
|
|
||||||
|
#### TC-API-04a: reset_admin 后的 Setup 流程(改邮箱 + 改密码)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m app.gateway.auth.reset_admin --email admin@example.com
|
||||||
|
# 从 .deer-flow/admin_initial_credentials.txt 读取 reset 后密码
|
||||||
|
|
||||||
|
curl -s -X POST $BASE/api/v1/auth/login/local \
|
||||||
|
-d "username=admin@example.com&password=<凭据文件密码>" \
|
||||||
|
-c cookies.txt | jq .
|
||||||
|
|
||||||
|
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
|
||||||
|
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":"AdminPass2!","new_email":"admin2@example.com"}' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期:**
|
||||||
|
- 登录返回 `{"expires_in": 604800, "needs_setup": true}`
|
||||||
|
- `change-password` 后 `/auth/me` 邮箱变为 `admin2@example.com`,`needs_setup` 变为 `false`
|
||||||
|
|
||||||
#### TC-API-05: 普通用户注册
|
#### TC-API-05: 普通用户注册
|
||||||
|
|
||||||
@@ -493,7 +521,7 @@ curl -s -X POST $BASE/api/v1/auth/register \
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 检查数据库
|
# 检查数据库
|
||||||
sqlite3 backend/.deer-flow/users.db "SELECT email, password_hash FROM users LIMIT 3;"
|
sqlite3 backend/.deer-flow/data/deerflow.db "SELECT email, password_hash FROM users LIMIT 3;"
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:** `password_hash` 以 `$2b$` 开头(bcrypt 格式)
|
**预期:** `password_hash` 以 `$2b$` 开头(bcrypt 格式)
|
||||||
@@ -506,24 +534,25 @@ sqlite3 backend/.deer-flow/users.db "SELECT email, password_hash FROM users LIMI
|
|||||||
|
|
||||||
### 4.1 首次登录流程
|
### 4.1 首次登录流程
|
||||||
|
|
||||||
#### TC-UI-01: 访问首页跳转登录
|
#### TC-UI-01: 无 admin 时访问 workspace 跳转 setup
|
||||||
|
|
||||||
1. 打开 `http://localhost:2026/workspace`
|
1. 打开 `http://localhost:2026/workspace`
|
||||||
2. **预期:** 自动跳转到 `/login`
|
2. **预期:** 自动跳转到 `/setup`
|
||||||
|
|
||||||
#### TC-UI-02: Login 页面
|
#### TC-UI-02: Setup 页面创建 admin
|
||||||
|
|
||||||
1. 输入 admin 邮箱和控制台密码
|
1. 输入 admin 邮箱、密码、确认密码
|
||||||
2. 点击 Login
|
2. 点击 Create Admin Account
|
||||||
3. **预期:** 跳转到 `/setup`(因为 `needs_setup=true`)
|
|
||||||
|
|
||||||
#### TC-UI-03: Setup 页面
|
|
||||||
|
|
||||||
1. 输入新邮箱、控制台密码(current)、新密码、确认密码
|
|
||||||
2. 点击 Complete Setup
|
|
||||||
3. **预期:** 跳转到 `/workspace`
|
3. **预期:** 跳转到 `/workspace`
|
||||||
4. 刷新页面不跳回 `/setup`
|
4. 刷新页面不跳回 `/setup`
|
||||||
|
|
||||||
|
#### TC-UI-03: 已初始化后 Login 页面
|
||||||
|
|
||||||
|
1. 退出登录后访问 `/login`
|
||||||
|
2. 输入 admin 邮箱和密码
|
||||||
|
3. 点击 Login
|
||||||
|
4. **预期:** 跳转到 `/workspace`
|
||||||
|
|
||||||
#### TC-UI-04: Setup 密码不匹配
|
#### TC-UI-04: Setup 密码不匹配
|
||||||
|
|
||||||
1. 新密码和确认密码不一致
|
1. 新密码和确认密码不一致
|
||||||
@@ -602,7 +631,7 @@ sqlite3 backend/.deer-flow/users.db "SELECT email, password_hash FROM users LIMI
|
|||||||
#### TC-UI-15: reset_admin 后重新登录
|
#### TC-UI-15: reset_admin 后重新登录
|
||||||
|
|
||||||
1. 执行 `cd backend && python -m app.gateway.auth.reset_admin`
|
1. 执行 `cd backend && python -m app.gateway.auth.reset_admin`
|
||||||
2. 使用新密码登录
|
2. 从 `.deer-flow/admin_initial_credentials.txt` 读取新密码并登录
|
||||||
3. **预期:** 跳转到 `/setup` 页面(`needs_setup` 被重置为 true)
|
3. **预期:** 跳转到 `/setup` 页面(`needs_setup` 被重置为 true)
|
||||||
4. 旧 session 已失效
|
4. 旧 session 已失效
|
||||||
|
|
||||||
@@ -645,18 +674,28 @@ make install
|
|||||||
make dev
|
make dev
|
||||||
```
|
```
|
||||||
|
|
||||||
#### TC-UPG-01: 首次启动创建 admin
|
#### TC-UPG-01: 首次启动等待 admin 初始化
|
||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- [ ] 控制台输出 admin 邮箱(`admin@deerflow.dev`)和随机密码
|
- [ ] 控制台不输出 admin 邮箱或随机密码
|
||||||
|
- [ ] 访问 `/setup` 可创建第一个 admin
|
||||||
- [ ] 无报错,正常启动
|
- [ ] 无报错,正常启动
|
||||||
|
|
||||||
#### TC-UPG-02: 旧 Thread 迁移到 admin
|
#### TC-UPG-02: 旧 Thread 迁移到 admin
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 创建第一个 admin
|
||||||
|
curl -s -X POST http://localhost:2026/api/v1/auth/initialize \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"admin@example.com","password":"AdminPass1!"}' \
|
||||||
|
-c cookies.txt
|
||||||
|
|
||||||
|
# 重启一次:启动迁移只在已有 admin 的启动路径执行
|
||||||
|
make stop && make dev
|
||||||
|
|
||||||
# 登录 admin
|
# 登录 admin
|
||||||
curl -s -X POST http://localhost:2026/api/v1/auth/login/local \
|
curl -s -X POST http://localhost:2026/api/v1/auth/login/local \
|
||||||
-d "username=admin@deerflow.dev&password=<控制台密码>" \
|
-d "username=admin@example.com&password=AdminPass1!" \
|
||||||
-c cookies.txt
|
-c cookies.txt
|
||||||
|
|
||||||
# 查看 thread 列表
|
# 查看 thread 列表
|
||||||
@@ -670,8 +709,8 @@ curl -s -X POST http://localhost:2026/api/threads/search \
|
|||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- [ ] 返回的 thread 数量 ≥ 旧版创建的数量
|
- [ ] 返回的 thread 数量 ≥ 旧版创建的数量
|
||||||
- [ ] 控制台日志有 `Migrated N orphaned thread(s) to admin`
|
- [ ] 控制台日志有 `Migrated N orphan LangGraph thread(s) to admin`
|
||||||
- [ ] 每个 thread 的 `metadata.owner_id` 都已被设为 admin 的 ID
|
- [ ] 旧 thread 只对 admin 可见
|
||||||
|
|
||||||
#### TC-UPG-03: 旧 Thread 内容完整
|
#### TC-UPG-03: 旧 Thread 内容完整
|
||||||
|
|
||||||
@@ -683,7 +722,7 @@ curl -s http://localhost:2026/api/threads/<old-thread-id> \
|
|||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- [ ] `metadata.title` 保留原值(如 `old-thread-1`)
|
- [ ] `metadata.title` 保留原值(如 `old-thread-1`)
|
||||||
- [ ] `metadata.owner_id` 已填充
|
- [ ] 响应不回显服务端保留的 `user_id` / `owner_id`
|
||||||
|
|
||||||
#### TC-UPG-04: 新用户看不到旧 Thread
|
#### TC-UPG-04: 新用户看不到旧 Thread
|
||||||
|
|
||||||
@@ -706,18 +745,19 @@ curl -s -X POST http://localhost:2026/api/threads/search \
|
|||||||
|
|
||||||
### 5.3 数据库 Schema 兼容
|
### 5.3 数据库 Schema 兼容
|
||||||
|
|
||||||
#### TC-UPG-05: 无 users.db 时自动创建
|
#### TC-UPG-05: 无 deerflow.db 时创建 schema 但不创建默认用户
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ls -la backend/.deer-flow/users.db
|
ls -la backend/.deer-flow/data/deerflow.db
|
||||||
|
sqlite3 backend/.deer-flow/data/deerflow.db "SELECT COUNT(*) FROM users;"
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:** 文件存在,`sqlite3` 可查到 `users` 表含 `needs_setup`、`token_version` 列
|
**预期:** 文件存在,`sqlite3` 可查到 `users` 表含 `needs_setup`、`token_version` 列;未调用 `/initialize` 前用户数为 0
|
||||||
|
|
||||||
#### TC-UPG-06: users.db WAL 模式
|
#### TC-UPG-06: deerflow.db WAL 模式
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sqlite3 backend/.deer-flow/users.db "PRAGMA journal_mode;"
|
sqlite3 backend/.deer-flow/data/deerflow.db "PRAGMA journal_mode;"
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:** 返回 `wal`
|
**预期:** 返回 `wal`
|
||||||
@@ -768,9 +808,9 @@ make dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- [ ] 服务正常启动(忽略 `users.db`,无 auth 相关代码不报错)
|
- [ ] 服务正常启动(忽略 `deerflow.db`,无 auth 相关代码不报错)
|
||||||
- [ ] 旧对话数据仍然可访问
|
- [ ] 旧对话数据仍然可访问
|
||||||
- [ ] `users.db` 文件残留但不影响运行
|
- [ ] `deerflow.db` 文件残留但不影响运行
|
||||||
|
|
||||||
#### TC-UPG-12: 再次升级到 auth 分支
|
#### TC-UPG-12: 再次升级到 auth 分支
|
||||||
|
|
||||||
@@ -781,51 +821,47 @@ make dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- [ ] 识别已有 `users.db`,不重新创建 admin
|
- [ ] 识别已有 `deerflow.db`,不重新创建 admin
|
||||||
- [ ] 旧的 admin 账号仍可登录(如果回退期间未删 `users.db`)
|
- [ ] 旧的 admin 账号仍可登录(如果回退期间未删 `deerflow.db`)
|
||||||
|
|
||||||
### 5.7 休眠 Admin(初始密码未使用/未更改)
|
### 5.7 Admin 初始化与 reset_admin
|
||||||
|
|
||||||
> 首次启动生成 admin + 随机密码,但运维未登录、未改密码。
|
> 首次启动不生成默认 admin,也不在日志输出密码。忘记密码时走 `reset_admin`,新密码写入 0600 凭据文件。
|
||||||
> 密码只在首次启动的控制台闪过一次,后续启动不再显示。
|
|
||||||
|
|
||||||
#### TC-UPG-13: 重启后自动重置密码并打印
|
#### TC-UPG-13: 未初始化 admin 时重启不创建默认账号
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 首次启动,记录密码
|
rm -f backend/.deer-flow/data/deerflow.db
|
||||||
rm -f backend/.deer-flow/users.db
|
|
||||||
make dev
|
make dev
|
||||||
# 控制台输出密码 P0,不登录
|
|
||||||
make stop
|
make stop
|
||||||
|
|
||||||
# 隔了几天,再次启动
|
|
||||||
make dev
|
make dev
|
||||||
# 控制台输出新密码 P1
|
curl -s $BASE/api/v1/auth/setup-status | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- [ ] 控制台输出 `Admin account setup incomplete — password reset`
|
- [ ] 控制台不输出密码
|
||||||
- [ ] 输出新密码 P1(P0 已失效)
|
- [ ] `setup-status` 仍为 `{"needs_setup": true}`
|
||||||
- [ ] 用 P1 可以登录,P0 不可以
|
- [ ] 访问 `/setup` 仍可创建第一个 admin
|
||||||
- [ ] 登录后 `needs_setup=true`,跳转 `/setup`
|
|
||||||
- [ ] `token_version` 递增(旧 session 如有也失效)
|
|
||||||
|
|
||||||
#### TC-UPG-14: 密码丢失 — 无需 CLI,重启即可
|
#### TC-UPG-14: 密码丢失 — reset_admin 写入凭据文件
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 忘记了控制台密码 → 直接重启服务
|
python -m app.gateway.auth.reset_admin --email admin@example.com
|
||||||
make stop && make dev
|
ls -la backend/.deer-flow/admin_initial_credentials.txt
|
||||||
# 控制台自动输出新密码
|
cat backend/.deer-flow/admin_initial_credentials.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- [ ] 无需 `reset_admin`,重启服务即可拿到新密码
|
- [ ] 命令行只输出凭据文件路径,不输出明文密码
|
||||||
- [ ] `reset_admin` CLI 仍然可用作手动备选方案
|
- [ ] 凭据文件权限为 `0600`
|
||||||
|
- [ ] 凭据文件包含 email + password 行
|
||||||
|
- [ ] 该用户下次登录返回 `needs_setup=true`
|
||||||
|
|
||||||
#### TC-UPG-15: 休眠 admin 期间普通用户注册
|
#### TC-UPG-15: 未初始化 admin 期间普通用户注册策略边界
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# admin 存在但从未登录,普通用户先注册
|
# admin 尚不存在,普通用户尝试注册
|
||||||
curl -s -X POST $BASE/api/v1/auth/register \
|
curl -s -X POST $BASE/api/v1/auth/register \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"email":"earlybird@example.com","password":"EarlyPass1!"}' \
|
-d '{"email":"earlybird@example.com","password":"EarlyPass1!"}' \
|
||||||
@@ -833,11 +869,11 @@ curl -s -X POST $BASE/api/v1/auth/register \
|
|||||||
```
|
```
|
||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- [ ] 注册成功(201),角色为 `user`
|
- [ ] 当前代码允许注册普通用户并自动登录(201,角色为 `user`)
|
||||||
- [ ] 无法提权为 admin
|
- [ ] 但 `setup-status` 仍为 `{"needs_setup": true}`,因为 admin 仍不存在
|
||||||
- [ ] 普通用户的数据与 admin 隔离
|
- [ ] 这是一个产品策略边界:若要求“必须先有 admin”,需要在 `/register` 增加 admin-exists gate
|
||||||
|
|
||||||
#### TC-UPG-16: 休眠 admin 不影响后续操作
|
#### TC-UPG-16: 普通用户数据与后续 admin 隔离
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 普通用户正常创建 thread、发消息
|
# 普通用户正常创建 thread、发消息
|
||||||
@@ -849,14 +885,13 @@ curl -s -X POST $BASE/api/threads \
|
|||||||
-d '{"metadata":{}}' | jq .thread_id
|
-d '{"metadata":{}}' | jq .thread_id
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:** 正常创建,不受休眠 admin 影响
|
**预期:** 普通用户正常创建 thread;后续 admin 创建后,搜索不到该普通用户 thread
|
||||||
|
|
||||||
#### TC-UPG-17: 休眠 admin 最终完成 Setup
|
#### TC-UPG-17: reset_admin 后完成 Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 运维终于登录
|
|
||||||
curl -s -X POST $BASE/api/v1/auth/login/local \
|
curl -s -X POST $BASE/api/v1/auth/login/local \
|
||||||
-d "username=admin@deerflow.dev&password=<P0或P1>" \
|
-d "username=admin@example.com&password=<凭据文件密码>" \
|
||||||
-c admin.txt | jq .needs_setup
|
-c admin.txt | jq .needs_setup
|
||||||
# 预期: true
|
# 预期: true
|
||||||
|
|
||||||
@@ -866,7 +901,7 @@ curl -s -X POST $BASE/api/v1/auth/change-password \
|
|||||||
-b admin.txt \
|
-b admin.txt \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "X-CSRF-Token: $CSRF" \
|
-H "X-CSRF-Token: $CSRF" \
|
||||||
-d '{"current_password":"<密码>","new_password":"AdminFinal1!","new_email":"admin@real.com"}' \
|
-d '{"current_password":"<凭据文件密码>","new_password":"AdminFinal1!","new_email":"admin@real.com"}' \
|
||||||
-c admin.txt
|
-c admin.txt
|
||||||
|
|
||||||
# 验证
|
# 验证
|
||||||
@@ -876,7 +911,7 @@ curl -s $BASE/api/v1/auth/me -b admin.txt | jq '{email, needs_setup}'
|
|||||||
**预期:**
|
**预期:**
|
||||||
- [ ] `email` 变为 `admin@real.com`
|
- [ ] `email` 变为 `admin@real.com`
|
||||||
- [ ] `needs_setup` 变为 `false`
|
- [ ] `needs_setup` 变为 `false`
|
||||||
- [ ] 后续重启控制台不再有 warning
|
- [ ] 后续登录使用新密码
|
||||||
|
|
||||||
#### TC-UPG-18: 长期未用后 JWT 密钥轮换
|
#### TC-UPG-18: 长期未用后 JWT 密钥轮换
|
||||||
|
|
||||||
@@ -890,8 +925,8 @@ make stop && make dev
|
|||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- [ ] 服务正常启动
|
- [ ] 服务正常启动
|
||||||
- [ ] 旧密码仍可登录(密码存在 DB,与 JWT 密钥无关)
|
- [ ] 账号密码仍可登录(密码存在 DB,与 JWT 密钥无关)
|
||||||
- [ ] 旧的 JWT token 失效(密钥变了签名不匹配)— 但因为从未登录过也没有旧 token
|
- [ ] 旧的 JWT token 失效(密钥变了签名不匹配)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -910,7 +945,7 @@ for i in 1 2 3; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
# 检查 admin 数量
|
# 检查 admin 数量
|
||||||
sqlite3 backend/.deer-flow/users.db \
|
sqlite3 backend/.deer-flow/data/deerflow.db \
|
||||||
"SELECT COUNT(*) FROM users WHERE system_role='admin';"
|
"SELECT COUNT(*) FROM users WHERE system_role='admin';"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1055,7 +1090,7 @@ curl -s -X POST $BASE/api/v1/auth/register \
|
|||||||
wait
|
wait
|
||||||
|
|
||||||
# 检查用户数
|
# 检查用户数
|
||||||
sqlite3 backend/.deer-flow/users.db \
|
sqlite3 backend/.deer-flow/data/deerflow.db \
|
||||||
"SELECT COUNT(*) FROM users WHERE email='race@example.com';"
|
"SELECT COUNT(*) FROM users WHERE email='race@example.com';"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1165,13 +1200,16 @@ curl -s -w "%{http_code}" -X DELETE "$BASE/api/threads/$TID" \
|
|||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
python -m app.gateway.auth.reset_admin
|
python -m app.gateway.auth.reset_admin
|
||||||
# 记录密码 P1
|
cp .deer-flow/admin_initial_credentials.txt /tmp/deerflow-reset-p1.txt
|
||||||
|
P1=$(awk -F': ' '/^password:/ {print $2}' /tmp/deerflow-reset-p1.txt)
|
||||||
|
|
||||||
python -m app.gateway.auth.reset_admin
|
python -m app.gateway.auth.reset_admin
|
||||||
# 记录密码 P2
|
cp .deer-flow/admin_initial_credentials.txt /tmp/deerflow-reset-p2.txt
|
||||||
|
P2=$(awk -F': ' '/^password:/ {print $2}' /tmp/deerflow-reset-p2.txt)
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
|
- [ ] `.deer-flow/admin_initial_credentials.txt` 每次都会被重写,文件权限为 `0600`
|
||||||
- [ ] P1 ≠ P2(每次生成新随机密码)
|
- [ ] P1 ≠ P2(每次生成新随机密码)
|
||||||
- [ ] P1 不可用,只有 P2 有效
|
- [ ] P1 不可用,只有 P2 有效
|
||||||
- [ ] `token_version` 递增了 2
|
- [ ] `token_version` 递增了 2
|
||||||
@@ -1324,7 +1362,8 @@ done
|
|||||||
```bash
|
```bash
|
||||||
GW=http://localhost:8001
|
GW=http://localhost:8001
|
||||||
|
|
||||||
for path in /health /api/v1/auth/setup-status /api/v1/auth/login/local /api/v1/auth/register; do
|
for path in /health /api/v1/auth/setup-status /api/v1/auth/login/local \
|
||||||
|
/api/v1/auth/register /api/v1/auth/initialize /api/v1/auth/logout; do
|
||||||
echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $GW$path)"
|
echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $GW$path)"
|
||||||
done
|
done
|
||||||
# 预期: 200 或 405/422(方法不对但不是 401)
|
# 预期: 200 或 405/422(方法不对但不是 401)
|
||||||
@@ -1399,9 +1438,9 @@ done
|
|||||||
>
|
>
|
||||||
> 前置条件:
|
> 前置条件:
|
||||||
> - `.env` 中设置 `AUTH_JWT_SECRET`(否则每次容器重启 session 全部失效)
|
> - `.env` 中设置 `AUTH_JWT_SECRET`(否则每次容器重启 session 全部失效)
|
||||||
> - `DEER_FLOW_HOME` 挂载到宿主机目录(持久化 `users.db`)
|
> - `DEER_FLOW_HOME` 挂载到宿主机目录(持久化 `deerflow.db`)
|
||||||
|
|
||||||
#### TC-DOCKER-01: users.db 通过 volume 持久化
|
#### TC-DOCKER-01: deerflow.db 通过 volume 持久化
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 启动容器
|
# 启动容器
|
||||||
@@ -1416,13 +1455,13 @@ curl -s -X POST $BASE/api/v1/auth/register \
|
|||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"email":"docker-test@example.com","password":"DockerTest1!"}' -w "\nHTTP %{http_code}"
|
-d '{"email":"docker-test@example.com","password":"DockerTest1!"}' -w "\nHTTP %{http_code}"
|
||||||
|
|
||||||
# 检查宿主机上的 users.db
|
# 检查宿主机上的 deerflow.db
|
||||||
ls -la ${DEER_FLOW_HOME:-backend/.deer-flow}/users.db
|
ls -la ${DEER_FLOW_HOME:-backend/.deer-flow}/data/deerflow.db
|
||||||
sqlite3 ${DEER_FLOW_HOME:-backend/.deer-flow}/users.db \
|
sqlite3 ${DEER_FLOW_HOME:-backend/.deer-flow}/data/deerflow.db \
|
||||||
"SELECT email FROM users WHERE email='docker-test@example.com';"
|
"SELECT email FROM users WHERE email='docker-test@example.com';"
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:** users.db 在宿主机 `DEER_FLOW_HOME` 目录中,查询可见刚注册的用户。
|
**预期:** deerflow.db 在宿主机 `DEER_FLOW_HOME` 目录中,查询可见刚注册的用户。
|
||||||
|
|
||||||
#### TC-DOCKER-02: 重启容器后 session 保持
|
#### TC-DOCKER-02: 重启容器后 session 保持
|
||||||
|
|
||||||
@@ -1466,22 +1505,24 @@ done
|
|||||||
|
|
||||||
**已知限制:** In-process rate limiter 不跨 worker 共享。生产环境如需精确限速,需要 Redis 等外部存储。
|
**已知限制:** In-process rate limiter 不跨 worker 共享。生产环境如需精确限速,需要 Redis 等外部存储。
|
||||||
|
|
||||||
#### TC-DOCKER-04: IM 渠道不经过 auth
|
#### TC-DOCKER-04: IM 渠道使用内部认证
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# IM 渠道(Feishu/Slack/Telegram)在 gateway 容器内部通过 LangGraph SDK 通信
|
# IM 渠道(Feishu/Slack/Telegram)在 gateway 容器内部通过 LangGraph SDK 调 Gateway
|
||||||
# 不走 nginx,不经过 AuthMiddleware
|
# 请求携带 process-local internal auth header,并带匹配的 CSRF cookie/header
|
||||||
|
|
||||||
# 验证方式:检查 gateway 日志中 channel manager 的请求不包含 auth 错误
|
# 验证方式:检查 gateway 日志中 channel manager 的请求不包含 auth 错误
|
||||||
docker logs deer-flow-gateway 2>&1 | grep -E "ChannelManager|channel" | head -10
|
docker logs deer-flow-gateway 2>&1 | grep -E "ChannelManager|channel" | head -10
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:** 无 auth 相关错误。渠道通过 `langgraph-sdk` 直连 LangGraph Server(`http://langgraph:2024`),不走 auth 层。
|
**预期:** 无 auth 相关错误。渠道不依赖浏览器 cookie;服务端通过内部认证头把请求归入 `default` 用户桶。
|
||||||
|
|
||||||
#### TC-DOCKER-05: admin 密码写入 0600 凭证文件(不再走日志)
|
#### TC-DOCKER-05: reset_admin 密码写入 0600 凭证文件(不再走日志)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 凭证文件写在挂载到宿主机的 DEER_FLOW_HOME 下
|
# 首次启动不会自动生成 admin 密码。先重置已有 admin,凭据文件写在挂载到宿主机的 DEER_FLOW_HOME 下。
|
||||||
|
docker exec deer-flow-gateway python -m app.gateway.auth.reset_admin --email docker-test@example.com
|
||||||
|
|
||||||
ls -la ${DEER_FLOW_HOME:-backend/.deer-flow}/admin_initial_credentials.txt
|
ls -la ${DEER_FLOW_HOME:-backend/.deer-flow}/admin_initial_credentials.txt
|
||||||
# 预期文件权限: -rw------- (0600)
|
# 预期文件权限: -rw------- (0600)
|
||||||
|
|
||||||
@@ -1512,14 +1553,15 @@ sleep 15
|
|||||||
docker ps --filter name=deer-flow-langgraph --format '{{.Names}}' | wc -l
|
docker ps --filter name=deer-flow-langgraph --format '{{.Names}}' | wc -l
|
||||||
# 预期: 0
|
# 预期: 0
|
||||||
|
|
||||||
# auth 流程正常
|
# auth 流程正常:未登录受保护接口返回 401
|
||||||
curl -s -w "%{http_code}" -o /dev/null $BASE/api/models
|
curl -s -w "%{http_code}" -o /dev/null $BASE/api/models
|
||||||
# 预期: 401
|
# 预期: 401
|
||||||
|
|
||||||
curl -s -X POST $BASE/api/v1/auth/login/local \
|
curl -s -X POST $BASE/api/v1/auth/initialize \
|
||||||
-d "username=admin@deerflow.dev&password=<日志密码>" \
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"admin@example.com","password":"AdminPass1!"}' \
|
||||||
-c cookies.txt -w "\nHTTP %{http_code}"
|
-c cookies.txt -w "\nHTTP %{http_code}"
|
||||||
# 预期: 200
|
# 预期: 201
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.4 补充边界用例
|
### 7.4 补充边界用例
|
||||||
@@ -1587,13 +1629,15 @@ curl -s -D - -X POST $BASE/api/v1/auth/login/local \
|
|||||||
#### TC-EDGE-05: HTTP 无 max_age / HTTPS 有 max_age
|
#### TC-EDGE-05: HTTP 无 max_age / HTTPS 有 max_age
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
GW=http://localhost:8001
|
||||||
|
|
||||||
# HTTP
|
# HTTP
|
||||||
curl -s -D - -X POST $BASE/api/v1/auth/login/local \
|
curl -s -D - -X POST $GW/api/v1/auth/login/local \
|
||||||
-d "username=admin@example.com&password=正确密码" 2>/dev/null \
|
-d "username=admin@example.com&password=正确密码" 2>/dev/null \
|
||||||
| grep "access_token=" | grep -oi "max-age=[0-9]*" || echo "NO max-age (HTTP session cookie)"
|
| grep "access_token=" | grep -oi "max-age=[0-9]*" || echo "NO max-age (HTTP session cookie)"
|
||||||
|
|
||||||
# HTTPS
|
# HTTPS:直连 Gateway 才能用 X-Forwarded-Proto 模拟 HTTPS;nginx 会覆盖该 header
|
||||||
curl -s -D - -X POST $BASE/api/v1/auth/login/local \
|
curl -s -D - -X POST $GW/api/v1/auth/login/local \
|
||||||
-H "X-Forwarded-Proto: https" \
|
-H "X-Forwarded-Proto: https" \
|
||||||
-d "username=admin@example.com&password=正确密码" 2>/dev/null \
|
-d "username=admin@example.com&password=正确密码" 2>/dev/null \
|
||||||
| grep "access_token=" | grep -oi "max-age=[0-9]*"
|
| grep "access_token=" | grep -oi "max-age=[0-9]*"
|
||||||
@@ -1712,10 +1756,10 @@ curl -s -X POST $BASE/api/threads \
|
|||||||
-b cookies.txt \
|
-b cookies.txt \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "X-CSRF-Token: $CSRF" \
|
-H "X-CSRF-Token: $CSRF" \
|
||||||
-d '{"metadata":{"owner_id":"victim-user-id"}}' | jq .metadata.owner_id
|
-d '{"metadata":{"owner_id":"victim-user-id","user_id":"victim-user-id"}}' | jq .metadata
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:** 返回的 `metadata.owner_id` 应为当前登录用户的 ID,不是请求中注入的 `victim-user-id`。服务端应覆盖客户端提供的 `user_id`。
|
**预期:** 返回的 `metadata` 不包含 `owner_id` 或 `user_id`。真实所有权写入 `threads_meta.user_id`,不从客户端 metadata 接收,也不通过 metadata 回显。
|
||||||
|
|
||||||
#### 7.5.6 HTTP Method 探测
|
#### 7.5.6 HTTP Method 探测
|
||||||
|
|
||||||
@@ -1796,6 +1840,6 @@ cd backend && PYTHONPATH=. uv run pytest \
|
|||||||
# 核心接口冒烟
|
# 核心接口冒烟
|
||||||
curl -s $BASE/health # 200
|
curl -s $BASE/health # 200
|
||||||
curl -s $BASE/api/models # 401 (无 cookie)
|
curl -s $BASE/api/models # 401 (无 cookie)
|
||||||
curl -s -X POST $BASE/api/v1/auth/setup-status # 200
|
curl -s $BASE/api/v1/auth/setup-status # 200
|
||||||
curl -s $BASE/api/v1/auth/me -b cookies.txt # 200 (有 cookie)
|
curl -s $BASE/api/v1/auth/me -b cookies.txt # 200 (有 cookie)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
DeerFlow 内置了认证模块。本文档面向从无认证版本升级的用户。
|
DeerFlow 内置了认证模块。本文档面向从无认证版本升级的用户。
|
||||||
|
|
||||||
|
完整设计见 [AUTH_DESIGN.md](AUTH_DESIGN.md)。
|
||||||
|
|
||||||
## 核心概念
|
## 核心概念
|
||||||
|
|
||||||
认证模块采用**始终强制**策略:
|
认证模块采用**始终强制**策略:
|
||||||
|
|
||||||
- 首次启动时自动创建 admin 账号,随机密码打印到控制台日志
|
- 首次启动时不会自动创建账号;首次访问 `/setup` 时由操作者创建第一个 admin 账号
|
||||||
- 认证从一开始就是强制的,无竞争窗口
|
- 认证从一开始就是强制的,无竞争窗口
|
||||||
- 历史对话(升级前创建的 thread)自动迁移到 admin 名下
|
- 已有 admin 后,服务启动时会把历史对话(升级前创建且缺少 `user_id` 的 thread)迁移到 admin 名下
|
||||||
|
- 新数据按用户隔离:thread、workspace/uploads/outputs、memory、自定义 agent 都归属当前用户
|
||||||
|
|
||||||
## 升级步骤
|
## 升级步骤
|
||||||
|
|
||||||
@@ -25,39 +28,41 @@ cd backend && make install
|
|||||||
make dev
|
make dev
|
||||||
```
|
```
|
||||||
|
|
||||||
控制台会输出:
|
如果没有 admin 账号,控制台只会提示:
|
||||||
|
|
||||||
```
|
```
|
||||||
============================================================
|
============================================================
|
||||||
Admin account created on first boot
|
First boot detected — no admin account exists.
|
||||||
Email: admin@deerflow.dev
|
Visit /setup to complete admin account creation.
|
||||||
Password: aB3xK9mN_pQ7rT2w
|
|
||||||
Change it after login: Settings → Account
|
|
||||||
============================================================
|
============================================================
|
||||||
```
|
```
|
||||||
|
|
||||||
如果未登录就重启了服务,不用担心——只要 setup 未完成,每次启动都会重置密码并重新打印到控制台。
|
首次启动不会在日志里打印随机密码,也不会写入默认 admin。这样避免启动日志泄露凭据,也避免在操作者创建账号前出现可被猜测的默认身份。
|
||||||
|
|
||||||
### 3. 登录
|
### 3. 创建 admin
|
||||||
|
|
||||||
访问 `http://localhost:2026/login`,使用控制台输出的邮箱和密码登录。
|
访问 `http://localhost:2026/setup`,填写邮箱和密码创建第一个 admin 账号。创建成功后会自动登录并进入 workspace。
|
||||||
|
|
||||||
### 4. 修改密码
|
如果这是从无认证版本升级,创建 admin 后重启一次服务,让启动迁移把缺少 `user_id` 的历史 thread 归属到 admin。
|
||||||
|
|
||||||
登录后进入 Settings → Account → Change Password。
|
### 4. 登录
|
||||||
|
|
||||||
|
后续访问 `http://localhost:2026/login`,使用已创建的邮箱和密码登录。
|
||||||
|
|
||||||
### 5. 添加用户(可选)
|
### 5. 添加用户(可选)
|
||||||
|
|
||||||
其他用户通过 `/login` 页面注册,自动获得 **user** 角色。每个用户只能看到自己的对话。
|
其他用户通过 `/login` 页面注册,自动获得 **user** 角色。每个用户只能看到自己的对话、上传文件、输出文件、memory 和自定义 agent。
|
||||||
|
|
||||||
## 安全机制
|
## 安全机制
|
||||||
|
|
||||||
| 机制 | 说明 |
|
| 机制 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| JWT HttpOnly Cookie | Token 不暴露给 JavaScript,防止 XSS 窃取 |
|
| JWT HttpOnly Cookie | Token 不暴露给 JavaScript,防止 XSS 窃取 |
|
||||||
| CSRF Double Submit Cookie | 所有 POST/PUT/DELETE 请求需携带 `X-CSRF-Token` |
|
| CSRF Double Submit Cookie | 受保护的 POST/PUT/PATCH/DELETE 请求需携带 `X-CSRF-Token`;登录/注册/初始化/登出走 auth 端点 Origin 校验 |
|
||||||
| bcrypt 密码哈希 | 密码不以明文存储 |
|
| bcrypt 密码哈希 | 密码不以明文存储 |
|
||||||
| 多租户隔离 | 用户只能访问自己的 thread |
|
| Thread owner filter | `threads_meta.user_id` 由服务端认证上下文写入,搜索、读取、更新、删除默认按当前用户过滤 |
|
||||||
|
| 文件系统隔离 | 线程数据写入 `{base_dir}/users/{user_id}/threads/{thread_id}/user-data/`,sandbox 内统一映射为 `/mnt/user-data/` |
|
||||||
|
| Memory / agent 隔离 | 用户 memory 和自定义 agent 写入 `{base_dir}/users/{user_id}/...`;旧共享 agent 只作为只读兼容回退 |
|
||||||
| HTTPS 自适应 | 检测 `x-forwarded-proto`,自动设置 `Secure` cookie 标志 |
|
| HTTPS 自适应 | 检测 `x-forwarded-proto`,自动设置 `Secure` cookie 标志 |
|
||||||
|
|
||||||
## 常见操作
|
## 常见操作
|
||||||
@@ -74,22 +79,26 @@ python -m app.gateway.auth.reset_admin
|
|||||||
python -m app.gateway.auth.reset_admin --email user@example.com
|
python -m app.gateway.auth.reset_admin --email user@example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
会输出新的随机密码。
|
会把新的随机密码写入 `.deer-flow/admin_initial_credentials.txt`,文件权限为 `0600`。命令行只输出文件路径,不输出明文密码。
|
||||||
|
|
||||||
### 完全重置
|
### 完全重置
|
||||||
|
|
||||||
删除用户数据库,重启后自动创建新 admin:
|
删除统一 SQLite 数据库,重启后重新访问 `/setup` 创建新 admin:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rm -f backend/.deer-flow/users.db
|
rm -f backend/.deer-flow/data/deerflow.db
|
||||||
# 重启服务,控制台输出新密码
|
# 重启服务后访问 http://localhost:2026/setup
|
||||||
```
|
```
|
||||||
|
|
||||||
## 数据存储
|
## 数据存储
|
||||||
|
|
||||||
| 文件 | 内容 |
|
| 文件 | 内容 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `.deer-flow/users.db` | SQLite 用户数据库(密码哈希、角色) |
|
| `.deer-flow/data/deerflow.db` | 统一 SQLite 数据库(users、threads_meta、runs、feedback 等应用数据) |
|
||||||
|
| `.deer-flow/users/{user_id}/threads/{thread_id}/user-data/` | 用户线程的 workspace、uploads、outputs |
|
||||||
|
| `.deer-flow/users/{user_id}/memory.json` | 用户级 memory |
|
||||||
|
| `.deer-flow/users/{user_id}/agents/{agent_name}/` | 用户自定义 agent 配置、SOUL 和 agent memory |
|
||||||
|
| `.deer-flow/admin_initial_credentials.txt` | `reset_admin` 生成的新凭据文件(0600,读完应删除) |
|
||||||
| `.env` 中的 `AUTH_JWT_SECRET` | JWT 签名密钥(未设置时自动生成临时密钥,重启后 session 失效) |
|
| `.env` 中的 `AUTH_JWT_SECRET` | JWT 签名密钥(未设置时自动生成临时密钥,重启后 session 失效) |
|
||||||
|
|
||||||
### 生产环境建议
|
### 生产环境建议
|
||||||
@@ -111,19 +120,21 @@ python -c "import secrets; print(secrets.token_urlsafe(32))"
|
|||||||
| `/api/v1/auth/me` | GET | 获取当前用户信息 |
|
| `/api/v1/auth/me` | GET | 获取当前用户信息 |
|
||||||
| `/api/v1/auth/change-password` | POST | 修改密码 |
|
| `/api/v1/auth/change-password` | POST | 修改密码 |
|
||||||
| `/api/v1/auth/setup-status` | GET | 检查 admin 是否存在 |
|
| `/api/v1/auth/setup-status` | GET | 检查 admin 是否存在 |
|
||||||
|
| `/api/v1/auth/initialize` | POST | 首次初始化第一个 admin(仅无 admin 时可调用) |
|
||||||
|
|
||||||
## 兼容性
|
## 兼容性
|
||||||
|
|
||||||
- **标准模式**(`make dev`):完全兼容,admin 自动创建
|
- **标准模式**(`make dev`):完全兼容;无 admin 时访问 `/setup` 初始化
|
||||||
- **Gateway 模式**(`make dev-pro`):完全兼容
|
- **Gateway 模式**(`make dev-pro`):完全兼容
|
||||||
- **Docker 部署**:完全兼容,`.deer-flow/users.db` 需持久化卷挂载
|
- **Docker 部署**:完全兼容,`.deer-flow/data/deerflow.db` 需持久化卷挂载
|
||||||
- **IM 渠道**(Feishu/Slack/Telegram):通过 LangGraph SDK 通信,不经过认证层
|
- **IM 渠道**(Feishu/Slack/Telegram):通过 Gateway 内部认证通信,使用 `default` 用户桶
|
||||||
- **DeerFlowClient**(嵌入式):不经过 HTTP,不受认证影响
|
- **DeerFlowClient**(嵌入式):不经过 HTTP,不受认证影响
|
||||||
|
|
||||||
## 故障排查
|
## 故障排查
|
||||||
|
|
||||||
| 症状 | 原因 | 解决 |
|
| 症状 | 原因 | 解决 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 启动后没看到密码 | admin 已存在(非首次启动) | 用 `reset_admin` 重置,或删 `users.db` |
|
| 启动后没看到密码 | 当前实现不在启动日志输出密码 | 首次安装访问 `/setup`;忘记密码用 `reset_admin` |
|
||||||
|
| `/login` 自动跳到 `/setup` | 系统还没有 admin | 在 `/setup` 创建第一个 admin |
|
||||||
| 登录后 POST 返回 403 | CSRF token 缺失 | 确认前端已更新 |
|
| 登录后 POST 返回 403 | CSRF token 缺失 | 确认前端已更新 |
|
||||||
| 重启后需要重新登录 | `AUTH_JWT_SECRET` 未持久化 | 在 `.env` 中设置固定密钥 |
|
| 重启后需要重新登录 | `AUTH_JWT_SECRET` 未持久化 | 在 `.env` 中设置固定密钥 |
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ This directory contains detailed documentation for the DeerFlow backend.
|
|||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| [ARCHITECTURE.md](ARCHITECTURE.md) | System architecture overview |
|
| [ARCHITECTURE.md](ARCHITECTURE.md) | System architecture overview |
|
||||||
| [API.md](API.md) | Complete API reference |
|
| [API.md](API.md) | Complete API reference |
|
||||||
|
| [AUTH_DESIGN.md](AUTH_DESIGN.md) | User authentication, CSRF, and per-user isolation design |
|
||||||
| [CONFIGURATION.md](CONFIGURATION.md) | Configuration options |
|
| [CONFIGURATION.md](CONFIGURATION.md) | Configuration options |
|
||||||
| [SETUP.md](SETUP.md) | Quick setup guide |
|
| [SETUP.md](SETUP.md) | Quick setup guide |
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ docs/
|
|||||||
├── README.md # This file
|
├── README.md # This file
|
||||||
├── ARCHITECTURE.md # System architecture
|
├── ARCHITECTURE.md # System architecture
|
||||||
├── API.md # API reference
|
├── API.md # API reference
|
||||||
|
├── AUTH_DESIGN.md # User authentication and isolation design
|
||||||
├── CONFIGURATION.md # Configuration guide
|
├── CONFIGURATION.md # Configuration guide
|
||||||
├── SETUP.md # Setup instructions
|
├── SETUP.md # Setup instructions
|
||||||
├── FILE_UPLOAD.md # File upload feature
|
├── FILE_UPLOAD.md # File upload feature
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ def _assemble_from_features(
|
|||||||
9. MemoryMiddleware (memory feature)
|
9. MemoryMiddleware (memory feature)
|
||||||
10. ViewImageMiddleware (vision feature)
|
10. ViewImageMiddleware (vision feature)
|
||||||
11. SubagentLimitMiddleware (subagent feature)
|
11. SubagentLimitMiddleware (subagent feature)
|
||||||
12. LoopDetectionMiddleware (always)
|
12. LoopDetectionMiddleware (loop_detection feature)
|
||||||
13. ClarificationMiddleware (always last)
|
13. ClarificationMiddleware (always last)
|
||||||
|
|
||||||
Two-phase ordering:
|
Two-phase ordering:
|
||||||
@@ -272,10 +272,15 @@ def _assemble_from_features(
|
|||||||
|
|
||||||
extra_tools.append(task_tool)
|
extra_tools.append(task_tool)
|
||||||
|
|
||||||
# --- [12] LoopDetection (always) ---
|
# --- [12] LoopDetection ---
|
||||||
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
if feat.loop_detection is not False:
|
||||||
|
if isinstance(feat.loop_detection, AgentMiddleware):
|
||||||
|
chain.append(feat.loop_detection)
|
||||||
|
else:
|
||||||
|
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
||||||
|
from deerflow.config.loop_detection_config import LoopDetectionConfig
|
||||||
|
|
||||||
chain.append(LoopDetectionMiddleware())
|
chain.append(LoopDetectionMiddleware.from_config(LoopDetectionConfig()))
|
||||||
|
|
||||||
# --- [13] Clarification (always last among built-ins) ---
|
# --- [13] Clarification (always last among built-ins) ---
|
||||||
chain.append(ClarificationMiddleware())
|
chain.append(ClarificationMiddleware())
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class RuntimeFeatures:
|
|||||||
vision: bool | AgentMiddleware = False
|
vision: bool | AgentMiddleware = False
|
||||||
auto_title: bool | AgentMiddleware = False
|
auto_title: bool | AgentMiddleware = False
|
||||||
guardrail: Literal[False] | AgentMiddleware = False
|
guardrail: Literal[False] | AgentMiddleware = False
|
||||||
|
loop_detection: bool | AgentMiddleware = True
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ from deerflow.agents.thread_state import ThreadState
|
|||||||
from deerflow.config.agents_config import load_agent_config, validate_agent_name
|
from deerflow.config.agents_config import load_agent_config, validate_agent_name
|
||||||
from deerflow.config.app_config import AppConfig, get_app_config
|
from deerflow.config.app_config import AppConfig, get_app_config
|
||||||
from deerflow.models import create_chat_model
|
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
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -256,6 +258,12 @@ def _build_middlewares(
|
|||||||
resolved_app_config = app_config or get_app_config()
|
resolved_app_config = app_config or get_app_config()
|
||||||
middlewares = build_lead_runtime_middlewares(app_config=resolved_app_config, lazy_init=True)
|
middlewares = build_lead_runtime_middlewares(app_config=resolved_app_config, lazy_init=True)
|
||||||
|
|
||||||
|
# Always inject current date (and optionally memory) as <system-reminder> into the
|
||||||
|
# first HumanMessage to keep the system prompt fully static for prefix-cache reuse.
|
||||||
|
from deerflow.agents.middlewares.dynamic_context_middleware import DynamicContextMiddleware
|
||||||
|
|
||||||
|
middlewares.append(DynamicContextMiddleware(agent_name=agent_name, app_config=resolved_app_config))
|
||||||
|
|
||||||
# Add summarization middleware if enabled
|
# Add summarization middleware if enabled
|
||||||
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
|
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
|
||||||
if summarization_middleware is not None:
|
if summarization_middleware is not None:
|
||||||
@@ -297,7 +305,9 @@ def _build_middlewares(
|
|||||||
middlewares.append(SubagentLimitMiddleware(max_concurrent=max_concurrent_subagents))
|
middlewares.append(SubagentLimitMiddleware(max_concurrent=max_concurrent_subagents))
|
||||||
|
|
||||||
# LoopDetectionMiddleware — detect and break repetitive tool call loops
|
# LoopDetectionMiddleware — detect and break repetitive tool call loops
|
||||||
middlewares.append(LoopDetectionMiddleware())
|
loop_detection_config = resolved_app_config.loop_detection
|
||||||
|
if loop_detection_config.enabled:
|
||||||
|
middlewares.append(LoopDetectionMiddleware.from_config(loop_detection_config))
|
||||||
|
|
||||||
# Inject custom middlewares before ClarificationMiddleware
|
# Inject custom middlewares before ClarificationMiddleware
|
||||||
if custom_middlewares:
|
if custom_middlewares:
|
||||||
@@ -308,6 +318,28 @@ def _build_middlewares(
|
|||||||
return middlewares
|
return middlewares
|
||||||
|
|
||||||
|
|
||||||
|
def _available_skill_names(agent_config, is_bootstrap: bool) -> set[str] | None:
|
||||||
|
if is_bootstrap:
|
||||||
|
return {"bootstrap"}
|
||||||
|
if agent_config and agent_config.skills is not None:
|
||||||
|
return set(agent_config.skills)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_enabled_skills_for_tool_policy(available_skills: set[str] | None, *, app_config: AppConfig) -> list[Skill]:
|
||||||
|
try:
|
||||||
|
from deerflow.agents.lead_agent.prompt import get_enabled_skills_for_config
|
||||||
|
|
||||||
|
skills = get_enabled_skills_for_config(app_config)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to load skills for allowed-tools policy")
|
||||||
|
raise
|
||||||
|
|
||||||
|
if available_skills is None:
|
||||||
|
return skills
|
||||||
|
return [skill for skill in skills if skill.name in available_skills]
|
||||||
|
|
||||||
|
|
||||||
def make_lead_agent(config: RunnableConfig):
|
def make_lead_agent(config: RunnableConfig):
|
||||||
"""LangGraph graph factory; keep the signature compatible with LangGraph Server."""
|
"""LangGraph graph factory; keep the signature compatible with LangGraph Server."""
|
||||||
runtime_config = _get_runtime_config(config)
|
runtime_config = _get_runtime_config(config)
|
||||||
@@ -318,7 +350,7 @@ def make_lead_agent(config: RunnableConfig):
|
|||||||
def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||||
# Lazy import to avoid circular dependency
|
# Lazy import to avoid circular dependency
|
||||||
from deerflow.tools import get_available_tools
|
from deerflow.tools import get_available_tools
|
||||||
from deerflow.tools.builtins import setup_agent
|
from deerflow.tools.builtins import setup_agent, update_agent
|
||||||
|
|
||||||
cfg = _get_runtime_config(config)
|
cfg = _get_runtime_config(config)
|
||||||
resolved_app_config = app_config
|
resolved_app_config = app_config
|
||||||
@@ -333,6 +365,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
agent_name = validate_agent_name(cfg.get("agent_name"))
|
agent_name = validate_agent_name(cfg.get("agent_name"))
|
||||||
|
|
||||||
agent_config = load_agent_config(agent_name) if not is_bootstrap else None
|
agent_config = load_agent_config(agent_name) if not is_bootstrap else None
|
||||||
|
available_skills = _available_skill_names(agent_config, is_bootstrap)
|
||||||
# Custom agent model from agent config (if any), or None to let _resolve_model_name pick the default
|
# Custom agent model from agent config (if any), or None to let _resolve_model_name pick the default
|
||||||
agent_model_name = agent_config.model if agent_config and agent_config.model else None
|
agent_model_name = agent_config.model if agent_config and agent_config.model else None
|
||||||
|
|
||||||
@@ -371,15 +404,18 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
"is_plan_mode": is_plan_mode,
|
"is_plan_mode": is_plan_mode,
|
||||||
"subagent_enabled": subagent_enabled,
|
"subagent_enabled": subagent_enabled,
|
||||||
"tool_groups": agent_config.tool_groups if agent_config else None,
|
"tool_groups": agent_config.tool_groups if agent_config else None,
|
||||||
"available_skills": ["bootstrap"] if is_bootstrap else (agent_config.skills if agent_config and agent_config.skills is not None else None),
|
"available_skills": sorted(available_skills) if available_skills is not None else None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
skills_for_tool_policy = _load_enabled_skills_for_tool_policy(available_skills, app_config=resolved_app_config)
|
||||||
|
|
||||||
if is_bootstrap:
|
if is_bootstrap:
|
||||||
# Special bootstrap agent with minimal prompt for initial custom agent creation flow
|
# 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]
|
||||||
return create_agent(
|
return create_agent(
|
||||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config),
|
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config),
|
||||||
tools=get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled, app_config=resolved_app_config) + [setup_agent],
|
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),
|
middleware=_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
|
||||||
system_prompt=apply_prompt_template(
|
system_prompt=apply_prompt_template(
|
||||||
subagent_enabled=subagent_enabled,
|
subagent_enabled=subagent_enabled,
|
||||||
@@ -390,15 +426,14 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
state_schema=ThreadState,
|
state_schema=ThreadState,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Custom agents can update their own SOUL.md / config via update_agent.
|
||||||
|
# The default agent (no agent_name) does not see this tool.
|
||||||
|
extra_tools = [update_agent] if agent_name else []
|
||||||
# Default lead agent (unchanged behavior)
|
# 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)
|
||||||
return create_agent(
|
return create_agent(
|
||||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, 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),
|
||||||
tools=get_available_tools(
|
tools=filter_tools_by_skill_allowed_tools(tools + extra_tools, skills_for_tool_policy),
|
||||||
model_name=model_name,
|
|
||||||
groups=agent_config.tool_groups if agent_config else None,
|
|
||||||
subagent_enabled=subagent_enabled,
|
|
||||||
app_config=resolved_app_config,
|
|
||||||
),
|
|
||||||
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_name, app_config=resolved_app_config),
|
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_name, app_config=resolved_app_config),
|
||||||
system_prompt=apply_prompt_template(
|
system_prompt=apply_prompt_template(
|
||||||
subagent_enabled=subagent_enabled,
|
subagent_enabled=subagent_enabled,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
@@ -20,6 +19,7 @@ logger = logging.getLogger(__name__)
|
|||||||
_ENABLED_SKILLS_REFRESH_WAIT_TIMEOUT_SECONDS = 5.0
|
_ENABLED_SKILLS_REFRESH_WAIT_TIMEOUT_SECONDS = 5.0
|
||||||
_enabled_skills_lock = threading.Lock()
|
_enabled_skills_lock = threading.Lock()
|
||||||
_enabled_skills_cache: list[Skill] | None = None
|
_enabled_skills_cache: list[Skill] | None = None
|
||||||
|
_enabled_skills_by_config_cache: dict[int, tuple[object, list[Skill]]] = {}
|
||||||
_enabled_skills_refresh_active = False
|
_enabled_skills_refresh_active = False
|
||||||
_enabled_skills_refresh_version = 0
|
_enabled_skills_refresh_version = 0
|
||||||
_enabled_skills_refresh_event = threading.Event()
|
_enabled_skills_refresh_event = threading.Event()
|
||||||
@@ -84,6 +84,7 @@ def _invalidate_enabled_skills_cache() -> threading.Event:
|
|||||||
_get_cached_skills_prompt_section.cache_clear()
|
_get_cached_skills_prompt_section.cache_clear()
|
||||||
with _enabled_skills_lock:
|
with _enabled_skills_lock:
|
||||||
_enabled_skills_cache = None
|
_enabled_skills_cache = None
|
||||||
|
_enabled_skills_by_config_cache.clear()
|
||||||
_enabled_skills_refresh_version += 1
|
_enabled_skills_refresh_version += 1
|
||||||
_enabled_skills_refresh_event.clear()
|
_enabled_skills_refresh_event.clear()
|
||||||
if _enabled_skills_refresh_active:
|
if _enabled_skills_refresh_active:
|
||||||
@@ -107,6 +108,15 @@ def warm_enabled_skills_cache(timeout_seconds: float = _ENABLED_SKILLS_REFRESH_W
|
|||||||
|
|
||||||
|
|
||||||
def _get_enabled_skills():
|
def _get_enabled_skills():
|
||||||
|
return get_cached_enabled_skills()
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached_enabled_skills() -> list[Skill]:
|
||||||
|
"""Return the cached enabled-skills list, kicking off a background refresh on miss.
|
||||||
|
|
||||||
|
Safe to call from request paths: never blocks on disk I/O. Returns an empty
|
||||||
|
list on cache miss; the next call will see the warmed result.
|
||||||
|
"""
|
||||||
with _enabled_skills_lock:
|
with _enabled_skills_lock:
|
||||||
cached = _enabled_skills_cache
|
cached = _enabled_skills_cache
|
||||||
|
|
||||||
@@ -117,17 +127,29 @@ def _get_enabled_skills():
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _get_enabled_skills_for_config(app_config: AppConfig | None = None) -> list[Skill]:
|
def get_enabled_skills_for_config(app_config: AppConfig | None = None) -> list[Skill]:
|
||||||
"""Return enabled skills using the caller's config source.
|
"""Return enabled skills using the caller's config source.
|
||||||
|
|
||||||
When a concrete ``app_config`` is supplied, bypass the global enabled-skills
|
When a concrete ``app_config`` is supplied, cache the loaded skills by that
|
||||||
cache so the skill list and skill paths are resolved from the same config
|
config object's identity so request-scoped config injection still resolves
|
||||||
object. This keeps request-scoped config injection consistent even while the
|
skill paths from the matching config without rescanning storage on every
|
||||||
release branch still supports global fallback paths.
|
agent factory call.
|
||||||
"""
|
"""
|
||||||
if app_config is None:
|
if app_config is None:
|
||||||
return _get_enabled_skills()
|
return _get_enabled_skills()
|
||||||
return list(get_or_new_skill_storage(app_config=app_config).load_skills(enabled_only=True))
|
|
||||||
|
cache_key = id(app_config)
|
||||||
|
with _enabled_skills_lock:
|
||||||
|
cached = _enabled_skills_by_config_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
cached_config, cached_skills = cached
|
||||||
|
if cached_config is app_config:
|
||||||
|
return list(cached_skills)
|
||||||
|
|
||||||
|
skills = list(get_or_new_skill_storage(app_config=app_config).load_skills(enabled_only=True))
|
||||||
|
with _enabled_skills_lock:
|
||||||
|
_enabled_skills_by_config_cache[cache_key] = (app_config, skills)
|
||||||
|
return list(skills)
|
||||||
|
|
||||||
|
|
||||||
def _skill_mutability_label(category: SkillCategory | str) -> str:
|
def _skill_mutability_label(category: SkillCategory | str) -> str:
|
||||||
@@ -344,8 +366,7 @@ You are {agent_name}, an open-source super agent.
|
|||||||
</role>
|
</role>
|
||||||
|
|
||||||
{soul}
|
{soul}
|
||||||
{memory_context}
|
{self_update_section}
|
||||||
|
|
||||||
<thinking_style>
|
<thinking_style>
|
||||||
- Think concisely and strategically about the user's request BEFORE taking action
|
- Think concisely and strategically about the user's request BEFORE taking action
|
||||||
- Break down the task: What is clear? What is ambiguous? What is missing?
|
- Break down the task: What is clear? What is ambiguous? What is missing?
|
||||||
@@ -604,7 +625,7 @@ You have access to skills that provide optimized workflows for specific tasks. E
|
|||||||
|
|
||||||
def get_skills_prompt_section(available_skills: set[str] | None = None, *, app_config: AppConfig | None = None) -> str:
|
def get_skills_prompt_section(available_skills: set[str] | None = None, *, app_config: AppConfig | None = None) -> str:
|
||||||
"""Generate the skills prompt section with available skills list."""
|
"""Generate the skills prompt section with available skills list."""
|
||||||
skills = _get_enabled_skills_for_config(app_config)
|
skills = get_enabled_skills_for_config(app_config)
|
||||||
|
|
||||||
if app_config is None:
|
if app_config is None:
|
||||||
try:
|
try:
|
||||||
@@ -643,6 +664,26 @@ def get_agent_soul(agent_name: str | None) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_self_update_section(agent_name: str | None) -> str:
|
||||||
|
"""Prompt block that teaches the custom agent to persist self-updates via update_agent."""
|
||||||
|
if not agent_name:
|
||||||
|
return ""
|
||||||
|
return f"""<self_update>
|
||||||
|
You are running as the custom agent **{agent_name}** with a persisted SOUL.md and config.yaml.
|
||||||
|
|
||||||
|
When the user asks you to update your own description, personality, behaviour, skill set, tool groups, or default model,
|
||||||
|
you MUST persist the change with the `update_agent` tool. Do NOT use `bash`, `write_file`, or any sandbox tool to edit
|
||||||
|
SOUL.md or config.yaml — those write into a temporary sandbox/tool workspace and the changes will be lost on the next turn.
|
||||||
|
|
||||||
|
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.
|
||||||
|
- 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:
|
def get_deferred_tools_prompt_section(*, app_config: AppConfig | None = None) -> str:
|
||||||
"""Generate <available-deferred-tools> block for the system prompt.
|
"""Generate <available-deferred-tools> block for the system prompt.
|
||||||
|
|
||||||
@@ -732,9 +773,6 @@ def apply_prompt_template(
|
|||||||
available_skills: set[str] | None = None,
|
available_skills: set[str] | None = None,
|
||||||
app_config: AppConfig | None = None,
|
app_config: AppConfig | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
# Get memory context
|
|
||||||
memory_context = _get_memory_context(agent_name, app_config=app_config)
|
|
||||||
|
|
||||||
# Include subagent section only if enabled (from runtime parameter)
|
# Include subagent section only if enabled (from runtime parameter)
|
||||||
n = max_concurrent_subagents
|
n = max_concurrent_subagents
|
||||||
subagent_section = _build_subagent_section(n, app_config=app_config) if subagent_enabled else ""
|
subagent_section = _build_subagent_section(n, app_config=app_config) if subagent_enabled else ""
|
||||||
@@ -768,17 +806,18 @@ def apply_prompt_template(
|
|||||||
custom_mounts_section = _build_custom_mounts_section(app_config=app_config)
|
custom_mounts_section = _build_custom_mounts_section(app_config=app_config)
|
||||||
acp_and_mounts_section = "\n".join(section for section in (acp_section, custom_mounts_section) if section)
|
acp_and_mounts_section = "\n".join(section for section in (acp_section, custom_mounts_section) if section)
|
||||||
|
|
||||||
# Format the prompt with dynamic skills and memory
|
# Build and return the fully static system prompt.
|
||||||
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
# Memory and current date are injected per-turn via DynamicContextMiddleware
|
||||||
|
# as a <system-reminder> in the first HumanMessage, keeping this prompt
|
||||||
|
# identical across users and sessions for maximum prefix-cache reuse.
|
||||||
|
return SYSTEM_PROMPT_TEMPLATE.format(
|
||||||
agent_name=agent_name or "DeerFlow 2.0",
|
agent_name=agent_name or "DeerFlow 2.0",
|
||||||
soul=get_agent_soul(agent_name),
|
soul=get_agent_soul(agent_name),
|
||||||
|
self_update_section=_build_self_update_section(agent_name),
|
||||||
skills_section=skills_section,
|
skills_section=skills_section,
|
||||||
deferred_tools_section=deferred_tools_section,
|
deferred_tools_section=deferred_tools_section,
|
||||||
memory_context=memory_context,
|
|
||||||
subagent_section=subagent_section,
|
subagent_section=subagent_section,
|
||||||
subagent_reminder=subagent_reminder,
|
subagent_reminder=subagent_reminder,
|
||||||
subagent_thinking=subagent_thinking,
|
subagent_thinking=subagent_thinking,
|
||||||
acp_section=acp_and_mounts_section,
|
acp_section=acp_and_mounts_section,
|
||||||
)
|
)
|
||||||
|
|
||||||
return prompt + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
|
|
||||||
|
|||||||
+57
-26
@@ -36,42 +36,73 @@ class DanglingToolCallMiddleware(AgentMiddleware[AgentState]):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _message_tool_calls(msg) -> list[dict]:
|
def _message_tool_calls(msg) -> list[dict]:
|
||||||
"""Return normalized tool calls from structured fields or raw provider payloads."""
|
"""Return normalized tool calls from structured fields or raw provider payloads.
|
||||||
|
|
||||||
|
LangChain stores malformed provider function calls in ``invalid_tool_calls``.
|
||||||
|
They do not execute, but provider adapters may still serialize enough of
|
||||||
|
the call id/name back into the next request that strict OpenAI-compatible
|
||||||
|
validators expect a matching ToolMessage. Treat them as dangling calls so
|
||||||
|
the next model request stays well-formed and the model sees a recoverable
|
||||||
|
tool error instead of another provider 400.
|
||||||
|
"""
|
||||||
|
normalized: list[dict] = []
|
||||||
|
|
||||||
tool_calls = getattr(msg, "tool_calls", None) or []
|
tool_calls = getattr(msg, "tool_calls", None) or []
|
||||||
if tool_calls:
|
normalized.extend(list(tool_calls))
|
||||||
return list(tool_calls)
|
|
||||||
|
|
||||||
raw_tool_calls = (getattr(msg, "additional_kwargs", None) or {}).get("tool_calls") or []
|
raw_tool_calls = (getattr(msg, "additional_kwargs", None) or {}).get("tool_calls") or []
|
||||||
normalized: list[dict] = []
|
if not tool_calls:
|
||||||
for raw_tc in raw_tool_calls:
|
for raw_tc in raw_tool_calls:
|
||||||
if not isinstance(raw_tc, dict):
|
if not isinstance(raw_tc, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
function = raw_tc.get("function")
|
||||||
|
name = raw_tc.get("name")
|
||||||
|
if not name and isinstance(function, dict):
|
||||||
|
name = function.get("name")
|
||||||
|
|
||||||
|
args = raw_tc.get("args", {})
|
||||||
|
if not args and isinstance(function, dict):
|
||||||
|
raw_args = function.get("arguments")
|
||||||
|
if isinstance(raw_args, str):
|
||||||
|
try:
|
||||||
|
parsed_args = json.loads(raw_args)
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError):
|
||||||
|
parsed_args = {}
|
||||||
|
args = parsed_args if isinstance(parsed_args, dict) else {}
|
||||||
|
|
||||||
|
normalized.append(
|
||||||
|
{
|
||||||
|
"id": raw_tc.get("id"),
|
||||||
|
"name": name or "unknown",
|
||||||
|
"args": args if isinstance(args, dict) else {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for invalid_tc in getattr(msg, "invalid_tool_calls", None) or []:
|
||||||
|
if not isinstance(invalid_tc, dict):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
function = raw_tc.get("function")
|
|
||||||
name = raw_tc.get("name")
|
|
||||||
if not name and isinstance(function, dict):
|
|
||||||
name = function.get("name")
|
|
||||||
|
|
||||||
args = raw_tc.get("args", {})
|
|
||||||
if not args and isinstance(function, dict):
|
|
||||||
raw_args = function.get("arguments")
|
|
||||||
if isinstance(raw_args, str):
|
|
||||||
try:
|
|
||||||
parsed_args = json.loads(raw_args)
|
|
||||||
except (TypeError, ValueError, json.JSONDecodeError):
|
|
||||||
parsed_args = {}
|
|
||||||
args = parsed_args if isinstance(parsed_args, dict) else {}
|
|
||||||
|
|
||||||
normalized.append(
|
normalized.append(
|
||||||
{
|
{
|
||||||
"id": raw_tc.get("id"),
|
"id": invalid_tc.get("id"),
|
||||||
"name": name or "unknown",
|
"name": invalid_tc.get("name") or "unknown",
|
||||||
"args": args if isinstance(args, dict) else {},
|
"args": {},
|
||||||
|
"invalid": True,
|
||||||
|
"error": invalid_tc.get("error"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _synthetic_tool_message_content(tool_call: dict) -> str:
|
||||||
|
if tool_call.get("invalid"):
|
||||||
|
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}]"
|
||||||
|
return "[Tool call could not be executed because its arguments were invalid.]"
|
||||||
|
return "[Tool call was interrupted and did not return a result.]"
|
||||||
|
|
||||||
def _build_patched_messages(self, messages: list) -> list | None:
|
def _build_patched_messages(self, messages: list) -> list | None:
|
||||||
"""Return a new message list with patches inserted at the correct positions.
|
"""Return a new message list with patches inserted at the correct positions.
|
||||||
|
|
||||||
@@ -114,7 +145,7 @@ class DanglingToolCallMiddleware(AgentMiddleware[AgentState]):
|
|||||||
if tc_id and tc_id not in existing_tool_msg_ids and tc_id not in patched_ids:
|
if tc_id and tc_id not in existing_tool_msg_ids and tc_id not in patched_ids:
|
||||||
patched.append(
|
patched.append(
|
||||||
ToolMessage(
|
ToolMessage(
|
||||||
content="[Tool call was interrupted and did not return a result.]",
|
content=self._synthetic_tool_message_content(tc),
|
||||||
tool_call_id=tc_id,
|
tool_call_id=tc_id,
|
||||||
name=tc.get("name", "unknown"),
|
name=tc.get("name", "unknown"),
|
||||||
status="error",
|
status="error",
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""Middleware to inject dynamic context (memory, current date) as a system-reminder.
|
||||||
|
|
||||||
|
The system prompt is kept fully static for maximum prefix-cache reuse across users
|
||||||
|
and sessions. The current date is always injected. Per-user memory is also injected
|
||||||
|
when ``memory.injection_enabled`` is True in the app config. Both are delivered once
|
||||||
|
per conversation as a dedicated <system-reminder> HumanMessage inserted before the
|
||||||
|
first user message (frozen-snapshot pattern).
|
||||||
|
|
||||||
|
When a conversation spans midnight the middleware detects the date change and injects
|
||||||
|
a lightweight date-update reminder as a separate HumanMessage before the current turn.
|
||||||
|
This correction is persisted so subsequent turns on the new day see a consistent history
|
||||||
|
and do not re-inject.
|
||||||
|
|
||||||
|
Reminder format:
|
||||||
|
|
||||||
|
<system-reminder>
|
||||||
|
<memory>...</memory>
|
||||||
|
|
||||||
|
<current_date>2026-05-08, Friday</current_date>
|
||||||
|
</system-reminder>
|
||||||
|
|
||||||
|
Date-update format:
|
||||||
|
|
||||||
|
<system-reminder>
|
||||||
|
<current_date>2026-05-09, Saturday</current_date>
|
||||||
|
</system-reminder>
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING, override
|
||||||
|
|
||||||
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
|
from langchain_core.messages import HumanMessage
|
||||||
|
from langgraph.runtime import Runtime
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from deerflow.config.app_config import AppConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DATE_RE = re.compile(r"<current_date>([^<]+)</current_date>")
|
||||||
|
_DYNAMIC_CONTEXT_REMINDER_KEY = "dynamic_context_reminder"
|
||||||
|
_SUMMARY_MESSAGE_NAME = "summary"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_date(content: str) -> str | None:
|
||||||
|
"""Return the first <current_date> value found in *content*, or None."""
|
||||||
|
m = _DATE_RE.search(content)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def is_dynamic_context_reminder(message: object) -> bool:
|
||||||
|
"""Return whether *message* is a hidden dynamic-context reminder."""
|
||||||
|
return isinstance(message, HumanMessage) and bool(message.additional_kwargs.get(_DYNAMIC_CONTEXT_REMINDER_KEY))
|
||||||
|
|
||||||
|
|
||||||
|
def _last_injected_date(messages: list) -> str | None:
|
||||||
|
"""Scan messages in reverse and return the most recently injected date.
|
||||||
|
|
||||||
|
Detection uses the ``dynamic_context_reminder`` additional_kwargs flag rather
|
||||||
|
than content substring matching, so user messages containing ``<system-reminder>``
|
||||||
|
are not mistakenly treated as injected reminders.
|
||||||
|
"""
|
||||||
|
for msg in reversed(messages):
|
||||||
|
if is_dynamic_context_reminder(msg):
|
||||||
|
content_str = msg.content if isinstance(msg.content, str) else str(msg.content)
|
||||||
|
return _extract_date(content_str)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_user_injection_target(message: object) -> bool:
|
||||||
|
"""Return whether *message* can receive a dynamic-context reminder."""
|
||||||
|
return isinstance(message, HumanMessage) and not is_dynamic_context_reminder(message) and message.name != _SUMMARY_MESSAGE_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class DynamicContextMiddleware(AgentMiddleware):
|
||||||
|
"""Inject memory and current date into HumanMessages as a <system-reminder>.
|
||||||
|
|
||||||
|
First turn
|
||||||
|
----------
|
||||||
|
Prepends a full system-reminder (memory + date) to the first HumanMessage and
|
||||||
|
persists it (same message ID). The first message is then frozen for the whole
|
||||||
|
session — its content never changes again, so the prefix cache can hit on every
|
||||||
|
subsequent turn.
|
||||||
|
|
||||||
|
Midnight crossing
|
||||||
|
-----------------
|
||||||
|
If the conversation spans midnight, the current date differs from the date that
|
||||||
|
was injected earlier. In that case a lightweight date-update reminder is prepended
|
||||||
|
to the **current** (last) HumanMessage and persisted. Subsequent turns on the new
|
||||||
|
day see the corrected date in history and skip re-injection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, agent_name: str | None = None, *, app_config: AppConfig | None = None):
|
||||||
|
super().__init__()
|
||||||
|
self._agent_name = agent_name
|
||||||
|
self._app_config = app_config
|
||||||
|
|
||||||
|
def _build_full_reminder(self) -> str:
|
||||||
|
from deerflow.agents.lead_agent.prompt import _get_memory_context
|
||||||
|
|
||||||
|
# Memory injection is gated by injection_enabled; date is always included.
|
||||||
|
injection_enabled = self._app_config.memory.injection_enabled if self._app_config else True
|
||||||
|
memory_context = _get_memory_context(self._agent_name, app_config=self._app_config) if injection_enabled else ""
|
||||||
|
current_date = datetime.now().strftime("%Y-%m-%d, %A")
|
||||||
|
|
||||||
|
lines: list[str] = ["<system-reminder>"]
|
||||||
|
if memory_context:
|
||||||
|
lines.append(memory_context.strip())
|
||||||
|
lines.append("") # blank line separating memory from date
|
||||||
|
lines.append(f"<current_date>{current_date}</current_date>")
|
||||||
|
lines.append("</system-reminder>")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _build_date_update_reminder(self) -> str:
|
||||||
|
current_date = datetime.now().strftime("%Y-%m-%d, %A")
|
||||||
|
return "\n".join(
|
||||||
|
[
|
||||||
|
"<system-reminder>",
|
||||||
|
f"<current_date>{current_date}</current_date>",
|
||||||
|
"</system-reminder>",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_reminder_and_user_messages(original: HumanMessage, reminder_content: str) -> tuple[HumanMessage, HumanMessage]:
|
||||||
|
"""Return (reminder_msg, user_msg) using the ID-swap technique.
|
||||||
|
|
||||||
|
reminder_msg takes the original message's ID so that add_messages replaces it
|
||||||
|
in-place (preserving position). user_msg carries the original content with a
|
||||||
|
derived ``{id}__user`` ID and is appended immediately after by add_messages.
|
||||||
|
|
||||||
|
If the original message has no ID a stable UUID is generated so the derived
|
||||||
|
``{id}__user`` ID never collapses to the ambiguous ``None__user`` string.
|
||||||
|
"""
|
||||||
|
stable_id = original.id or str(uuid.uuid4())
|
||||||
|
reminder_msg = HumanMessage(
|
||||||
|
content=reminder_content,
|
||||||
|
id=stable_id,
|
||||||
|
additional_kwargs={"hide_from_ui": True, _DYNAMIC_CONTEXT_REMINDER_KEY: True},
|
||||||
|
)
|
||||||
|
user_msg = HumanMessage(
|
||||||
|
content=original.content,
|
||||||
|
id=f"{stable_id}__user",
|
||||||
|
name=original.name,
|
||||||
|
additional_kwargs=original.additional_kwargs,
|
||||||
|
)
|
||||||
|
return reminder_msg, user_msg
|
||||||
|
|
||||||
|
def _inject(self, state) -> dict | None:
|
||||||
|
messages = list(state.get("messages", []))
|
||||||
|
if not messages:
|
||||||
|
return None
|
||||||
|
|
||||||
|
current_date = datetime.now().strftime("%Y-%m-%d, %A")
|
||||||
|
last_date = _last_injected_date(messages)
|
||||||
|
logger.debug(
|
||||||
|
"DynamicContextMiddleware._inject: msg_count=%d last_date=%r current_date=%r",
|
||||||
|
len(messages),
|
||||||
|
last_date,
|
||||||
|
current_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
if last_date is None:
|
||||||
|
# ── First turn: inject full reminder as a separate HumanMessage ─────
|
||||||
|
first_idx = next((i for i, m in enumerate(messages) if _is_user_injection_target(m)), None)
|
||||||
|
if first_idx is None:
|
||||||
|
return None
|
||||||
|
full_reminder = self._build_full_reminder()
|
||||||
|
logger.info(
|
||||||
|
"DynamicContextMiddleware: injecting full reminder (len=%d, has_memory=%s) into first HumanMessage id=%r",
|
||||||
|
len(full_reminder),
|
||||||
|
"<memory>" in full_reminder,
|
||||||
|
messages[first_idx].id,
|
||||||
|
)
|
||||||
|
reminder_msg, user_msg = self._make_reminder_and_user_messages(messages[first_idx], full_reminder)
|
||||||
|
return {"messages": [reminder_msg, user_msg]}
|
||||||
|
|
||||||
|
if last_date == current_date:
|
||||||
|
# ── Same day: nothing to do ──────────────────────────────────────────
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── Midnight crossed: inject date-update reminder as a separate HumanMessage ──
|
||||||
|
last_human_idx = next((i for i in reversed(range(len(messages))) if _is_user_injection_target(messages[i])), None)
|
||||||
|
if last_human_idx is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
reminder_msg, user_msg = self._make_reminder_and_user_messages(messages[last_human_idx], self._build_date_update_reminder())
|
||||||
|
logger.info("DynamicContextMiddleware: midnight crossing detected — injected date update before current turn")
|
||||||
|
return {"messages": [reminder_msg, user_msg]}
|
||||||
|
|
||||||
|
@override
|
||||||
|
def before_agent(self, state, runtime: Runtime) -> dict | None:
|
||||||
|
return self._inject(state)
|
||||||
|
|
||||||
|
@override
|
||||||
|
async def abefore_agent(self, state, runtime: Runtime) -> dict | None:
|
||||||
|
return self._inject(state)
|
||||||
@@ -12,19 +12,23 @@ Detection strategy:
|
|||||||
response so the agent is forced to produce a final text answer.
|
response so the agent is forced to produce a final text answer.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from collections import OrderedDict, defaultdict
|
from collections import OrderedDict, defaultdict
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from typing import override
|
from typing import TYPE_CHECKING, override
|
||||||
|
|
||||||
from langchain.agents import AgentState
|
from langchain.agents import AgentState
|
||||||
from langchain.agents.middleware import AgentMiddleware
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
from langchain_core.messages import HumanMessage
|
|
||||||
from langgraph.runtime import Runtime
|
from langgraph.runtime import Runtime
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from deerflow.config.loop_detection_config import LoopDetectionConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Defaults — can be overridden via constructor
|
# Defaults — can be overridden via constructor
|
||||||
@@ -140,6 +144,9 @@ _TOOL_FREQ_HARD_STOP_MSG = "[FORCED STOP] Tool {tool_name} called {count} times
|
|||||||
class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
||||||
"""Detects and breaks repetitive tool call loops.
|
"""Detects and breaks repetitive tool call loops.
|
||||||
|
|
||||||
|
Threshold parameters are validated upstream by :class:`LoopDetectionConfig`;
|
||||||
|
construct via :meth:`from_config` to ensure values pass Pydantic validation.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
warn_threshold: Number of identical tool call sets before injecting
|
warn_threshold: Number of identical tool call sets before injecting
|
||||||
a warning message. Default: 3.
|
a warning message. Default: 3.
|
||||||
@@ -155,6 +162,14 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
|||||||
Default: 30.
|
Default: 30.
|
||||||
tool_freq_hard_limit: Number of calls to the same tool type before
|
tool_freq_hard_limit: Number of calls to the same tool type before
|
||||||
forcing a stop. Default: 50.
|
forcing a stop. Default: 50.
|
||||||
|
tool_freq_overrides: Per-tool overrides for frequency thresholds,
|
||||||
|
keyed by tool name. Each value is a ``(warn, hard_limit)`` tuple
|
||||||
|
that replaces ``tool_freq_warn`` / ``tool_freq_hard_limit`` for
|
||||||
|
that specific tool. Tools not listed here fall back to the global
|
||||||
|
thresholds. Useful for raising limits on intentionally
|
||||||
|
high-frequency tools (e.g. ``bash`` in batch pipelines) without
|
||||||
|
weakening protection on all other tools. Default: ``None``
|
||||||
|
(no overrides).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -165,6 +180,7 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
|||||||
max_tracked_threads: int = _DEFAULT_MAX_TRACKED_THREADS,
|
max_tracked_threads: int = _DEFAULT_MAX_TRACKED_THREADS,
|
||||||
tool_freq_warn: int = _DEFAULT_TOOL_FREQ_WARN,
|
tool_freq_warn: int = _DEFAULT_TOOL_FREQ_WARN,
|
||||||
tool_freq_hard_limit: int = _DEFAULT_TOOL_FREQ_HARD_LIMIT,
|
tool_freq_hard_limit: int = _DEFAULT_TOOL_FREQ_HARD_LIMIT,
|
||||||
|
tool_freq_overrides: dict[str, tuple[int, int]] | None = None,
|
||||||
):
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.warn_threshold = warn_threshold
|
self.warn_threshold = warn_threshold
|
||||||
@@ -173,14 +189,26 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
|||||||
self.max_tracked_threads = max_tracked_threads
|
self.max_tracked_threads = max_tracked_threads
|
||||||
self.tool_freq_warn = tool_freq_warn
|
self.tool_freq_warn = tool_freq_warn
|
||||||
self.tool_freq_hard_limit = tool_freq_hard_limit
|
self.tool_freq_hard_limit = tool_freq_hard_limit
|
||||||
|
self._tool_freq_overrides: dict[str, tuple[int, int]] = tool_freq_overrides or {}
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
# Per-thread tracking using OrderedDict for LRU eviction
|
|
||||||
self._history: OrderedDict[str, list[str]] = OrderedDict()
|
self._history: OrderedDict[str, list[str]] = OrderedDict()
|
||||||
self._warned: dict[str, set[str]] = defaultdict(set)
|
self._warned: dict[str, set[str]] = defaultdict(set)
|
||||||
# Per-thread, per-tool-type cumulative call counts
|
|
||||||
self._tool_freq: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
self._tool_freq: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
||||||
self._tool_freq_warned: dict[str, set[str]] = defaultdict(set)
|
self._tool_freq_warned: dict[str, set[str]] = defaultdict(set)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, config: LoopDetectionConfig) -> LoopDetectionMiddleware:
|
||||||
|
"""Construct from a Pydantic-validated config, trusting its validation."""
|
||||||
|
return cls(
|
||||||
|
warn_threshold=config.warn_threshold,
|
||||||
|
hard_limit=config.hard_limit,
|
||||||
|
window_size=config.window_size,
|
||||||
|
max_tracked_threads=config.max_tracked_threads,
|
||||||
|
tool_freq_warn=config.tool_freq_warn,
|
||||||
|
tool_freq_hard_limit=config.tool_freq_hard_limit,
|
||||||
|
tool_freq_overrides={name: (o.warn, o.hard_limit) for name, o in config.tool_freq_overrides.items()},
|
||||||
|
)
|
||||||
|
|
||||||
def _get_thread_id(self, runtime: Runtime) -> str:
|
def _get_thread_id(self, runtime: Runtime) -> str:
|
||||||
"""Extract thread_id from runtime context for per-thread tracking."""
|
"""Extract thread_id from runtime context for per-thread tracking."""
|
||||||
thread_id = runtime.context.get("thread_id") if runtime.context else None
|
thread_id = runtime.context.get("thread_id") if runtime.context else None
|
||||||
@@ -280,7 +308,12 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
|||||||
freq[name] += 1
|
freq[name] += 1
|
||||||
tc_count = freq[name]
|
tc_count = freq[name]
|
||||||
|
|
||||||
if tc_count >= self.tool_freq_hard_limit:
|
if name in self._tool_freq_overrides:
|
||||||
|
eff_warn, eff_hard = self._tool_freq_overrides[name]
|
||||||
|
else:
|
||||||
|
eff_warn, eff_hard = self.tool_freq_warn, self.tool_freq_hard_limit
|
||||||
|
|
||||||
|
if tc_count >= eff_hard:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Tool frequency hard limit reached — forcing stop",
|
"Tool frequency hard limit reached — forcing stop",
|
||||||
extra={
|
extra={
|
||||||
@@ -291,7 +324,7 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
|||||||
)
|
)
|
||||||
return _TOOL_FREQ_HARD_STOP_MSG.format(tool_name=name, count=tc_count), True
|
return _TOOL_FREQ_HARD_STOP_MSG.format(tool_name=name, count=tc_count), True
|
||||||
|
|
||||||
if tc_count >= self.tool_freq_warn:
|
if tc_count >= eff_warn:
|
||||||
warned = self._tool_freq_warned[thread_id]
|
warned = self._tool_freq_warned[thread_id]
|
||||||
if name not in warned:
|
if name not in warned:
|
||||||
warned.add(name)
|
warned.add(name)
|
||||||
@@ -356,13 +389,30 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
|||||||
return {"messages": [stripped_msg]}
|
return {"messages": [stripped_msg]}
|
||||||
|
|
||||||
if warning:
|
if warning:
|
||||||
# Inject as HumanMessage instead of SystemMessage to avoid
|
# WORKAROUND for v2.0-m1 — see #2724.
|
||||||
# Anthropic's "multiple non-consecutive system messages" error.
|
#
|
||||||
# Anthropic models require system messages only at the start of
|
# Append the warning to the AIMessage content instead of
|
||||||
# the conversation; injecting one mid-conversation crashes
|
# injecting a separate HumanMessage. Inserting any non-tool
|
||||||
# langchain_anthropic's _format_messages(). HumanMessage works
|
# message between an AIMessage(tool_calls=...) and its
|
||||||
# with all providers. See #1299.
|
# ToolMessage responses breaks OpenAI/Moonshot strict pairing
|
||||||
return {"messages": [HumanMessage(content=warning, name="loop_warning")]}
|
# 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]}
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from langchain.agents import AgentState
|
|||||||
from langchain.agents.middleware import AgentMiddleware
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
from langgraph.runtime import Runtime
|
from langgraph.runtime import Runtime
|
||||||
|
|
||||||
|
from deerflow.agents.middlewares.tool_call_metadata import clone_ai_message_with_tool_calls
|
||||||
from deerflow.subagents.executor import MAX_CONCURRENT_SUBAGENTS
|
from deerflow.subagents.executor import MAX_CONCURRENT_SUBAGENTS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -63,7 +64,7 @@ class SubagentLimitMiddleware(AgentMiddleware[AgentState]):
|
|||||||
logger.warning(f"Truncated {dropped_count} excess task tool call(s) from model response (limit: {self.max_concurrent})")
|
logger.warning(f"Truncated {dropped_count} excess task tool call(s) from model response (limit: {self.max_concurrent})")
|
||||||
|
|
||||||
# Replace the AIMessage with truncated tool_calls (same id triggers replacement)
|
# Replace the AIMessage with truncated tool_calls (same id triggers replacement)
|
||||||
updated_msg = last_msg.model_copy(update={"tool_calls": truncated_tool_calls})
|
updated_msg = clone_ai_message_with_tool_calls(last_msg, truncated_tool_calls)
|
||||||
return {"messages": [updated_msg]}
|
return {"messages": [updated_msg]}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ from langgraph.config import get_config
|
|||||||
from langgraph.graph.message import REMOVE_ALL_MESSAGES
|
from langgraph.graph.message import REMOVE_ALL_MESSAGES
|
||||||
from langgraph.runtime import Runtime
|
from langgraph.runtime import Runtime
|
||||||
|
|
||||||
|
from deerflow.agents.middlewares.dynamic_context_middleware import is_dynamic_context_reminder
|
||||||
|
from deerflow.agents.middlewares.tool_call_metadata import clone_ai_message_with_tool_calls
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -78,10 +81,7 @@ def _clone_ai_message(
|
|||||||
content: Any | None = None,
|
content: Any | None = None,
|
||||||
) -> AIMessage:
|
) -> AIMessage:
|
||||||
"""Clone an AIMessage while replacing its tool_calls list and optional content."""
|
"""Clone an AIMessage while replacing its tool_calls list and optional content."""
|
||||||
update: dict[str, Any] = {"tool_calls": tool_calls}
|
return clone_ai_message_with_tool_calls(message, tool_calls, content=content)
|
||||||
if content is not None:
|
|
||||||
update["content"] = content
|
|
||||||
return message.model_copy(update=update)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -136,6 +136,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
messages_to_summarize, preserved_messages = self._partition_with_skill_rescue(messages, cutoff_index)
|
messages_to_summarize, preserved_messages = self._partition_with_skill_rescue(messages, cutoff_index)
|
||||||
|
messages_to_summarize, preserved_messages = self._preserve_dynamic_context_reminders(messages_to_summarize, preserved_messages)
|
||||||
self._fire_hooks(messages_to_summarize, preserved_messages, runtime)
|
self._fire_hooks(messages_to_summarize, preserved_messages, runtime)
|
||||||
summary = self._create_summary(messages_to_summarize)
|
summary = self._create_summary(messages_to_summarize)
|
||||||
new_messages = self._build_new_messages(summary)
|
new_messages = self._build_new_messages(summary)
|
||||||
@@ -161,6 +162,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
messages_to_summarize, preserved_messages = self._partition_with_skill_rescue(messages, cutoff_index)
|
messages_to_summarize, preserved_messages = self._partition_with_skill_rescue(messages, cutoff_index)
|
||||||
|
messages_to_summarize, preserved_messages = self._preserve_dynamic_context_reminders(messages_to_summarize, preserved_messages)
|
||||||
self._fire_hooks(messages_to_summarize, preserved_messages, runtime)
|
self._fire_hooks(messages_to_summarize, preserved_messages, runtime)
|
||||||
summary = await self._acreate_summary(messages_to_summarize)
|
summary = await self._acreate_summary(messages_to_summarize)
|
||||||
new_messages = self._build_new_messages(summary)
|
new_messages = self._build_new_messages(summary)
|
||||||
@@ -180,6 +182,24 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
|||||||
"""
|
"""
|
||||||
return [HumanMessage(content=f"Here is a summary of the conversation to date:\n\n{summary}", name="summary")]
|
return [HumanMessage(content=f"Here is a summary of the conversation to date:\n\n{summary}", name="summary")]
|
||||||
|
|
||||||
|
def _preserve_dynamic_context_reminders(
|
||||||
|
self,
|
||||||
|
messages_to_summarize: list[AnyMessage],
|
||||||
|
preserved_messages: list[AnyMessage],
|
||||||
|
) -> tuple[list[AnyMessage], list[AnyMessage]]:
|
||||||
|
"""Keep hidden dynamic-context reminders out of summary compression.
|
||||||
|
|
||||||
|
These reminders carry the current date and optional memory. If summarization
|
||||||
|
removes them, DynamicContextMiddleware can mistake the summary HumanMessage
|
||||||
|
for the first user message and inject the reminder in the wrong place.
|
||||||
|
"""
|
||||||
|
reminders = [msg for msg in messages_to_summarize if is_dynamic_context_reminder(msg)]
|
||||||
|
if not reminders:
|
||||||
|
return messages_to_summarize, preserved_messages
|
||||||
|
|
||||||
|
remaining = [msg for msg in messages_to_summarize if not is_dynamic_context_reminder(msg)]
|
||||||
|
return remaining, reminders + preserved_messages
|
||||||
|
|
||||||
def _partition_with_skill_rescue(
|
def _partition_with_skill_rescue(
|
||||||
self,
|
self,
|
||||||
messages: list[AnyMessage],
|
messages: list[AnyMessage],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from langchain.agents.middleware import AgentMiddleware
|
|||||||
from langgraph.config import get_config
|
from langgraph.config import get_config
|
||||||
from langgraph.runtime import Runtime
|
from langgraph.runtime import Runtime
|
||||||
|
|
||||||
|
from deerflow.agents.middlewares.dynamic_context_middleware import is_dynamic_context_reminder
|
||||||
from deerflow.config.title_config import get_title_config
|
from deerflow.config.title_config import get_title_config
|
||||||
from deerflow.models import create_chat_model
|
from deerflow.models import create_chat_model
|
||||||
|
|
||||||
@@ -61,6 +62,10 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
|||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_user_message_for_title(message: object) -> bool:
|
||||||
|
return getattr(message, "type", None) == "human" and not is_dynamic_context_reminder(message)
|
||||||
|
|
||||||
def _should_generate_title(self, state: TitleMiddlewareState) -> bool:
|
def _should_generate_title(self, state: TitleMiddlewareState) -> bool:
|
||||||
"""Check if we should generate a title for this thread."""
|
"""Check if we should generate a title for this thread."""
|
||||||
config = self._get_title_config()
|
config = self._get_title_config()
|
||||||
@@ -77,7 +82,7 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Count user and assistant messages
|
# Count user and assistant messages
|
||||||
user_messages = [m for m in messages if m.type == "human"]
|
user_messages = [m for m in messages if self._is_user_message_for_title(m)]
|
||||||
assistant_messages = [m for m in messages if m.type == "ai"]
|
assistant_messages = [m for m in messages if m.type == "ai"]
|
||||||
|
|
||||||
# Generate title after first complete exchange
|
# Generate title after first complete exchange
|
||||||
@@ -91,7 +96,7 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
|||||||
config = self._get_title_config()
|
config = self._get_title_config()
|
||||||
messages = state.get("messages", [])
|
messages = state.get("messages", [])
|
||||||
|
|
||||||
user_msg_content = next((m.content for m in messages if m.type == "human"), "")
|
user_msg_content = next((m.content for m in messages if self._is_user_message_for_title(m)), "")
|
||||||
assistant_msg_content = next((m.content for m in messages if m.type == "ai"), "")
|
assistant_msg_content = next((m.content for m in messages if m.type == "ai"), "")
|
||||||
|
|
||||||
user_msg = self._normalize_content(user_msg_content)
|
user_msg = self._normalize_content(user_msg_content)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from typing import Any, override
|
|||||||
from langchain.agents import AgentState
|
from langchain.agents import AgentState
|
||||||
from langchain.agents.middleware import AgentMiddleware
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
from langchain.agents.middleware.todo import Todo
|
from langchain.agents.middleware.todo import Todo
|
||||||
from langchain_core.messages import AIMessage
|
from langchain_core.messages import AIMessage, ToolMessage
|
||||||
from langgraph.runtime import Runtime
|
from langgraph.runtime import Runtime
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -217,6 +217,17 @@ def _infer_step_kind(message: AIMessage, actions: list[dict[str, Any]]) -> str:
|
|||||||
return "thinking"
|
return "thinking"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_tool_call(message: AIMessage, tool_call_id: str) -> bool:
|
||||||
|
"""Return True if the AIMessage contains a tool_call with the given id."""
|
||||||
|
for tc in message.tool_calls or []:
|
||||||
|
if isinstance(tc, dict):
|
||||||
|
if tc.get("id") == tool_call_id:
|
||||||
|
return True
|
||||||
|
elif hasattr(tc, "id") and tc.id == tool_call_id:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _build_attribution(message: AIMessage, todos: list[Todo]) -> dict[str, Any]:
|
def _build_attribution(message: AIMessage, todos: list[Todo]) -> dict[str, Any]:
|
||||||
tool_calls = getattr(message, "tool_calls", None) or []
|
tool_calls = getattr(message, "tool_calls", None) or []
|
||||||
actions: list[dict[str, Any]] = []
|
actions: list[dict[str, Any]] = []
|
||||||
@@ -261,17 +272,69 @@ class TokenUsageMiddleware(AgentMiddleware):
|
|||||||
if not messages:
|
if not messages:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Annotate subagent token usage onto the AIMessage that dispatched it.
|
||||||
|
# When a task tool completes, its usage is cached by tool_call_id. Detect
|
||||||
|
# the ToolMessage → search backward for the corresponding AIMessage → merge.
|
||||||
|
# Walk backward through consecutive ToolMessages before the new AIMessage
|
||||||
|
# so that multiple concurrent task tool calls all get their subagent tokens
|
||||||
|
# written back to the same dispatch message (merging into one update).
|
||||||
|
state_updates: dict[int, AIMessage] = {}
|
||||||
|
if len(messages) >= 2:
|
||||||
|
from deerflow.tools.builtins.task_tool import pop_cached_subagent_usage
|
||||||
|
|
||||||
|
idx = len(messages) - 2
|
||||||
|
while idx >= 0:
|
||||||
|
tool_msg = messages[idx]
|
||||||
|
if not isinstance(tool_msg, ToolMessage) or not tool_msg.tool_call_id:
|
||||||
|
break
|
||||||
|
|
||||||
|
subagent_usage = pop_cached_subagent_usage(tool_msg.tool_call_id)
|
||||||
|
if subagent_usage:
|
||||||
|
# Search backward from the ToolMessage to find the AIMessage
|
||||||
|
# that dispatched it. A single model response can dispatch
|
||||||
|
# multiple task tool calls, so we can't assume a fixed offset.
|
||||||
|
dispatch_idx = idx - 1
|
||||||
|
while dispatch_idx >= 0:
|
||||||
|
candidate = messages[dispatch_idx]
|
||||||
|
if isinstance(candidate, AIMessage) and _has_tool_call(candidate, tool_msg.tool_call_id):
|
||||||
|
# Accumulate into an existing update for the same
|
||||||
|
# AIMessage (multiple task calls in one response),
|
||||||
|
# or merge fresh from the original message.
|
||||||
|
existing_update = state_updates.get(dispatch_idx)
|
||||||
|
prev = existing_update.usage_metadata if existing_update else (getattr(candidate, "usage_metadata", None) or {})
|
||||||
|
merged = {
|
||||||
|
**prev,
|
||||||
|
"input_tokens": prev.get("input_tokens", 0) + subagent_usage["input_tokens"],
|
||||||
|
"output_tokens": prev.get("output_tokens", 0) + subagent_usage["output_tokens"],
|
||||||
|
"total_tokens": prev.get("total_tokens", 0) + subagent_usage["total_tokens"],
|
||||||
|
}
|
||||||
|
state_updates[dispatch_idx] = candidate.model_copy(update={"usage_metadata": merged})
|
||||||
|
break
|
||||||
|
dispatch_idx -= 1
|
||||||
|
idx -= 1
|
||||||
|
|
||||||
last = messages[-1]
|
last = messages[-1]
|
||||||
if not isinstance(last, AIMessage):
|
if not isinstance(last, AIMessage):
|
||||||
|
if state_updates:
|
||||||
|
return {"messages": [state_updates[idx] for idx in sorted(state_updates)]}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
usage = getattr(last, "usage_metadata", None)
|
usage = getattr(last, "usage_metadata", None)
|
||||||
if usage:
|
if usage:
|
||||||
|
input_token_details = usage.get("input_token_details") or {}
|
||||||
|
output_token_details = usage.get("output_token_details") or {}
|
||||||
|
detail_parts = []
|
||||||
|
if input_token_details:
|
||||||
|
detail_parts.append(f"input_token_details={input_token_details}")
|
||||||
|
if output_token_details:
|
||||||
|
detail_parts.append(f"output_token_details={output_token_details}")
|
||||||
|
detail_suffix = f" {' '.join(detail_parts)}" if detail_parts else ""
|
||||||
logger.info(
|
logger.info(
|
||||||
"LLM token usage: input=%s output=%s total=%s",
|
"LLM token usage: input=%s output=%s total=%s%s",
|
||||||
usage.get("input_tokens", "?"),
|
usage.get("input_tokens", "?"),
|
||||||
usage.get("output_tokens", "?"),
|
usage.get("output_tokens", "?"),
|
||||||
usage.get("total_tokens", "?"),
|
usage.get("total_tokens", "?"),
|
||||||
|
detail_suffix,
|
||||||
)
|
)
|
||||||
|
|
||||||
todos = state.get("todos") or []
|
todos = state.get("todos") or []
|
||||||
@@ -279,11 +342,12 @@ class TokenUsageMiddleware(AgentMiddleware):
|
|||||||
additional_kwargs = dict(getattr(last, "additional_kwargs", {}) or {})
|
additional_kwargs = dict(getattr(last, "additional_kwargs", {}) or {})
|
||||||
|
|
||||||
if additional_kwargs.get(TOKEN_USAGE_ATTRIBUTION_KEY) == attribution:
|
if additional_kwargs.get(TOKEN_USAGE_ATTRIBUTION_KEY) == attribution:
|
||||||
return None
|
return {"messages": [state_updates[idx] for idx in sorted(state_updates)]} if state_updates else None
|
||||||
|
|
||||||
additional_kwargs[TOKEN_USAGE_ATTRIBUTION_KEY] = attribution
|
additional_kwargs[TOKEN_USAGE_ATTRIBUTION_KEY] = attribution
|
||||||
updated_msg = last.model_copy(update={"additional_kwargs": additional_kwargs})
|
updated_msg = last.model_copy(update={"additional_kwargs": additional_kwargs})
|
||||||
return {"messages": [updated_msg]}
|
state_updates[len(messages) - 1] = updated_msg
|
||||||
|
return {"messages": [state_updates[idx] for idx in sorted(state_updates)]}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Helpers for keeping AIMessage tool-call metadata consistent."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langchain_core.messages import AIMessage
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_tool_call_id(raw_tool_call: Any) -> str | None:
|
||||||
|
if not isinstance(raw_tool_call, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
raw_id = raw_tool_call.get("id")
|
||||||
|
return raw_id if isinstance(raw_id, str) and raw_id else None
|
||||||
|
|
||||||
|
|
||||||
|
def clone_ai_message_with_tool_calls(
|
||||||
|
message: AIMessage,
|
||||||
|
tool_calls: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
content: Any | None = None,
|
||||||
|
) -> AIMessage:
|
||||||
|
"""Clone an AIMessage while keeping raw provider tool-call metadata in sync."""
|
||||||
|
kept_ids = {tc["id"] for tc in tool_calls if isinstance(tc.get("id"), str) and tc["id"]}
|
||||||
|
|
||||||
|
update: dict[str, Any] = {"tool_calls": tool_calls}
|
||||||
|
if content is not None:
|
||||||
|
update["content"] = content
|
||||||
|
|
||||||
|
additional_kwargs = dict(getattr(message, "additional_kwargs", {}) or {})
|
||||||
|
raw_tool_calls = additional_kwargs.get("tool_calls")
|
||||||
|
if isinstance(raw_tool_calls, list):
|
||||||
|
synced_raw_tool_calls = [raw_tc for raw_tc in raw_tool_calls if _raw_tool_call_id(raw_tc) in kept_ids]
|
||||||
|
if synced_raw_tool_calls:
|
||||||
|
additional_kwargs["tool_calls"] = synced_raw_tool_calls
|
||||||
|
else:
|
||||||
|
additional_kwargs.pop("tool_calls", None)
|
||||||
|
|
||||||
|
if not tool_calls:
|
||||||
|
additional_kwargs.pop("function_call", None)
|
||||||
|
|
||||||
|
update["additional_kwargs"] = additional_kwargs
|
||||||
|
|
||||||
|
response_metadata = dict(getattr(message, "response_metadata", {}) or {})
|
||||||
|
if not tool_calls and response_metadata.get("finish_reason") == "tool_calls":
|
||||||
|
response_metadata["finish_reason"] = "stop"
|
||||||
|
update["response_metadata"] = response_metadata
|
||||||
|
|
||||||
|
return message.model_copy(update=update)
|
||||||
@@ -84,8 +84,52 @@ class RemoteSandboxBackend(SandboxBackend):
|
|||||||
"""
|
"""
|
||||||
return self._provisioner_discover(sandbox_id)
|
return self._provisioner_discover(sandbox_id)
|
||||||
|
|
||||||
|
def list_running(self) -> list[SandboxInfo]:
|
||||||
|
"""Return all sandboxes currently managed by the provisioner.
|
||||||
|
|
||||||
|
Calls ``GET /api/sandboxes`` so that ``AioSandboxProvider._reconcile_orphans()``
|
||||||
|
can adopt pods that were created by a previous process and were never
|
||||||
|
explicitly destroyed.
|
||||||
|
Without this, a process restart silently orphans all existing k8s Pods —
|
||||||
|
they stay running forever because the idle checker only
|
||||||
|
tracks in-process state.
|
||||||
|
"""
|
||||||
|
return self._provisioner_list()
|
||||||
|
|
||||||
# ── Provisioner API calls ─────────────────────────────────────────────
|
# ── Provisioner API calls ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _provisioner_list(self) -> list[SandboxInfo]:
|
||||||
|
"""GET /api/sandboxes → list all running sandboxes."""
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{self._provisioner_url}/api/sandboxes", timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
logger.warning("Provisioner list_running returned non-dict payload: %r", type(data))
|
||||||
|
return []
|
||||||
|
|
||||||
|
sandboxes = data.get("sandboxes", [])
|
||||||
|
if not isinstance(sandboxes, list):
|
||||||
|
logger.warning("Provisioner list_running returned non-list sandboxes: %r", type(sandboxes))
|
||||||
|
return []
|
||||||
|
|
||||||
|
infos: list[SandboxInfo] = []
|
||||||
|
for sandbox in sandboxes:
|
||||||
|
if not isinstance(sandbox, dict):
|
||||||
|
logger.warning("Provisioner list_running entry is not a dict: %r", type(sandbox))
|
||||||
|
continue
|
||||||
|
|
||||||
|
sandbox_id = sandbox.get("sandbox_id")
|
||||||
|
sandbox_url = sandbox.get("sandbox_url")
|
||||||
|
if isinstance(sandbox_id, str) and sandbox_id and isinstance(sandbox_url, str) and sandbox_url:
|
||||||
|
infos.append(SandboxInfo(sandbox_id=sandbox_id, sandbox_url=sandbox_url))
|
||||||
|
|
||||||
|
logger.info("Provisioner list_running: %d sandbox(es) found", len(infos))
|
||||||
|
return infos
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
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, sandbox_id: str, extra_mounts: list[tuple[str, str, bool]] | None = None) -> SandboxInfo:
|
||||||
"""POST /api/sandboxes → create Pod + Service."""
|
"""POST /api/sandboxes → create Pod + Service."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from .app_config import get_app_config
|
from .app_config import get_app_config
|
||||||
from .extensions_config import ExtensionsConfig, get_extensions_config
|
from .extensions_config import ExtensionsConfig, get_extensions_config
|
||||||
|
from .loop_detection_config import LoopDetectionConfig
|
||||||
from .memory_config import MemoryConfig, get_memory_config
|
from .memory_config import MemoryConfig, get_memory_config
|
||||||
from .paths import Paths, get_paths
|
from .paths import Paths, get_paths
|
||||||
from .skill_evolution_config import SkillEvolutionConfig
|
from .skill_evolution_config import SkillEvolutionConfig
|
||||||
@@ -20,6 +21,7 @@ __all__ = [
|
|||||||
"SkillsConfig",
|
"SkillsConfig",
|
||||||
"ExtensionsConfig",
|
"ExtensionsConfig",
|
||||||
"get_extensions_config",
|
"get_extensions_config",
|
||||||
|
"LoopDetectionConfig",
|
||||||
"MemoryConfig",
|
"MemoryConfig",
|
||||||
"get_memory_config",
|
"get_memory_config",
|
||||||
"get_tracing_config",
|
"get_tracing_config",
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
"""Configuration and loaders for custom agents."""
|
"""Configuration and loaders for custom agents.
|
||||||
|
|
||||||
|
Custom agents are stored per-user under ``{base_dir}/users/{user_id}/agents/{name}/``.
|
||||||
|
A legacy shared layout at ``{base_dir}/agents/{name}/`` is still readable so that
|
||||||
|
installations that pre-date user isolation continue to work until they run the
|
||||||
|
``scripts/migrate_user_isolation.py`` migration. New writes always target the
|
||||||
|
per-user layout.
|
||||||
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -40,14 +49,47 @@ class AgentConfig(BaseModel):
|
|||||||
skills: list[str] | None = None
|
skills: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
def load_agent_config(name: str | None) -> AgentConfig | None:
|
def resolve_agent_dir(name: str, *, user_id: str | None = None) -> Path:
|
||||||
|
"""Return the on-disk directory for an agent, preferring the per-user layout.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
1. ``{base_dir}/users/{user_id}/agents/{name}/`` (per-user, current layout).
|
||||||
|
2. ``{base_dir}/agents/{name}/`` (legacy shared layout — read-only fallback).
|
||||||
|
|
||||||
|
If neither exists, the per-user path is returned so callers that intend to
|
||||||
|
create the agent write into the new layout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Validated agent name.
|
||||||
|
user_id: Owner of the agent. Defaults to the effective user from the
|
||||||
|
request context (or ``"default"`` in no-auth mode).
|
||||||
|
"""
|
||||||
|
paths = get_paths()
|
||||||
|
effective_user = user_id or get_effective_user_id()
|
||||||
|
user_path = paths.user_agent_dir(effective_user, name)
|
||||||
|
if user_path.exists():
|
||||||
|
return user_path
|
||||||
|
|
||||||
|
legacy_path = paths.agent_dir(name)
|
||||||
|
if legacy_path.exists():
|
||||||
|
return legacy_path
|
||||||
|
|
||||||
|
return user_path
|
||||||
|
|
||||||
|
|
||||||
|
def load_agent_config(name: str | None, *, user_id: str | None = None) -> AgentConfig | None:
|
||||||
"""Load the custom or default agent's config from its directory.
|
"""Load the custom or default agent's config from its directory.
|
||||||
|
|
||||||
|
Reads from the per-user layout first; falls back to the legacy shared layout
|
||||||
|
for installations that have not yet been migrated.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: The agent name.
|
name: The agent name.
|
||||||
|
user_id: Owner of the agent. Defaults to the effective user from the
|
||||||
|
current request context.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
AgentConfig instance.
|
AgentConfig instance, or ``None`` if ``name`` is ``None``.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
FileNotFoundError: If the agent directory or config.yaml does not exist.
|
FileNotFoundError: If the agent directory or config.yaml does not exist.
|
||||||
@@ -58,7 +100,7 @@ def load_agent_config(name: str | None) -> AgentConfig | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
name = validate_agent_name(name)
|
name = validate_agent_name(name)
|
||||||
agent_dir = get_paths().agent_dir(name)
|
agent_dir = resolve_agent_dir(name, user_id=user_id)
|
||||||
config_file = agent_dir / "config.yaml"
|
config_file = agent_dir / "config.yaml"
|
||||||
|
|
||||||
if not agent_dir.exists():
|
if not agent_dir.exists():
|
||||||
@@ -84,7 +126,7 @@ def load_agent_config(name: str | None) -> AgentConfig | None:
|
|||||||
return AgentConfig(**data)
|
return AgentConfig(**data)
|
||||||
|
|
||||||
|
|
||||||
def load_agent_soul(agent_name: str | None) -> str | None:
|
def load_agent_soul(agent_name: str | None, *, user_id: str | None = None) -> str | None:
|
||||||
"""Read the SOUL.md file for a custom agent, if it exists.
|
"""Read the SOUL.md file for a custom agent, if it exists.
|
||||||
|
|
||||||
SOUL.md defines the agent's personality, values, and behavioral guardrails.
|
SOUL.md defines the agent's personality, values, and behavioral guardrails.
|
||||||
@@ -92,11 +134,16 @@ def load_agent_soul(agent_name: str | None) -> str | None:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
agent_name: The name of the agent or None for the default agent.
|
agent_name: The name of the agent or None for the default agent.
|
||||||
|
user_id: Owner of the agent. Defaults to the effective user from the
|
||||||
|
current request context.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The SOUL.md content as a string, or None if the file does not exist.
|
The SOUL.md content as a string, or None if the file does not exist.
|
||||||
"""
|
"""
|
||||||
agent_dir = get_paths().agent_dir(agent_name) if agent_name else get_paths().base_dir
|
if agent_name:
|
||||||
|
agent_dir = resolve_agent_dir(agent_name, user_id=user_id)
|
||||||
|
else:
|
||||||
|
agent_dir = get_paths().base_dir
|
||||||
soul_path = agent_dir / SOUL_FILENAME
|
soul_path = agent_dir / SOUL_FILENAME
|
||||||
if not soul_path.exists():
|
if not soul_path.exists():
|
||||||
return None
|
return None
|
||||||
@@ -104,32 +151,50 @@ def load_agent_soul(agent_name: str | None) -> str | None:
|
|||||||
return content or None
|
return content or None
|
||||||
|
|
||||||
|
|
||||||
def list_custom_agents() -> list[AgentConfig]:
|
def list_custom_agents(*, user_id: str | None = None) -> list[AgentConfig]:
|
||||||
"""Scan the agents directory and return all valid custom agents.
|
"""Scan the agents directory and return all valid custom agents.
|
||||||
|
|
||||||
|
Returns the union of agents in the per-user layout and the legacy shared
|
||||||
|
layout, so that pre-migration installations remain visible until they are
|
||||||
|
migrated. Per-user entries shadow legacy entries with the same name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner whose agents to list. Defaults to the effective user
|
||||||
|
from the current request context.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of AgentConfig for each valid agent directory found.
|
List of AgentConfig for each valid agent directory found.
|
||||||
"""
|
"""
|
||||||
agents_dir = get_paths().agents_dir
|
paths = get_paths()
|
||||||
|
effective_user = user_id or get_effective_user_id()
|
||||||
if not agents_dir.exists():
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
agents: list[AgentConfig] = []
|
agents: list[AgentConfig] = []
|
||||||
|
|
||||||
for entry in sorted(agents_dir.iterdir()):
|
user_root = paths.user_agents_dir(effective_user)
|
||||||
if not entry.is_dir():
|
legacy_root = paths.agents_dir
|
||||||
|
|
||||||
|
for root in (user_root, legacy_root):
|
||||||
|
if not root.exists():
|
||||||
continue
|
continue
|
||||||
|
for entry in sorted(root.iterdir()):
|
||||||
|
if not entry.is_dir():
|
||||||
|
continue
|
||||||
|
if entry.name in seen:
|
||||||
|
continue
|
||||||
|
config_file = entry / "config.yaml"
|
||||||
|
if not config_file.exists():
|
||||||
|
logger.debug(f"Skipping {entry.name}: no config.yaml")
|
||||||
|
continue
|
||||||
|
|
||||||
config_file = entry / "config.yaml"
|
try:
|
||||||
if not config_file.exists():
|
agent_cfg = load_agent_config(entry.name, user_id=effective_user)
|
||||||
logger.debug(f"Skipping {entry.name}: no config.yaml")
|
if agent_cfg is None:
|
||||||
continue
|
continue
|
||||||
|
agents.append(agent_cfg)
|
||||||
try:
|
seen.add(entry.name)
|
||||||
agent_cfg = load_agent_config(entry.name)
|
except Exception as e:
|
||||||
agents.append(agent_cfg)
|
logger.warning(f"Skipping agent '{entry.name}': {e}")
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Skipping agent '{entry.name}': {e}")
|
|
||||||
|
|
||||||
|
agents.sort(key=lambda a: a.name)
|
||||||
return agents
|
return agents
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from collections.abc import Mapping
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Self
|
from typing import Any, Self
|
||||||
@@ -14,6 +15,7 @@ from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpo
|
|||||||
from deerflow.config.database_config import DatabaseConfig
|
from deerflow.config.database_config import DatabaseConfig
|
||||||
from deerflow.config.extensions_config import ExtensionsConfig
|
from deerflow.config.extensions_config import ExtensionsConfig
|
||||||
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
|
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
|
||||||
|
from deerflow.config.loop_detection_config import LoopDetectionConfig
|
||||||
from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict
|
from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict
|
||||||
from deerflow.config.model_config import ModelConfig
|
from deerflow.config.model_config import ModelConfig
|
||||||
from deerflow.config.run_events_config import RunEventsConfig
|
from deerflow.config.run_events_config import RunEventsConfig
|
||||||
@@ -99,6 +101,7 @@ class AppConfig(BaseModel):
|
|||||||
subagents: SubagentsAppConfig = Field(default_factory=SubagentsAppConfig, description="Subagent runtime configuration")
|
subagents: SubagentsAppConfig = Field(default_factory=SubagentsAppConfig, description="Subagent runtime configuration")
|
||||||
guardrails: GuardrailsConfig = Field(default_factory=GuardrailsConfig, description="Guardrail middleware configuration")
|
guardrails: GuardrailsConfig = Field(default_factory=GuardrailsConfig, description="Guardrail middleware configuration")
|
||||||
circuit_breaker: CircuitBreakerConfig = Field(default_factory=CircuitBreakerConfig, description="LLM circuit breaker 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")
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
database: DatabaseConfig = Field(default_factory=DatabaseConfig, description="Unified database backend configuration")
|
database: DatabaseConfig = Field(default_factory=DatabaseConfig, description="Unified database backend configuration")
|
||||||
run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration")
|
run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration")
|
||||||
@@ -157,56 +160,54 @@ class AppConfig(BaseModel):
|
|||||||
config_data = cls.resolve_env_variables(config_data)
|
config_data = cls.resolve_env_variables(config_data)
|
||||||
cls._apply_database_defaults(config_data)
|
cls._apply_database_defaults(config_data)
|
||||||
|
|
||||||
# Load title config if present
|
|
||||||
if "title" in config_data:
|
|
||||||
load_title_config_from_dict(config_data["title"])
|
|
||||||
|
|
||||||
# Load summarization config if present
|
|
||||||
if "summarization" in config_data:
|
|
||||||
load_summarization_config_from_dict(config_data["summarization"])
|
|
||||||
|
|
||||||
# Load memory config if present
|
|
||||||
if "memory" in config_data:
|
|
||||||
load_memory_config_from_dict(config_data["memory"])
|
|
||||||
|
|
||||||
# Always refresh agents API config so removed config sections reset
|
|
||||||
# singleton-backed state to its default/disabled values on reload.
|
|
||||||
load_agents_api_config_from_dict(config_data.get("agents_api") or {})
|
|
||||||
|
|
||||||
# Load subagents config if present
|
|
||||||
if "subagents" in config_data:
|
|
||||||
load_subagents_config_from_dict(config_data["subagents"])
|
|
||||||
|
|
||||||
# Load tool_search config if present
|
|
||||||
if "tool_search" in config_data:
|
|
||||||
load_tool_search_config_from_dict(config_data["tool_search"])
|
|
||||||
|
|
||||||
# Load guardrails config if present
|
|
||||||
if "guardrails" in config_data:
|
|
||||||
load_guardrails_config_from_dict(config_data["guardrails"])
|
|
||||||
|
|
||||||
# Load circuit_breaker config if present
|
# Load circuit_breaker config if present
|
||||||
if "circuit_breaker" in config_data:
|
if "circuit_breaker" in config_data:
|
||||||
config_data["circuit_breaker"] = config_data["circuit_breaker"]
|
config_data["circuit_breaker"] = config_data["circuit_breaker"]
|
||||||
|
|
||||||
# Load checkpointer config if present
|
|
||||||
if "checkpointer" in config_data:
|
|
||||||
load_checkpointer_config_from_dict(config_data["checkpointer"])
|
|
||||||
|
|
||||||
# Load stream bridge config if present
|
|
||||||
if "stream_bridge" in config_data:
|
|
||||||
load_stream_bridge_config_from_dict(config_data["stream_bridge"])
|
|
||||||
|
|
||||||
# Always refresh ACP agent config so removed entries do not linger across reloads.
|
|
||||||
load_acp_config_from_dict(config_data.get("acp_agents", {}))
|
|
||||||
|
|
||||||
# Load extensions config separately (it's in a different file)
|
# Load extensions config separately (it's in a different file)
|
||||||
extensions_config = ExtensionsConfig.from_file()
|
extensions_config = ExtensionsConfig.from_file()
|
||||||
config_data["extensions"] = extensions_config.model_dump()
|
config_data["extensions"] = extensions_config.model_dump()
|
||||||
|
|
||||||
result = cls.model_validate(config_data)
|
result = cls.model_validate(config_data)
|
||||||
|
acp_agents = cls._validate_acp_agents(config_data.get("acp_agents", {}))
|
||||||
|
cls._apply_singleton_configs(result, acp_agents)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _validate_acp_agents(
|
||||||
|
cls,
|
||||||
|
config_data: Mapping[str, Mapping[str, object]] | None,
|
||||||
|
) -> dict[str, ACPAgentConfig]:
|
||||||
|
if config_data is None:
|
||||||
|
config_data = {}
|
||||||
|
return {name: ACPAgentConfig(**cfg) for name, cfg in config_data.items()}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _apply_singleton_configs(cls, config: Self, acp_agents: dict[str, ACPAgentConfig]) -> None:
|
||||||
|
from deerflow.config.checkpointer_config import get_checkpointer_config
|
||||||
|
|
||||||
|
previous_checkpointer_config = get_checkpointer_config()
|
||||||
|
|
||||||
|
load_title_config_from_dict(config.title.model_dump())
|
||||||
|
load_summarization_config_from_dict(config.summarization.model_dump())
|
||||||
|
load_memory_config_from_dict(config.memory.model_dump())
|
||||||
|
load_agents_api_config_from_dict(config.agents_api.model_dump())
|
||||||
|
load_subagents_config_from_dict(config.subagents.model_dump())
|
||||||
|
load_tool_search_config_from_dict(config.tool_search.model_dump())
|
||||||
|
load_guardrails_config_from_dict(config.guardrails.model_dump())
|
||||||
|
load_checkpointer_config_from_dict(config.checkpointer.model_dump() if config.checkpointer is not None else None)
|
||||||
|
load_stream_bridge_config_from_dict(config.stream_bridge.model_dump() if config.stream_bridge is not None else None)
|
||||||
|
load_acp_config_from_dict({name: agent.model_dump() for name, agent in acp_agents.items()})
|
||||||
|
|
||||||
|
if previous_checkpointer_config != config.checkpointer:
|
||||||
|
# These runtime singletons derive their backend from checkpointer config.
|
||||||
|
# Keep imports local to avoid cycles: both providers import get_app_config.
|
||||||
|
from deerflow.runtime.checkpointer import reset_checkpointer
|
||||||
|
from deerflow.runtime.store import reset_store
|
||||||
|
|
||||||
|
reset_checkpointer()
|
||||||
|
reset_store()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _apply_database_defaults(cls, config_data: dict[str, Any]) -> None:
|
def _apply_database_defaults(cls, config_data: dict[str, Any]) -> None:
|
||||||
"""Apply config.yaml defaults for persistence when the section is absent."""
|
"""Apply config.yaml defaults for persistence when the section is absent."""
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ class CheckpointerConfig(BaseModel):
|
|||||||
description="Checkpointer backend type. "
|
description="Checkpointer backend type. "
|
||||||
"'memory' is in-process only (lost on restart). "
|
"'memory' is in-process only (lost on restart). "
|
||||||
"'sqlite' persists to a local file (requires langgraph-checkpoint-sqlite). "
|
"'sqlite' persists to a local file (requires langgraph-checkpoint-sqlite). "
|
||||||
"'postgres' persists to PostgreSQL (requires langgraph-checkpoint-postgres)."
|
"'postgres' persists to PostgreSQL (install with deerflow-harness[postgres])."
|
||||||
)
|
)
|
||||||
connection_string: str | None = Field(
|
connection_string: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Connection string for sqlite (file path) or postgres (DSN). "
|
description="Connection string for sqlite (file path) or postgres (DSN). "
|
||||||
"Required for sqlite and postgres types. "
|
"Optional for sqlite and defaults to 'store.db' when omitted. "
|
||||||
|
"Required for postgres. "
|
||||||
"For sqlite, use a file path like '.deer-flow/checkpoints.db' or ':memory:' for in-memory. "
|
"For sqlite, use a file path like '.deer-flow/checkpoints.db' or ':memory:' for in-memory. "
|
||||||
"For postgres, use a DSN like 'postgresql://user:pass@localhost:5432/db'.",
|
"For postgres, use a DSN like 'postgresql://user:pass@localhost:5432/db'.",
|
||||||
)
|
)
|
||||||
@@ -40,7 +41,10 @@ def set_checkpointer_config(config: CheckpointerConfig | None) -> None:
|
|||||||
_checkpointer_config = config
|
_checkpointer_config = config
|
||||||
|
|
||||||
|
|
||||||
def load_checkpointer_config_from_dict(config_dict: dict) -> None:
|
def load_checkpointer_config_from_dict(config_dict: dict | None) -> None:
|
||||||
"""Load checkpointer configuration from a dictionary."""
|
"""Load checkpointer configuration from a dictionary."""
|
||||||
global _checkpointer_config
|
global _checkpointer_config
|
||||||
|
if config_dict is None:
|
||||||
|
_checkpointer_config = None
|
||||||
|
return
|
||||||
_checkpointer_config = CheckpointerConfig(**config_dict)
|
_checkpointer_config = CheckpointerConfig(**config_dict)
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Configuration for loop detection middleware."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class ToolFreqOverride(BaseModel):
|
||||||
|
"""Per-tool frequency threshold override.
|
||||||
|
|
||||||
|
Can be higher or lower than the global defaults. Commonly used to raise
|
||||||
|
thresholds for high-frequency tools like bash in batch workflows (e.g.
|
||||||
|
RNA-seq pipelines) without weakening protection on every other tool.
|
||||||
|
"""
|
||||||
|
|
||||||
|
warn: int = Field(ge=1)
|
||||||
|
hard_limit: int = Field(ge=1)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate(self) -> "ToolFreqOverride":
|
||||||
|
if self.hard_limit < self.warn:
|
||||||
|
raise ValueError("hard_limit must be >= warn")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class LoopDetectionConfig(BaseModel):
|
||||||
|
"""Configuration for repetitive tool-call loop detection."""
|
||||||
|
|
||||||
|
enabled: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Whether to enable repetitive tool-call loop detection",
|
||||||
|
)
|
||||||
|
warn_threshold: int = Field(
|
||||||
|
default=3,
|
||||||
|
ge=1,
|
||||||
|
description="Number of identical tool-call sets before injecting a warning",
|
||||||
|
)
|
||||||
|
hard_limit: int = Field(
|
||||||
|
default=5,
|
||||||
|
ge=1,
|
||||||
|
description="Number of identical tool-call sets before forcing a stop",
|
||||||
|
)
|
||||||
|
window_size: int = Field(
|
||||||
|
default=20,
|
||||||
|
ge=1,
|
||||||
|
description="Number of recent tool-call sets to track per thread",
|
||||||
|
)
|
||||||
|
max_tracked_threads: int = Field(
|
||||||
|
default=100,
|
||||||
|
ge=1,
|
||||||
|
description="Maximum number of thread histories to keep in memory",
|
||||||
|
)
|
||||||
|
tool_freq_warn: int = Field(
|
||||||
|
default=30,
|
||||||
|
ge=1,
|
||||||
|
description="Number of calls to the same tool type before injecting a frequency warning",
|
||||||
|
)
|
||||||
|
tool_freq_hard_limit: int = Field(
|
||||||
|
default=50,
|
||||||
|
ge=1,
|
||||||
|
description="Number of calls to the same tool type before forcing a stop",
|
||||||
|
)
|
||||||
|
tool_freq_overrides: dict[str, ToolFreqOverride] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description=("Per-tool overrides for tool_freq_warn / tool_freq_hard_limit, keyed by tool name. Values can be higher or lower than the global defaults. Commonly used to raise thresholds for high-frequency tools like bash."),
|
||||||
|
)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_thresholds(self) -> "LoopDetectionConfig":
|
||||||
|
"""Ensure hard stop cannot happen before the warning threshold."""
|
||||||
|
if self.hard_limit < self.warn_threshold:
|
||||||
|
raise ValueError("hard_limit must be greater than or equal to warn_threshold")
|
||||||
|
if self.tool_freq_hard_limit < self.tool_freq_warn:
|
||||||
|
raise ValueError("tool_freq_hard_limit must be greater than or equal to tool_freq_warn")
|
||||||
|
return self
|
||||||
@@ -132,15 +132,20 @@ class Paths:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def agents_dir(self) -> Path:
|
def agents_dir(self) -> Path:
|
||||||
"""Root directory for all custom agents: `{base_dir}/agents/`."""
|
"""Legacy root for shared (pre user-isolation) custom agents: `{base_dir}/agents/`.
|
||||||
|
|
||||||
|
New code should use :meth:`user_agents_dir` instead. This property remains
|
||||||
|
only as a read-side fallback for installations that have not yet run the
|
||||||
|
``migrate_user_isolation.py`` script.
|
||||||
|
"""
|
||||||
return self.base_dir / "agents"
|
return self.base_dir / "agents"
|
||||||
|
|
||||||
def agent_dir(self, name: str) -> Path:
|
def agent_dir(self, name: str) -> Path:
|
||||||
"""Directory for a specific agent: `{base_dir}/agents/{name}/`."""
|
"""Legacy per-agent directory (no user isolation): `{base_dir}/agents/{name}/`."""
|
||||||
return self.agents_dir / name.lower()
|
return self.agents_dir / name.lower()
|
||||||
|
|
||||||
def agent_memory_file(self, name: str) -> Path:
|
def agent_memory_file(self, name: str) -> Path:
|
||||||
"""Per-agent memory file: `{base_dir}/agents/{name}/memory.json`."""
|
"""Legacy per-agent memory file: `{base_dir}/agents/{name}/memory.json`."""
|
||||||
return self.agent_dir(name) / "memory.json"
|
return self.agent_dir(name) / "memory.json"
|
||||||
|
|
||||||
def user_dir(self, user_id: str) -> Path:
|
def user_dir(self, user_id: str) -> Path:
|
||||||
@@ -151,9 +156,17 @@ class Paths:
|
|||||||
"""Per-user memory file: `{base_dir}/users/{user_id}/memory.json`."""
|
"""Per-user memory file: `{base_dir}/users/{user_id}/memory.json`."""
|
||||||
return self.user_dir(user_id) / "memory.json"
|
return self.user_dir(user_id) / "memory.json"
|
||||||
|
|
||||||
|
def user_agents_dir(self, user_id: str) -> Path:
|
||||||
|
"""Per-user root for that user's custom agents: `{base_dir}/users/{user_id}/agents/`."""
|
||||||
|
return self.user_dir(user_id) / "agents"
|
||||||
|
|
||||||
|
def user_agent_dir(self, user_id: str, agent_name: str) -> Path:
|
||||||
|
"""Per-user per-agent directory: `{base_dir}/users/{user_id}/agents/{name}/`."""
|
||||||
|
return self.user_agents_dir(user_id) / agent_name.lower()
|
||||||
|
|
||||||
def user_agent_memory_file(self, user_id: str, agent_name: str) -> Path:
|
def user_agent_memory_file(self, user_id: str, agent_name: str) -> Path:
|
||||||
"""Per-user per-agent memory: `{base_dir}/users/{user_id}/agents/{name}/memory.json`."""
|
"""Per-user per-agent memory: `{base_dir}/users/{user_id}/agents/{name}/memory.json`."""
|
||||||
return self.user_dir(user_id) / "agents" / agent_name.lower() / "memory.json"
|
return self.user_agent_dir(user_id, agent_name) / "memory.json"
|
||||||
|
|
||||||
def thread_dir(self, thread_id: str, *, user_id: str | None = None) -> Path:
|
def thread_dir(self, thread_id: str, *, user_id: str | None = None) -> Path:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ def set_stream_bridge_config(config: StreamBridgeConfig | None) -> None:
|
|||||||
_stream_bridge_config = config
|
_stream_bridge_config = config
|
||||||
|
|
||||||
|
|
||||||
def load_stream_bridge_config_from_dict(config_dict: dict) -> None:
|
def load_stream_bridge_config_from_dict(config_dict: dict | None) -> None:
|
||||||
"""Load stream bridge configuration from a dictionary."""
|
"""Load stream bridge configuration from a dictionary."""
|
||||||
global _stream_bridge_config
|
global _stream_bridge_config
|
||||||
|
if config_dict is None:
|
||||||
|
_stream_bridge_config = None
|
||||||
|
return
|
||||||
_stream_bridge_config = StreamBridgeConfig(**config_dict)
|
_stream_bridge_config = StreamBridgeConfig(**config_dict)
|
||||||
|
|||||||
@@ -179,9 +179,3 @@ def load_subagents_config_from_dict(config_dict: dict) -> None:
|
|||||||
overrides_summary or "none",
|
overrides_summary or "none",
|
||||||
custom_agents_names or "none",
|
custom_agents_names or "none",
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
"Subagents config loaded: default timeout=%ss, default max_turns=%s, no per-agent overrides",
|
|
||||||
_subagents_config.timeout_seconds,
|
|
||||||
_subagents_config.max_turns,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ from pydantic import BaseModel, Field
|
|||||||
class TokenUsageConfig(BaseModel):
|
class TokenUsageConfig(BaseModel):
|
||||||
"""Configuration for token usage tracking."""
|
"""Configuration for token usage tracking."""
|
||||||
|
|
||||||
enabled: bool = Field(default=False, description="Enable token usage tracking middleware")
|
enabled: bool = Field(default=True, description="Enable token usage tracking middleware")
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
"""Load MCP tools using langchain-mcp-adapters."""
|
"""Load MCP tools using langchain-mcp-adapters."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import atexit
|
|
||||||
import concurrent.futures
|
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from langchain_core.tools import BaseTool
|
from langchain_core.tools import BaseTool
|
||||||
|
|
||||||
@@ -13,46 +8,10 @@ from deerflow.config.extensions_config import ExtensionsConfig
|
|||||||
from deerflow.mcp.client import build_servers_config
|
from deerflow.mcp.client import build_servers_config
|
||||||
from deerflow.mcp.oauth import build_oauth_tool_interceptor, get_initial_oauth_headers
|
from deerflow.mcp.oauth import build_oauth_tool_interceptor, get_initial_oauth_headers
|
||||||
from deerflow.reflection import resolve_variable
|
from deerflow.reflection import resolve_variable
|
||||||
|
from deerflow.tools.sync import make_sync_tool_wrapper
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Global thread pool for sync tool invocation in async environments
|
|
||||||
_SYNC_TOOL_EXECUTOR = concurrent.futures.ThreadPoolExecutor(max_workers=10, thread_name_prefix="mcp-sync-tool")
|
|
||||||
|
|
||||||
# Register shutdown hook for the global executor
|
|
||||||
atexit.register(lambda: _SYNC_TOOL_EXECUTOR.shutdown(wait=False))
|
|
||||||
|
|
||||||
|
|
||||||
def _make_sync_tool_wrapper(coro: Callable[..., Any], tool_name: str) -> Callable[..., Any]:
|
|
||||||
"""Build a synchronous wrapper for an asynchronous tool coroutine.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
coro: The tool's asynchronous coroutine.
|
|
||||||
tool_name: Name of the tool (for logging).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A synchronous function that correctly handles nested event loops.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
||||||
try:
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
except RuntimeError:
|
|
||||||
loop = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
if loop is not None and loop.is_running():
|
|
||||||
# Use global executor to avoid nested loop issues and improve performance
|
|
||||||
future = _SYNC_TOOL_EXECUTOR.submit(asyncio.run, coro(*args, **kwargs))
|
|
||||||
return future.result()
|
|
||||||
else:
|
|
||||||
return asyncio.run(coro(*args, **kwargs))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error invoking MCP tool '{tool_name}' via sync wrapper: {e}", exc_info=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
return sync_wrapper
|
|
||||||
|
|
||||||
|
|
||||||
async def get_mcp_tools() -> list[BaseTool]:
|
async def get_mcp_tools() -> list[BaseTool]:
|
||||||
"""Get all tools from enabled MCP servers.
|
"""Get all tools from enabled MCP servers.
|
||||||
@@ -126,7 +85,7 @@ async def get_mcp_tools() -> list[BaseTool]:
|
|||||||
# Patch tools to support sync invocation, as deerflow client streams synchronously
|
# Patch tools to support sync invocation, as deerflow client streams synchronously
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
if getattr(tool, "func", None) is None and getattr(tool, "coroutine", None) is not None:
|
if getattr(tool, "func", None) is None and getattr(tool, "coroutine", None) is not None:
|
||||||
tool.func = _make_sync_tool_wrapper(tool.coroutine, tool.name)
|
tool.func = make_sync_tool_wrapper(tool.coroutine, tool.name)
|
||||||
|
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
|
|||||||
@@ -196,6 +196,10 @@ class ClaudeChatModel(ChatAnthropic):
|
|||||||
enforced by both the Anthropic API and AWS Bedrock. Breakpoints are
|
enforced by both the Anthropic API and AWS Bedrock. Breakpoints are
|
||||||
placed on the *last* eligible blocks because later breakpoints cover a
|
placed on the *last* eligible blocks because later breakpoints cover a
|
||||||
larger prefix and yield better cache hit rates.
|
larger prefix and yield better cache hit rates.
|
||||||
|
|
||||||
|
The system prompt is expected to be fully static (no per-user memory or
|
||||||
|
current date). Dynamic context is injected per-turn via
|
||||||
|
DynamicContextMiddleware as a <system-reminder> in the first HumanMessage.
|
||||||
"""
|
"""
|
||||||
MAX_CACHE_BREAKPOINTS = 4
|
MAX_CACHE_BREAKPOINTS = 4
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,16 @@ async def init_engine(
|
|||||||
try:
|
try:
|
||||||
import asyncpg # noqa: F401
|
import asyncpg # noqa: F401
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ImportError("database.backend is set to 'postgres' but asyncpg is not installed.\nInstall it with:\n uv sync --extra postgres\nOr switch to backend: sqlite in config.yaml for single-node deployment.") from None
|
raise ImportError(
|
||||||
|
"database.backend is set to 'postgres' but asyncpg is not installed.\n"
|
||||||
|
"Install it with:\n"
|
||||||
|
" cd backend && uv sync --all-packages --extra postgres\n"
|
||||||
|
"On the next `make dev` the postgres extra is auto-detected from\n"
|
||||||
|
"config.yaml (database.backend: postgres) and reinstalled, so it\n"
|
||||||
|
"will not be wiped again. Set UV_EXTRAS=postgres in .env to opt in\n"
|
||||||
|
"explicitly. Or switch to backend: sqlite in config.yaml for\n"
|
||||||
|
"single-node deployment."
|
||||||
|
) from None
|
||||||
|
|
||||||
if backend == "sqlite":
|
if backend == "sqlite":
|
||||||
import os
|
import os
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Dialect-aware JSON value matching for SQLAlchemy (SQLite + PostgreSQL)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Float, String, bindparam
|
||||||
|
from sqlalchemy.ext.compiler import compiles
|
||||||
|
from sqlalchemy.sql.compiler import SQLCompiler
|
||||||
|
from sqlalchemy.sql.expression import ColumnElement
|
||||||
|
from sqlalchemy.sql.visitors import InternalTraversal
|
||||||
|
from sqlalchemy.types import Boolean, TypeEngine
|
||||||
|
|
||||||
|
# Key is interpolated into compiled SQL; restrict charset to prevent injection.
|
||||||
|
_KEY_CHARSET_RE = re.compile(r"^[A-Za-z0-9_\-]+$")
|
||||||
|
|
||||||
|
# Allowed value types for metadata filter values (same set accepted by JsonMatch).
|
||||||
|
ALLOWED_FILTER_VALUE_TYPES: tuple[type, ...] = (type(None), bool, int, float, str)
|
||||||
|
|
||||||
|
# SQLite raises an overflow when binding values outside signed 64-bit range;
|
||||||
|
# PostgreSQL overflows during BIGINT cast. Reject at validation time instead.
|
||||||
|
_INT64_MIN = -(2**63)
|
||||||
|
_INT64_MAX = 2**63 - 1
|
||||||
|
|
||||||
|
|
||||||
|
def validate_metadata_filter_key(key: object) -> bool:
|
||||||
|
"""Return True if *key* is safe for use as a JSON metadata filter key.
|
||||||
|
|
||||||
|
A key is "safe" when it is a string matching ``[A-Za-z0-9_-]+``. The
|
||||||
|
charset is restricted because the key is interpolated into the
|
||||||
|
compiled SQL path expression (``$."<key>"`` / ``->`` literal), so any
|
||||||
|
laxer pattern would open a SQL/JSONPath injection surface.
|
||||||
|
"""
|
||||||
|
return isinstance(key, str) and bool(_KEY_CHARSET_RE.match(key))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_metadata_filter_value(value: object) -> bool:
|
||||||
|
"""Return True if *value* is an allowed type for a JSON metadata filter.
|
||||||
|
|
||||||
|
Matches the set of types ``_build_clause`` knows how to compile into
|
||||||
|
a dialect-portable predicate. Anything else (list/dict/bytes/...) is
|
||||||
|
intentionally rejected rather than silently coerced via ``str()`` —
|
||||||
|
silent coercion would (a) produce wrong matches and (b) break
|
||||||
|
SQLAlchemy's ``inherit_cache`` invariant when ``value`` is unhashable.
|
||||||
|
|
||||||
|
Integer values are additionally restricted to the signed 64-bit range
|
||||||
|
``[-2**63, 2**63 - 1]``: SQLite overflows when binding larger values
|
||||||
|
and PostgreSQL overflows during the ``BIGINT`` cast.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, ALLOWED_FILTER_VALUE_TYPES):
|
||||||
|
return False
|
||||||
|
if isinstance(value, int) and not isinstance(value, bool):
|
||||||
|
if not (_INT64_MIN <= value <= _INT64_MAX):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class JsonMatch(ColumnElement):
|
||||||
|
"""Dialect-portable ``column[key] == value`` for JSON columns.
|
||||||
|
|
||||||
|
Compiles to ``json_type``/``json_extract`` on SQLite and
|
||||||
|
``json_typeof``/``->>`` on PostgreSQL, with type-safe comparison
|
||||||
|
that distinguishes bool vs int and NULL vs missing key.
|
||||||
|
|
||||||
|
*key* must be a single literal key matching ``[A-Za-z0-9_-]+``.
|
||||||
|
*value* must be one of: ``None``, ``bool``, ``int`` (signed 64-bit), ``float``, ``str``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
inherit_cache = True
|
||||||
|
type = Boolean()
|
||||||
|
_is_implicitly_boolean = True
|
||||||
|
|
||||||
|
_traverse_internals = [
|
||||||
|
("column", InternalTraversal.dp_clauseelement),
|
||||||
|
("key", InternalTraversal.dp_string),
|
||||||
|
("value", InternalTraversal.dp_plain_obj),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, column: ColumnElement, key: str, value: object) -> None:
|
||||||
|
if not validate_metadata_filter_key(key):
|
||||||
|
raise ValueError(f"JsonMatch key must match {_KEY_CHARSET_RE.pattern!r}; got: {key!r}")
|
||||||
|
if not validate_metadata_filter_value(value):
|
||||||
|
if isinstance(value, int) and not isinstance(value, bool):
|
||||||
|
raise TypeError(f"JsonMatch int value out of signed 64-bit range [-2**63, 2**63-1]: {value!r}")
|
||||||
|
raise TypeError(f"JsonMatch value must be None, bool, int, float, or str; got: {type(value).__name__!r}")
|
||||||
|
self.column = column
|
||||||
|
self.key = key
|
||||||
|
self.value = value
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Dialect:
|
||||||
|
"""Per-dialect names used when emitting JSON type/value comparisons."""
|
||||||
|
|
||||||
|
null_type: str
|
||||||
|
num_types: tuple[str, ...]
|
||||||
|
num_cast: str
|
||||||
|
int_types: tuple[str, ...]
|
||||||
|
int_cast: str
|
||||||
|
# None for SQLite where json_type already returns 'integer'/'real';
|
||||||
|
# regex literal for PostgreSQL where json_typeof returns 'number' for
|
||||||
|
# both ints and floats, so an extra guard prevents CAST errors on floats.
|
||||||
|
int_guard: str | None
|
||||||
|
string_type: str
|
||||||
|
bool_type: str | None
|
||||||
|
|
||||||
|
|
||||||
|
_SQLITE = _Dialect(
|
||||||
|
null_type="null",
|
||||||
|
num_types=("integer", "real"),
|
||||||
|
num_cast="REAL",
|
||||||
|
int_types=("integer",),
|
||||||
|
int_cast="INTEGER",
|
||||||
|
int_guard=None,
|
||||||
|
string_type="text",
|
||||||
|
bool_type=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
_PG = _Dialect(
|
||||||
|
null_type="null",
|
||||||
|
num_types=("number",),
|
||||||
|
num_cast="DOUBLE PRECISION",
|
||||||
|
int_types=("number",),
|
||||||
|
int_cast="BIGINT",
|
||||||
|
int_guard="'^-?[0-9]+$'",
|
||||||
|
string_type="string",
|
||||||
|
bool_type="boolean",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bind(compiler: SQLCompiler, value: object, sa_type: TypeEngine[Any], **kw: Any) -> str:
|
||||||
|
param = bindparam(None, value, type_=sa_type)
|
||||||
|
return compiler.process(param, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def _type_check(typeof: str, types: tuple[str, ...]) -> str:
|
||||||
|
if len(types) == 1:
|
||||||
|
return f"{typeof} = '{types[0]}'"
|
||||||
|
quoted = ", ".join(f"'{t}'" for t in types)
|
||||||
|
return f"{typeof} IN ({quoted})"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_clause(compiler: SQLCompiler, typeof: str, extract: str, value: object, dialect: _Dialect, **kw: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return f"{typeof} = '{dialect.null_type}'"
|
||||||
|
if isinstance(value, bool):
|
||||||
|
# bool check must precede int check — bool is a subclass of int in Python
|
||||||
|
bool_str = "true" if value else "false"
|
||||||
|
if dialect.bool_type is None:
|
||||||
|
return f"{typeof} = '{bool_str}'"
|
||||||
|
return f"({typeof} = '{dialect.bool_type}' AND {extract} = '{bool_str}')"
|
||||||
|
if isinstance(value, int):
|
||||||
|
bp = _bind(compiler, value, BigInteger(), **kw)
|
||||||
|
if dialect.int_guard:
|
||||||
|
# CASE prevents CAST error when json_typeof = 'number' also matches floats
|
||||||
|
return f"(CASE WHEN {_type_check(typeof, dialect.int_types)} AND {extract} ~ {dialect.int_guard} THEN CAST({extract} AS {dialect.int_cast}) END = {bp})"
|
||||||
|
return f"({_type_check(typeof, dialect.int_types)} AND CAST({extract} AS {dialect.int_cast}) = {bp})"
|
||||||
|
if isinstance(value, float):
|
||||||
|
bp = _bind(compiler, value, Float(), **kw)
|
||||||
|
return f"({_type_check(typeof, dialect.num_types)} AND CAST({extract} AS {dialect.num_cast}) = {bp})"
|
||||||
|
bp = _bind(compiler, str(value), String(), **kw)
|
||||||
|
return f"({typeof} = '{dialect.string_type}' AND {extract} = {bp})"
|
||||||
|
|
||||||
|
|
||||||
|
@compiles(JsonMatch, "sqlite")
|
||||||
|
def _compile_sqlite(element: JsonMatch, compiler: SQLCompiler, **kw: Any) -> str:
|
||||||
|
if not validate_metadata_filter_key(element.key):
|
||||||
|
raise ValueError(f"Key escaped validation: {element.key!r}")
|
||||||
|
col = compiler.process(element.column, **kw)
|
||||||
|
path = f'$."{element.key}"'
|
||||||
|
typeof = f"json_type({col}, '{path}')"
|
||||||
|
extract = f"json_extract({col}, '{path}')"
|
||||||
|
return _build_clause(compiler, typeof, extract, element.value, _SQLITE, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
@compiles(JsonMatch, "postgresql")
|
||||||
|
def _compile_pg(element: JsonMatch, compiler: SQLCompiler, **kw: Any) -> str:
|
||||||
|
if not validate_metadata_filter_key(element.key):
|
||||||
|
raise ValueError(f"Key escaped validation: {element.key!r}")
|
||||||
|
col = compiler.process(element.column, **kw)
|
||||||
|
typeof = f"json_typeof({col} -> '{element.key}')"
|
||||||
|
extract = f"({col} ->> '{element.key}')"
|
||||||
|
return _build_clause(compiler, typeof, extract, element.value, _PG, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
@compiles(JsonMatch)
|
||||||
|
def _compile_default(element: JsonMatch, compiler: SQLCompiler, **kw: Any) -> str:
|
||||||
|
raise NotImplementedError(f"JsonMatch supports only sqlite and postgresql; got dialect: {compiler.dialect.name}")
|
||||||
|
|
||||||
|
|
||||||
|
def json_match(column: ColumnElement, key: str, value: object) -> JsonMatch:
|
||||||
|
return JsonMatch(column, key, value)
|
||||||
@@ -23,6 +23,18 @@ class RunRepository(RunStore):
|
|||||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||||
self._sf = session_factory
|
self._sf = session_factory
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_model_name(model_name: str | None) -> str | None:
|
||||||
|
"""Normalize model_name for storage: strip whitespace, truncate to 128 chars."""
|
||||||
|
if model_name is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(model_name, str):
|
||||||
|
model_name = str(model_name)
|
||||||
|
normalized = model_name.strip()
|
||||||
|
if len(normalized) > 128:
|
||||||
|
normalized = normalized[:128]
|
||||||
|
return normalized
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _safe_json(obj: Any) -> Any:
|
def _safe_json(obj: Any) -> Any:
|
||||||
"""Ensure obj is JSON-serializable. Falls back to model_dump() or str()."""
|
"""Ensure obj is JSON-serializable. Falls back to model_dump() or str()."""
|
||||||
@@ -70,6 +82,7 @@ class RunRepository(RunStore):
|
|||||||
thread_id,
|
thread_id,
|
||||||
assistant_id=None,
|
assistant_id=None,
|
||||||
user_id: str | None | _AutoSentinel = AUTO,
|
user_id: str | None | _AutoSentinel = AUTO,
|
||||||
|
model_name: str | None = None,
|
||||||
status="pending",
|
status="pending",
|
||||||
multitask_strategy="reject",
|
multitask_strategy="reject",
|
||||||
metadata=None,
|
metadata=None,
|
||||||
@@ -85,6 +98,7 @@ class RunRepository(RunStore):
|
|||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
assistant_id=assistant_id,
|
assistant_id=assistant_id,
|
||||||
user_id=resolved_user_id,
|
user_id=resolved_user_id,
|
||||||
|
model_name=self._normalize_model_name(model_name),
|
||||||
status=status,
|
status=status,
|
||||||
multitask_strategy=multitask_strategy,
|
multitask_strategy=multitask_strategy,
|
||||||
metadata_json=self._safe_json(metadata) or {},
|
metadata_json=self._safe_json(metadata) or {},
|
||||||
@@ -209,10 +223,11 @@ class RunRepository(RunStore):
|
|||||||
"""Aggregate token usage via a single SQL GROUP BY query."""
|
"""Aggregate token usage via a single SQL GROUP BY query."""
|
||||||
_completed = RunRow.status.in_(("success", "error"))
|
_completed = RunRow.status.in_(("success", "error"))
|
||||||
_thread = RunRow.thread_id == thread_id
|
_thread = RunRow.thread_id == thread_id
|
||||||
|
model_name = func.coalesce(RunRow.model_name, "unknown")
|
||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(
|
select(
|
||||||
func.coalesce(RunRow.model_name, "unknown").label("model"),
|
model_name.label("model"),
|
||||||
func.count().label("runs"),
|
func.count().label("runs"),
|
||||||
func.coalesce(func.sum(RunRow.total_tokens), 0).label("total_tokens"),
|
func.coalesce(func.sum(RunRow.total_tokens), 0).label("total_tokens"),
|
||||||
func.coalesce(func.sum(RunRow.total_input_tokens), 0).label("total_input_tokens"),
|
func.coalesce(func.sum(RunRow.total_input_tokens), 0).label("total_input_tokens"),
|
||||||
@@ -222,7 +237,7 @@ class RunRepository(RunStore):
|
|||||||
func.coalesce(func.sum(RunRow.middleware_tokens), 0).label("middleware"),
|
func.coalesce(func.sum(RunRow.middleware_tokens), 0).label("middleware"),
|
||||||
)
|
)
|
||||||
.where(_thread, _completed)
|
.where(_thread, _completed)
|
||||||
.group_by(func.coalesce(RunRow.model_name, "unknown"))
|
.group_by(model_name)
|
||||||
)
|
)
|
||||||
|
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
from deerflow.persistence.thread_meta.base import InvalidMetadataFilterError, ThreadMetaStore
|
||||||
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
|
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
|
||||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||||
from deerflow.persistence.thread_meta.sql import ThreadMetaRepository
|
from deerflow.persistence.thread_meta.sql import ThreadMetaRepository
|
||||||
@@ -14,6 +14,7 @@ if TYPE_CHECKING:
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"InvalidMetadataFilterError",
|
||||||
"MemoryThreadMetaStore",
|
"MemoryThreadMetaStore",
|
||||||
"ThreadMetaRepository",
|
"ThreadMetaRepository",
|
||||||
"ThreadMetaRow",
|
"ThreadMetaRow",
|
||||||
|
|||||||
@@ -15,10 +15,15 @@ three-state semantics (see :mod:`deerflow.runtime.user_context`):
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import abc
|
import abc
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel
|
from deerflow.runtime.user_context import AUTO, _AutoSentinel
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidMetadataFilterError(ValueError):
|
||||||
|
"""Raised when all client-supplied metadata filter keys are rejected."""
|
||||||
|
|
||||||
|
|
||||||
class ThreadMetaStore(abc.ABC):
|
class ThreadMetaStore(abc.ABC):
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
async def create(
|
async def create(
|
||||||
@@ -40,12 +45,12 @@ class ThreadMetaStore(abc.ABC):
|
|||||||
async def search(
|
async def search(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
metadata: dict | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
user_id: str | None | _AutoSentinel = AUTO,
|
user_id: str | None | _AutoSentinel = AUTO,
|
||||||
) -> list[dict]:
|
) -> list[dict[str, Any]]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
|
|||||||
@@ -69,12 +69,12 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
|||||||
async def search(
|
async def search(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
metadata: dict | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
user_id: str | None | _AutoSentinel = AUTO,
|
user_id: str | None | _AutoSentinel = AUTO,
|
||||||
) -> list[dict]:
|
) -> list[dict[str, Any]]:
|
||||||
resolved_user_id = resolve_user_id(user_id, method_name="MemoryThreadMetaStore.search")
|
resolved_user_id = resolve_user_id(user_id, method_name="MemoryThreadMetaStore.search")
|
||||||
filter_dict: dict[str, Any] = {}
|
filter_dict: dict[str, Any] = {}
|
||||||
if metadata:
|
if metadata:
|
||||||
|
|||||||
@@ -2,16 +2,20 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
from deerflow.persistence.json_compat import json_match
|
||||||
|
from deerflow.persistence.thread_meta.base import InvalidMetadataFilterError, ThreadMetaStore
|
||||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ThreadMetaRepository(ThreadMetaStore):
|
class ThreadMetaRepository(ThreadMetaStore):
|
||||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||||
@@ -20,7 +24,7 @@ class ThreadMetaRepository(ThreadMetaStore):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _row_to_dict(row: ThreadMetaRow) -> dict[str, Any]:
|
def _row_to_dict(row: ThreadMetaRow) -> dict[str, Any]:
|
||||||
d = row.to_dict()
|
d = row.to_dict()
|
||||||
d["metadata"] = d.pop("metadata_json", {})
|
d["metadata"] = d.pop("metadata_json", None) or {}
|
||||||
for key in ("created_at", "updated_at"):
|
for key in ("created_at", "updated_at"):
|
||||||
val = d.get(key)
|
val = d.get(key)
|
||||||
if isinstance(val, datetime):
|
if isinstance(val, datetime):
|
||||||
@@ -104,39 +108,43 @@ class ThreadMetaRepository(ThreadMetaStore):
|
|||||||
async def search(
|
async def search(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
metadata: dict | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
user_id: str | None | _AutoSentinel = AUTO,
|
user_id: str | None | _AutoSentinel = AUTO,
|
||||||
) -> list[dict]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Search threads with optional metadata and status filters.
|
"""Search threads with optional metadata and status filters.
|
||||||
|
|
||||||
Owner filter is enforced by default: caller must be in a user
|
Owner filter is enforced by default: caller must be in a user
|
||||||
context. Pass ``user_id=None`` to bypass (migration/CLI).
|
context. Pass ``user_id=None`` to bypass (migration/CLI).
|
||||||
"""
|
"""
|
||||||
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.search")
|
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.search")
|
||||||
stmt = select(ThreadMetaRow).order_by(ThreadMetaRow.updated_at.desc())
|
stmt = select(ThreadMetaRow).order_by(ThreadMetaRow.updated_at.desc(), ThreadMetaRow.thread_id.desc())
|
||||||
if resolved_user_id is not None:
|
if resolved_user_id is not None:
|
||||||
stmt = stmt.where(ThreadMetaRow.user_id == resolved_user_id)
|
stmt = stmt.where(ThreadMetaRow.user_id == resolved_user_id)
|
||||||
if status:
|
if status:
|
||||||
stmt = stmt.where(ThreadMetaRow.status == status)
|
stmt = stmt.where(ThreadMetaRow.status == status)
|
||||||
|
|
||||||
if metadata:
|
if metadata:
|
||||||
# When metadata filter is active, fetch a larger window and filter
|
applied = 0
|
||||||
# in Python. TODO(Phase 2): use JSON DB operators (Postgres @>,
|
for key, value in metadata.items():
|
||||||
# SQLite json_extract) for server-side filtering.
|
try:
|
||||||
stmt = stmt.limit(limit * 5 + offset)
|
stmt = stmt.where(json_match(ThreadMetaRow.metadata_json, key, value))
|
||||||
async with self._sf() as session:
|
applied += 1
|
||||||
result = await session.execute(stmt)
|
except (ValueError, TypeError) as exc:
|
||||||
rows = [self._row_to_dict(r) for r in result.scalars()]
|
logger.warning("Skipping metadata filter key %s: %s", ascii(key), exc)
|
||||||
rows = [r for r in rows if all(r.get("metadata", {}).get(k) == v for k, v in metadata.items())]
|
if applied == 0:
|
||||||
return rows[offset : offset + limit]
|
# Comma-separated plain string (no list repr / nested
|
||||||
else:
|
# quoting) so the 400 detail surfaced by the Gateway is
|
||||||
stmt = stmt.limit(limit).offset(offset)
|
# easy for clients to read. Sorted for determinism.
|
||||||
async with self._sf() as session:
|
rejected_keys = ", ".join(sorted(str(k) for k in metadata))
|
||||||
result = await session.execute(stmt)
|
raise InvalidMetadataFilterError(f"All metadata filter keys were rejected as unsafe: {rejected_keys}")
|
||||||
return [self._row_to_dict(r) for r in result.scalars()]
|
|
||||||
|
stmt = stmt.limit(limit).offset(offset)
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return [self._row_to_dict(r) for r in result.scalars()]
|
||||||
|
|
||||||
async def _check_ownership(self, session: AsyncSession, thread_id: str, resolved_user_id: str | None) -> bool:
|
async def _check_ownership(self, session: AsyncSession, thread_id: str, resolved_user_id: str | None) -> bool:
|
||||||
"""Return True if the row exists and is owned (or filter bypassed)."""
|
"""Return True if the row exists and is owned (or filter bypassed)."""
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ logger = logging.getLogger(__name__)
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
SQLITE_INSTALL = "langgraph-checkpoint-sqlite is required for the SQLite checkpointer. Install it with: uv add langgraph-checkpoint-sqlite"
|
SQLITE_INSTALL = "langgraph-checkpoint-sqlite is required for the SQLite checkpointer. Install it with: uv add langgraph-checkpoint-sqlite"
|
||||||
POSTGRES_INSTALL = "langgraph-checkpoint-postgres is required for the PostgreSQL checkpointer. Install it with: uv add langgraph-checkpoint-postgres psycopg[binary] psycopg-pool"
|
POSTGRES_INSTALL = (
|
||||||
|
"langgraph-checkpoint-postgres is required for the PostgreSQL checkpointer. Install the package extra with: pip install 'deerflow-harness[postgres]' (or use: uv sync --all-packages --extra postgres when developing locally)"
|
||||||
|
)
|
||||||
POSTGRES_CONN_REQUIRED = "checkpointer.connection_string is required for the postgres backend"
|
POSTGRES_CONN_REQUIRED = "checkpointer.connection_string is required for the postgres backend"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
@@ -33,20 +34,21 @@ class DbRunEventStore(RunEventStore):
|
|||||||
if isinstance(val, datetime):
|
if isinstance(val, datetime):
|
||||||
d["created_at"] = val.isoformat()
|
d["created_at"] = val.isoformat()
|
||||||
d.pop("id", None)
|
d.pop("id", None)
|
||||||
# Restore dict content that was JSON-serialized on write
|
# Restore structured content that was JSON-serialized on write.
|
||||||
raw = d.get("content", "")
|
raw = d.get("content", "")
|
||||||
if isinstance(raw, str) and d.get("metadata", {}).get("content_is_dict"):
|
metadata = d.get("metadata", {})
|
||||||
|
if isinstance(raw, str) and (metadata.get("content_is_json") or metadata.get("content_is_dict")):
|
||||||
try:
|
try:
|
||||||
d["content"] = json.loads(raw)
|
d["content"] = json.loads(raw)
|
||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
# Content looked like JSON (content_is_dict flag) but failed to parse;
|
# Content looked like JSON but failed to parse;
|
||||||
# keep the raw string as-is.
|
# keep the raw string as-is.
|
||||||
logger.debug("Failed to deserialize content as JSON for event seq=%s", d.get("seq"))
|
logger.debug("Failed to deserialize content as JSON for event seq=%s", d.get("seq"))
|
||||||
return d
|
return d
|
||||||
|
|
||||||
def _truncate_trace(self, category: str, content: str | dict, metadata: dict | None) -> tuple[str | dict, dict]:
|
def _truncate_trace(self, category: str, content: Any, metadata: dict | None) -> tuple[Any, dict]:
|
||||||
if category == "trace":
|
if category == "trace":
|
||||||
text = json.dumps(content, default=str, ensure_ascii=False) if isinstance(content, dict) else content
|
text = content if isinstance(content, str) else json.dumps(content, default=str, ensure_ascii=False)
|
||||||
encoded = text.encode("utf-8")
|
encoded = text.encode("utf-8")
|
||||||
if len(encoded) > self._max_trace_content:
|
if len(encoded) > self._max_trace_content:
|
||||||
# Truncate by bytes, then decode back (may cut a multi-byte char, so use errors="ignore")
|
# Truncate by bytes, then decode back (may cut a multi-byte char, so use errors="ignore")
|
||||||
@@ -54,6 +56,18 @@ class DbRunEventStore(RunEventStore):
|
|||||||
metadata = {**(metadata or {}), "content_truncated": True, "original_byte_length": len(encoded)}
|
metadata = {**(metadata or {}), "content_truncated": True, "original_byte_length": len(encoded)}
|
||||||
return content, metadata or {}
|
return content, metadata or {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _content_to_db(content: Any, metadata: dict | None) -> tuple[str, dict]:
|
||||||
|
metadata = metadata or {}
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content, metadata
|
||||||
|
|
||||||
|
db_content = json.dumps(content, default=str, ensure_ascii=False)
|
||||||
|
metadata = {**metadata, "content_is_json": True}
|
||||||
|
if isinstance(content, dict):
|
||||||
|
metadata["content_is_dict"] = True
|
||||||
|
return db_content, metadata
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _user_id_from_context() -> str | None:
|
def _user_id_from_context() -> str | None:
|
||||||
"""Soft read of user_id from contextvar for write paths.
|
"""Soft read of user_id from contextvar for write paths.
|
||||||
@@ -82,11 +96,7 @@ class DbRunEventStore(RunEventStore):
|
|||||||
the initial ``human_message`` event (once per run).
|
the initial ``human_message`` event (once per run).
|
||||||
"""
|
"""
|
||||||
content, metadata = self._truncate_trace(category, content, metadata)
|
content, metadata = self._truncate_trace(category, content, metadata)
|
||||||
if isinstance(content, dict):
|
db_content, metadata = self._content_to_db(content, metadata)
|
||||||
db_content = json.dumps(content, default=str, ensure_ascii=False)
|
|
||||||
metadata = {**(metadata or {}), "content_is_dict": True}
|
|
||||||
else:
|
|
||||||
db_content = content
|
|
||||||
user_id = self._user_id_from_context()
|
user_id = self._user_id_from_context()
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
async with session.begin():
|
async with session.begin():
|
||||||
@@ -128,11 +138,7 @@ class DbRunEventStore(RunEventStore):
|
|||||||
category = e.get("category", "trace")
|
category = e.get("category", "trace")
|
||||||
metadata = e.get("metadata")
|
metadata = e.get("metadata")
|
||||||
content, metadata = self._truncate_trace(category, content, metadata)
|
content, metadata = self._truncate_trace(category, content, metadata)
|
||||||
if isinstance(content, dict):
|
db_content, metadata = self._content_to_db(content, metadata)
|
||||||
db_content = json.dumps(content, default=str, ensure_ascii=False)
|
|
||||||
metadata = {**(metadata or {}), "content_is_dict": True}
|
|
||||||
else:
|
|
||||||
db_content = content
|
|
||||||
row = RunEventRow(
|
row = RunEventRow(
|
||||||
thread_id=e["thread_id"],
|
thread_id=e["thread_id"],
|
||||||
run_id=e["run_id"],
|
run_id=e["run_id"],
|
||||||
|
|||||||
@@ -20,12 +20,13 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Mapping
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from langchain_core.callbacks import BaseCallbackHandler
|
from langchain_core.callbacks import BaseCallbackHandler
|
||||||
from langchain_core.messages import AnyMessage, BaseMessage, HumanMessage, ToolMessage
|
from langchain_core.messages import AIMessage, AnyMessage, BaseMessage, HumanMessage, ToolMessage
|
||||||
from langgraph.types import Command
|
from langgraph.types import Command
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -63,6 +64,16 @@ class RunJournal(BaseCallbackHandler):
|
|||||||
self._total_tokens = 0
|
self._total_tokens = 0
|
||||||
self._llm_call_count = 0
|
self._llm_call_count = 0
|
||||||
|
|
||||||
|
# Caller-bucketed token accumulators
|
||||||
|
self._lead_agent_tokens = 0
|
||||||
|
self._subagent_tokens = 0
|
||||||
|
self._middleware_tokens = 0
|
||||||
|
|
||||||
|
# Dedup: LangChain may fire on_llm_end multiple times for the same run_id
|
||||||
|
self._counted_llm_run_ids: set[str] = set()
|
||||||
|
self._counted_external_source_ids: set[str] = set()
|
||||||
|
self._counted_message_llm_run_ids: set[str] = set()
|
||||||
|
|
||||||
# Convenience fields
|
# Convenience fields
|
||||||
self._last_ai_msg: str | None = None
|
self._last_ai_msg: str | None = None
|
||||||
self._first_human_msg: str | None = None
|
self._first_human_msg: str | None = None
|
||||||
@@ -77,6 +88,50 @@ class RunJournal(BaseCallbackHandler):
|
|||||||
|
|
||||||
# -- Lifecycle callbacks --
|
# -- Lifecycle callbacks --
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _message_text(message: BaseMessage) -> str:
|
||||||
|
"""Extract displayable text from a message's mixed content shape."""
|
||||||
|
content = getattr(message, "content", None)
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts: list[str] = []
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, str):
|
||||||
|
parts.append(block)
|
||||||
|
elif isinstance(block, Mapping):
|
||||||
|
text = block.get("text")
|
||||||
|
if isinstance(text, str):
|
||||||
|
parts.append(text)
|
||||||
|
else:
|
||||||
|
nested = block.get("content")
|
||||||
|
if isinstance(nested, str):
|
||||||
|
parts.append(nested)
|
||||||
|
return "".join(parts)
|
||||||
|
if isinstance(content, Mapping):
|
||||||
|
for key in ("text", "content"):
|
||||||
|
value = content.get(key)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
|
||||||
|
text = getattr(message, "text", None)
|
||||||
|
if isinstance(text, str):
|
||||||
|
return text
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _record_message_summary(self, message: BaseMessage, *, caller: str | None = None) -> None:
|
||||||
|
"""Update run-level convenience fields for persisted run rows."""
|
||||||
|
self._msg_count += 1
|
||||||
|
|
||||||
|
# ``last_ai_message`` should represent the lead agent's user-facing
|
||||||
|
# answer. Middleware/subagent model calls and empty tool-call-only
|
||||||
|
# AI messages must not overwrite the last useful assistant text.
|
||||||
|
is_ai_message = isinstance(message, AIMessage) or getattr(message, "type", None) == "ai"
|
||||||
|
if is_ai_message and (caller is None or caller == "lead_agent"):
|
||||||
|
text = self._message_text(message).strip()
|
||||||
|
if text:
|
||||||
|
self._last_ai_msg = text[:2000]
|
||||||
|
|
||||||
def on_chain_start(
|
def on_chain_start(
|
||||||
self,
|
self,
|
||||||
serialized: dict[str, Any],
|
serialized: dict[str, Any],
|
||||||
@@ -155,6 +210,7 @@ class RunJournal(BaseCallbackHandler):
|
|||||||
content=m.model_dump(),
|
content=m.model_dump(),
|
||||||
metadata={"caller": caller},
|
metadata={"caller": caller},
|
||||||
)
|
)
|
||||||
|
self._record_message_summary(m, caller=caller)
|
||||||
break
|
break
|
||||||
if self._first_human_msg:
|
if self._first_human_msg:
|
||||||
break
|
break
|
||||||
@@ -213,20 +269,34 @@ class RunJournal(BaseCallbackHandler):
|
|||||||
"llm_call_index": call_index,
|
"llm_call_index": call_index,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if rid not in self._counted_message_llm_run_ids:
|
||||||
|
self._record_message_summary(message, caller=caller)
|
||||||
|
|
||||||
# Token accumulation
|
# Token accumulation (dedup by langchain run_id to avoid double-counting
|
||||||
|
# when the callback fires more than once for the same response)
|
||||||
if self._track_tokens:
|
if self._track_tokens:
|
||||||
input_tk = usage_dict.get("input_tokens", 0) or 0
|
input_tk = usage_dict.get("input_tokens", 0) or 0
|
||||||
output_tk = usage_dict.get("output_tokens", 0) or 0
|
output_tk = usage_dict.get("output_tokens", 0) or 0
|
||||||
total_tk = usage_dict.get("total_tokens", 0) or 0
|
total_tk = usage_dict.get("total_tokens", 0) or 0
|
||||||
if total_tk == 0:
|
if total_tk == 0:
|
||||||
total_tk = input_tk + output_tk
|
total_tk = input_tk + output_tk
|
||||||
if total_tk > 0:
|
if total_tk > 0 and rid not in self._counted_llm_run_ids:
|
||||||
|
self._counted_llm_run_ids.add(rid)
|
||||||
self._total_input_tokens += input_tk
|
self._total_input_tokens += input_tk
|
||||||
self._total_output_tokens += output_tk
|
self._total_output_tokens += output_tk
|
||||||
self._total_tokens += total_tk
|
self._total_tokens += total_tk
|
||||||
self._llm_call_count += 1
|
self._llm_call_count += 1
|
||||||
|
|
||||||
|
if caller.startswith("subagent:"):
|
||||||
|
self._subagent_tokens += total_tk
|
||||||
|
elif caller.startswith("middleware:"):
|
||||||
|
self._middleware_tokens += total_tk
|
||||||
|
else:
|
||||||
|
self._lead_agent_tokens += total_tk
|
||||||
|
|
||||||
|
if messages:
|
||||||
|
self._counted_message_llm_run_ids.add(str(run_id))
|
||||||
|
|
||||||
def on_llm_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
|
def on_llm_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
|
||||||
self._llm_start_times.pop(str(run_id), None)
|
self._llm_start_times.pop(str(run_id), None)
|
||||||
self._put(event_type="llm.error", category="trace", content=str(error))
|
self._put(event_type="llm.error", category="trace", content=str(error))
|
||||||
@@ -242,12 +312,14 @@ class RunJournal(BaseCallbackHandler):
|
|||||||
if isinstance(output, ToolMessage):
|
if isinstance(output, ToolMessage):
|
||||||
msg = cast(ToolMessage, output)
|
msg = cast(ToolMessage, output)
|
||||||
self._put(event_type="llm.tool.result", category="message", content=msg.model_dump())
|
self._put(event_type="llm.tool.result", category="message", content=msg.model_dump())
|
||||||
|
self._record_message_summary(msg)
|
||||||
elif isinstance(output, Command):
|
elif isinstance(output, Command):
|
||||||
cmd = cast(Command, output)
|
cmd = cast(Command, output)
|
||||||
messages = cmd.update.get("messages", [])
|
messages = cmd.update.get("messages", [])
|
||||||
for message in messages:
|
for message in messages:
|
||||||
if isinstance(message, BaseMessage):
|
if isinstance(message, BaseMessage):
|
||||||
self._put(event_type="llm.tool.result", category="message", content=message.model_dump())
|
self._put(event_type="llm.tool.result", category="message", content=message.model_dump())
|
||||||
|
self._record_message_summary(message)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"on_tool_end {run_id}: command update message is not BaseMessage: {type(message)}")
|
logger.warning(f"on_tool_end {run_id}: command update message is not BaseMessage: {type(message)}")
|
||||||
else:
|
else:
|
||||||
@@ -330,6 +402,49 @@ class RunJournal(BaseCallbackHandler):
|
|||||||
|
|
||||||
# -- Public methods (called by worker) --
|
# -- Public methods (called by worker) --
|
||||||
|
|
||||||
|
def record_external_llm_usage_records(
|
||||||
|
self,
|
||||||
|
records: list[dict[str, int | str]],
|
||||||
|
) -> None:
|
||||||
|
"""Record token usage from external sources (e.g., subagents).
|
||||||
|
|
||||||
|
Each record should contain:
|
||||||
|
source_run_id: Unique identifier to prevent double-counting
|
||||||
|
caller: Caller tag (e.g. "subagent:general-purpose")
|
||||||
|
input_tokens: Input token count
|
||||||
|
output_tokens: Output token count
|
||||||
|
total_tokens: Total token count (computed from input+output if 0/missing)
|
||||||
|
"""
|
||||||
|
if not self._track_tokens:
|
||||||
|
return
|
||||||
|
for record in records:
|
||||||
|
source_id = str(record.get("source_run_id", ""))
|
||||||
|
if not source_id:
|
||||||
|
continue
|
||||||
|
if source_id in self._counted_external_source_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_tk = record.get("total_tokens", 0) or 0
|
||||||
|
if total_tk <= 0:
|
||||||
|
input_tk = record.get("input_tokens", 0) or 0
|
||||||
|
output_tk = record.get("output_tokens", 0) or 0
|
||||||
|
total_tk = input_tk + output_tk
|
||||||
|
if total_tk <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._counted_external_source_ids.add(source_id)
|
||||||
|
self._total_input_tokens += record.get("input_tokens", 0) or 0
|
||||||
|
self._total_output_tokens += record.get("output_tokens", 0) or 0
|
||||||
|
self._total_tokens += total_tk
|
||||||
|
|
||||||
|
caller = str(record.get("caller", ""))
|
||||||
|
if caller.startswith("subagent:"):
|
||||||
|
self._subagent_tokens += total_tk
|
||||||
|
elif caller.startswith("middleware:"):
|
||||||
|
self._middleware_tokens += total_tk
|
||||||
|
else:
|
||||||
|
self._lead_agent_tokens += total_tk
|
||||||
|
|
||||||
def set_first_human_message(self, content: str) -> None:
|
def set_first_human_message(self, content: str) -> None:
|
||||||
"""Record the first human message for convenience fields."""
|
"""Record the first human message for convenience fields."""
|
||||||
self._first_human_msg = content[:2000] if content else None
|
self._first_human_msg = content[:2000] if content else None
|
||||||
@@ -376,6 +491,9 @@ class RunJournal(BaseCallbackHandler):
|
|||||||
"total_output_tokens": self._total_output_tokens,
|
"total_output_tokens": self._total_output_tokens,
|
||||||
"total_tokens": self._total_tokens,
|
"total_tokens": self._total_tokens,
|
||||||
"llm_call_count": self._llm_call_count,
|
"llm_call_count": self._llm_call_count,
|
||||||
|
"lead_agent_tokens": self._lead_agent_tokens,
|
||||||
|
"subagent_tokens": self._subagent_tokens,
|
||||||
|
"middleware_tokens": self._middleware_tokens,
|
||||||
"message_count": self._msg_count,
|
"message_count": self._msg_count,
|
||||||
"last_ai_message": self._last_ai_msg,
|
"last_ai_message": self._last_ai_msg,
|
||||||
"first_human_message": self._first_human_msg,
|
"first_human_message": self._first_human_msg,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class RunRecord:
|
|||||||
abort_event: asyncio.Event = field(default_factory=asyncio.Event, repr=False)
|
abort_event: asyncio.Event = field(default_factory=asyncio.Event, repr=False)
|
||||||
abort_action: str = "interrupt"
|
abort_action: str = "interrupt"
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
model_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class RunManager:
|
class RunManager:
|
||||||
@@ -65,6 +66,7 @@ class RunManager:
|
|||||||
metadata=record.metadata or {},
|
metadata=record.metadata or {},
|
||||||
kwargs=record.kwargs or {},
|
kwargs=record.kwargs or {},
|
||||||
created_at=record.created_at,
|
created_at=record.created_at,
|
||||||
|
model_name=record.model_name,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to persist run %s to store", record.run_id, exc_info=True)
|
logger.warning("Failed to persist run %s to store", record.run_id, exc_info=True)
|
||||||
@@ -137,6 +139,18 @@ class RunManager:
|
|||||||
logger.warning("Failed to persist status update for run %s", run_id, exc_info=True)
|
logger.warning("Failed to persist status update for run %s", run_id, exc_info=True)
|
||||||
logger.info("Run %s -> %s", run_id, status.value)
|
logger.info("Run %s -> %s", run_id, status.value)
|
||||||
|
|
||||||
|
async def update_model_name(self, run_id: str, model_name: str | None) -> None:
|
||||||
|
"""Update the model name for a run."""
|
||||||
|
async with self._lock:
|
||||||
|
record = self._runs.get(run_id)
|
||||||
|
if record is None:
|
||||||
|
logger.warning("update_model_name called for unknown run %s", run_id)
|
||||||
|
return
|
||||||
|
record.model_name = model_name
|
||||||
|
record.updated_at = _now_iso()
|
||||||
|
await self._persist_to_store(record)
|
||||||
|
logger.info("Run %s model_name=%s", run_id, model_name)
|
||||||
|
|
||||||
async def cancel(self, run_id: str, *, action: str = "interrupt") -> bool:
|
async def cancel(self, run_id: str, *, action: str = "interrupt") -> bool:
|
||||||
"""Request cancellation of a run.
|
"""Request cancellation of a run.
|
||||||
|
|
||||||
@@ -171,6 +185,7 @@ class RunManager:
|
|||||||
metadata: dict | None = None,
|
metadata: dict | None = None,
|
||||||
kwargs: dict | None = None,
|
kwargs: dict | None = None,
|
||||||
multitask_strategy: str = "reject",
|
multitask_strategy: str = "reject",
|
||||||
|
model_name: str | None = None,
|
||||||
) -> RunRecord:
|
) -> RunRecord:
|
||||||
"""Atomically check for inflight runs and create a new one.
|
"""Atomically check for inflight runs and create a new one.
|
||||||
|
|
||||||
@@ -221,6 +236,7 @@ class RunManager:
|
|||||||
kwargs=kwargs or {},
|
kwargs=kwargs or {},
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
|
model_name=model_name,
|
||||||
)
|
)
|
||||||
self._runs[run_id] = record
|
self._runs[run_id] = record
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class RunStore(abc.ABC):
|
|||||||
thread_id: str,
|
thread_id: str,
|
||||||
assistant_id: str | None = None,
|
assistant_id: str | None = None,
|
||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
|
model_name: str | None = None,
|
||||||
status: str = "pending",
|
status: str = "pending",
|
||||||
multitask_strategy: str = "reject",
|
multitask_strategy: str = "reject",
|
||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class MemoryRunStore(RunStore):
|
|||||||
thread_id,
|
thread_id,
|
||||||
assistant_id=None,
|
assistant_id=None,
|
||||||
user_id=None,
|
user_id=None,
|
||||||
|
model_name=None,
|
||||||
status="pending",
|
status="pending",
|
||||||
multitask_strategy="reject",
|
multitask_strategy="reject",
|
||||||
metadata=None,
|
metadata=None,
|
||||||
@@ -35,6 +36,7 @@ class MemoryRunStore(RunStore):
|
|||||||
"thread_id": thread_id,
|
"thread_id": thread_id,
|
||||||
"assistant_id": assistant_id,
|
"assistant_id": assistant_id,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
|
"model_name": model_name,
|
||||||
"status": status,
|
"status": status,
|
||||||
"multitask_strategy": multitask_strategy,
|
"multitask_strategy": multitask_strategy,
|
||||||
"metadata": metadata or {},
|
"metadata": metadata or {},
|
||||||
|
|||||||
@@ -230,6 +230,17 @@ async def run_agent(
|
|||||||
else:
|
else:
|
||||||
agent = agent_factory(config=runnable_config)
|
agent = agent_factory(config=runnable_config)
|
||||||
|
|
||||||
|
# Capture the effective (resolved) model name from the agent's metadata.
|
||||||
|
# _resolve_model_name in agent.py may return the default model if the
|
||||||
|
# requested name is not in the allowlist — this update ensures the
|
||||||
|
# persisted model_name reflects the actual model used.
|
||||||
|
if record.model_name is not None:
|
||||||
|
resolved = getattr(agent, "metadata", {}) or {}
|
||||||
|
if isinstance(resolved, dict):
|
||||||
|
effective = resolved.get("model_name")
|
||||||
|
if effective and effective != record.model_name:
|
||||||
|
await run_manager.update_model_name(record.run_id, effective)
|
||||||
|
|
||||||
# 4. Attach checkpointer and store
|
# 4. Attach checkpointer and store
|
||||||
if checkpointer is not None:
|
if checkpointer is not None:
|
||||||
agent.checkpointer = checkpointer
|
agent.checkpointer = checkpointer
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ logger = logging.getLogger(__name__)
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
SQLITE_STORE_INSTALL = "langgraph-checkpoint-sqlite is required for the SQLite store. Install it with: uv add langgraph-checkpoint-sqlite"
|
SQLITE_STORE_INSTALL = "langgraph-checkpoint-sqlite is required for the SQLite store. Install it with: uv add langgraph-checkpoint-sqlite"
|
||||||
POSTGRES_STORE_INSTALL = "langgraph-checkpoint-postgres is required for the PostgreSQL store. Install it with: uv add langgraph-checkpoint-postgres psycopg[binary] psycopg-pool"
|
POSTGRES_STORE_INSTALL = (
|
||||||
|
"langgraph-checkpoint-postgres is required for the PostgreSQL store. Install the package extra with: pip install 'deerflow-harness[postgres]' (or use: uv sync --all-packages --extra postgres when developing locally)"
|
||||||
|
)
|
||||||
POSTGRES_CONN_REQUIRED = "checkpointer.connection_string is required for the postgres backend"
|
POSTGRES_CONN_REQUIRED = "checkpointer.connection_string is required for the postgres backend"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -109,6 +109,34 @@ def get_effective_user_id() -> str:
|
|||||||
return str(user.id)
|
return str(user.id)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_runtime_user_id(runtime: object | None) -> str:
|
||||||
|
"""Single source of truth for a tool/middleware's effective user_id.
|
||||||
|
|
||||||
|
Resolution order (most authoritative first):
|
||||||
|
1. ``runtime.context["user_id"]`` — set by ``inject_authenticated_user_context``
|
||||||
|
in the gateway from the auth-validated ``request.state.user``. This is
|
||||||
|
the only source that survives boundaries where the contextvar may have
|
||||||
|
been lost (background tasks scheduled outside the request task,
|
||||||
|
worker pools that don't copy_context, future cross-process drivers).
|
||||||
|
2. The ``_current_user`` ContextVar — set by the auth middleware at
|
||||||
|
request entry. Reliable for in-task work; copied by ``asyncio``
|
||||||
|
child tasks and by ``ContextThreadPoolExecutor``.
|
||||||
|
3. ``DEFAULT_USER_ID`` — last-resort fallback so unauthenticated
|
||||||
|
CLI / migration / test paths keep working without raising.
|
||||||
|
|
||||||
|
Tools that persist user-scoped state (custom agents, memory, uploads)
|
||||||
|
MUST call this instead of ``get_effective_user_id()`` directly so they
|
||||||
|
benefit from the runtime.context channel that ``setup_agent`` already
|
||||||
|
relies on.
|
||||||
|
"""
|
||||||
|
context = getattr(runtime, "context", None)
|
||||||
|
if isinstance(context, dict):
|
||||||
|
ctx_user_id = context.get("user_id")
|
||||||
|
if ctx_user_id:
|
||||||
|
return str(ctx_user_id)
|
||||||
|
return get_effective_user_id()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Sentinel-based user_id resolution
|
# Sentinel-based user_id resolution
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ class LocalSandbox(Sandbox):
|
|||||||
"""Return whether the selected shell is cmd.exe."""
|
"""Return whether the selected shell is cmd.exe."""
|
||||||
return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"}
|
return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_msys_shell(shell: str) -> bool:
|
||||||
|
"""Return whether the selected shell is a Git Bash/MSYS shell."""
|
||||||
|
normalized = shell.replace("\\", "/").lower()
|
||||||
|
shell_name = LocalSandbox._shell_name(shell)
|
||||||
|
return shell_name in {"sh.exe", "bash.exe"} and any(part in normalized for part in ("/git/", "/mingw", "/msys"))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None:
|
def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None:
|
||||||
"""Return the first executable shell path or command found from candidates."""
|
"""Return the first executable shell path or command found from candidates."""
|
||||||
@@ -303,12 +310,19 @@ class LocalSandbox(Sandbox):
|
|||||||
shell = self._get_shell()
|
shell = self._get_shell()
|
||||||
|
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
|
env = None
|
||||||
if self._is_powershell(shell):
|
if self._is_powershell(shell):
|
||||||
args = [shell, "-NoProfile", "-Command", resolved_command]
|
args = [shell, "-NoProfile", "-Command", resolved_command]
|
||||||
elif self._is_cmd_shell(shell):
|
elif self._is_cmd_shell(shell):
|
||||||
args = [shell, "/c", resolved_command]
|
args = [shell, "/c", resolved_command]
|
||||||
else:
|
else:
|
||||||
args = [shell, "-c", resolved_command]
|
args = [shell, "-c", resolved_command]
|
||||||
|
if self._is_msys_shell(shell):
|
||||||
|
env = {
|
||||||
|
**os.environ,
|
||||||
|
"MSYS_NO_PATHCONV": "1",
|
||||||
|
"MSYS2_ARG_CONV_EXCL": "*",
|
||||||
|
}
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
args,
|
args,
|
||||||
@@ -316,6 +330,7 @@ class LocalSandbox(Sandbox):
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=600,
|
timeout=600,
|
||||||
|
env=env,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
args = [shell, "-c", resolved_command]
|
args = [shell, "-c", resolved_command]
|
||||||
|
|||||||
@@ -119,3 +119,13 @@ class LocalSandboxProvider(SandboxProvider):
|
|||||||
# For Docker-based providers (e.g., AioSandboxProvider), cleanup
|
# For Docker-based providers (e.g., AioSandboxProvider), cleanup
|
||||||
# happens at application shutdown via the shutdown() method.
|
# happens at application shutdown via the shutdown() method.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
# reset_sandbox_provider() must also clear the module singleton.
|
||||||
|
global _singleton
|
||||||
|
_singleton = None
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
# LocalSandboxProvider has no extra resources beyond the shared
|
||||||
|
# singleton, so shutdown uses the same cleanup path as reset.
|
||||||
|
self.reset()
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ class SandboxProvider(ABC):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Clear cached state that survives provider instance replacement."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
_default_sandbox_provider: SandboxProvider | None = None
|
_default_sandbox_provider: SandboxProvider | None = None
|
||||||
|
|
||||||
@@ -65,11 +69,18 @@ def reset_sandbox_provider() -> None:
|
|||||||
The next call to `get_sandbox_provider()` will create a new instance.
|
The next call to `get_sandbox_provider()` will create a new instance.
|
||||||
Useful for testing or when switching configurations.
|
Useful for testing or when switching configurations.
|
||||||
|
|
||||||
|
Providers can override `reset()` to clear any module-level state they keep
|
||||||
|
alive across instances (for example, `LocalSandboxProvider`'s cached
|
||||||
|
`LocalSandbox` singleton). Without it, config/mount changes would not take
|
||||||
|
effect on the next acquire().
|
||||||
|
|
||||||
Note: If the provider has active sandboxes, they will be orphaned.
|
Note: If the provider has active sandboxes, they will be orphaned.
|
||||||
Use `shutdown_sandbox_provider()` for proper cleanup.
|
Use `shutdown_sandbox_provider()` for proper cleanup.
|
||||||
"""
|
"""
|
||||||
global _default_sandbox_provider
|
global _default_sandbox_provider
|
||||||
_default_sandbox_provider = None
|
if _default_sandbox_provider is not None:
|
||||||
|
_default_sandbox_provider.reset()
|
||||||
|
_default_sandbox_provider = None
|
||||||
|
|
||||||
|
|
||||||
def shutdown_sandbox_provider() -> None:
|
def shutdown_sandbox_provider() -> None:
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ import re
|
|||||||
import shlex
|
import shlex
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from langchain.tools import ToolRuntime, tool
|
from langchain.tools import tool
|
||||||
from langgraph.typing import ContextT
|
|
||||||
|
|
||||||
from deerflow.agents.thread_state import ThreadDataState, ThreadState
|
from deerflow.agents.thread_state import ThreadDataState
|
||||||
from deerflow.config import get_app_config
|
from deerflow.config import get_app_config
|
||||||
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
|
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
|
||||||
from deerflow.sandbox.exceptions import (
|
from deerflow.sandbox.exceptions import (
|
||||||
@@ -19,6 +18,7 @@ from deerflow.sandbox.sandbox import Sandbox
|
|||||||
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
|
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
|
||||||
from deerflow.sandbox.search import GrepMatch
|
from deerflow.sandbox.search import GrepMatch
|
||||||
from deerflow.sandbox.security import LOCAL_HOST_BASH_DISABLED_MESSAGE, is_host_bash_allowed
|
from deerflow.sandbox.security import LOCAL_HOST_BASH_DISABLED_MESSAGE, is_host_bash_allowed
|
||||||
|
from deerflow.tools.types import Runtime
|
||||||
|
|
||||||
_ABSOLUTE_PATH_PATTERN = re.compile(r"(?<![:\w])(?<!:/)/(?:[^\s\"'`;&|<>()]+)")
|
_ABSOLUTE_PATH_PATTERN = re.compile(r"(?<![:\w])(?<!:/)/(?:[^\s\"'`;&|<>()]+)")
|
||||||
_FILE_URL_PATTERN = re.compile(r"\bfile://\S+", re.IGNORECASE)
|
_FILE_URL_PATTERN = re.compile(r"\bfile://\S+", re.IGNORECASE)
|
||||||
@@ -419,7 +419,7 @@ def _join_path_preserving_style(base: str, relative: str) -> str:
|
|||||||
return f"{stripped_base}{separator}{normalized_relative}"
|
return f"{stripped_base}{separator}{normalized_relative}"
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_error(error: Exception, runtime: "ToolRuntime[ContextT, ThreadState] | None" = None) -> str:
|
def _sanitize_error(error: Exception, runtime: Runtime | None = None) -> str:
|
||||||
"""Sanitize an error message to avoid leaking host filesystem paths.
|
"""Sanitize an error message to avoid leaking host filesystem paths.
|
||||||
|
|
||||||
In local-sandbox mode, resolved host paths in the error string are masked
|
In local-sandbox mode, resolved host paths in the error string are masked
|
||||||
@@ -994,7 +994,7 @@ def _apply_cwd_prefix(command: str, thread_data: ThreadDataState | None) -> str:
|
|||||||
return command
|
return command
|
||||||
|
|
||||||
|
|
||||||
def get_thread_data(runtime: ToolRuntime[ContextT, ThreadState] | None) -> ThreadDataState | None:
|
def get_thread_data(runtime: Runtime | None) -> ThreadDataState | None:
|
||||||
"""Extract thread_data from runtime state."""
|
"""Extract thread_data from runtime state."""
|
||||||
if runtime is None:
|
if runtime is None:
|
||||||
return None
|
return None
|
||||||
@@ -1003,7 +1003,7 @@ def get_thread_data(runtime: ToolRuntime[ContextT, ThreadState] | None) -> Threa
|
|||||||
return runtime.state.get("thread_data")
|
return runtime.state.get("thread_data")
|
||||||
|
|
||||||
|
|
||||||
def is_local_sandbox(runtime: ToolRuntime[ContextT, ThreadState] | None) -> bool:
|
def is_local_sandbox(runtime: Runtime | None) -> bool:
|
||||||
"""Check if the current sandbox is a local sandbox.
|
"""Check if the current sandbox is a local sandbox.
|
||||||
|
|
||||||
Path replacement is only needed for local sandbox since aio sandbox
|
Path replacement is only needed for local sandbox since aio sandbox
|
||||||
@@ -1019,7 +1019,7 @@ def is_local_sandbox(runtime: ToolRuntime[ContextT, ThreadState] | None) -> bool
|
|||||||
return sandbox_state.get("sandbox_id") == "local"
|
return sandbox_state.get("sandbox_id") == "local"
|
||||||
|
|
||||||
|
|
||||||
def sandbox_from_runtime(runtime: ToolRuntime[ContextT, ThreadState] | None = None) -> Sandbox:
|
def sandbox_from_runtime(runtime: Runtime | None = None) -> Sandbox:
|
||||||
"""Extract sandbox instance from tool runtime.
|
"""Extract sandbox instance from tool runtime.
|
||||||
|
|
||||||
DEPRECATED: Use ensure_sandbox_initialized() for lazy initialization support.
|
DEPRECATED: Use ensure_sandbox_initialized() for lazy initialization support.
|
||||||
@@ -1048,7 +1048,7 @@ def sandbox_from_runtime(runtime: ToolRuntime[ContextT, ThreadState] | None = No
|
|||||||
return sandbox
|
return sandbox
|
||||||
|
|
||||||
|
|
||||||
def ensure_sandbox_initialized(runtime: ToolRuntime[ContextT, ThreadState] | None = None) -> Sandbox:
|
def ensure_sandbox_initialized(runtime: Runtime | None = None) -> Sandbox:
|
||||||
"""Ensure sandbox is initialized, acquiring lazily if needed.
|
"""Ensure sandbox is initialized, acquiring lazily if needed.
|
||||||
|
|
||||||
On first call, acquires a sandbox from the provider and stores it in runtime state.
|
On first call, acquires a sandbox from the provider and stores it in runtime state.
|
||||||
@@ -1107,7 +1107,7 @@ def ensure_sandbox_initialized(runtime: ToolRuntime[ContextT, ThreadState] | Non
|
|||||||
return sandbox
|
return sandbox
|
||||||
|
|
||||||
|
|
||||||
def ensure_thread_directories_exist(runtime: ToolRuntime[ContextT, ThreadState] | None) -> None:
|
def ensure_thread_directories_exist(runtime: Runtime | None) -> None:
|
||||||
"""Ensure thread data directories (workspace, uploads, outputs) exist.
|
"""Ensure thread data directories (workspace, uploads, outputs) exist.
|
||||||
|
|
||||||
This function is called lazily when any sandbox tool is first used.
|
This function is called lazily when any sandbox tool is first used.
|
||||||
@@ -1221,7 +1221,7 @@ def _truncate_ls_output(output: str, max_chars: int) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@tool("bash", parse_docstring=True)
|
@tool("bash", parse_docstring=True)
|
||||||
def bash_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, command: str) -> str:
|
def bash_tool(runtime: Runtime, description: str, command: str) -> str:
|
||||||
"""Execute a bash command in a Linux environment.
|
"""Execute a bash command in a Linux environment.
|
||||||
|
|
||||||
|
|
||||||
@@ -1270,7 +1270,7 @@ def bash_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, com
|
|||||||
|
|
||||||
|
|
||||||
@tool("ls", parse_docstring=True)
|
@tool("ls", parse_docstring=True)
|
||||||
def ls_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, path: str) -> str:
|
def ls_tool(runtime: Runtime, description: str, path: str) -> str:
|
||||||
"""List the contents of a directory up to 2 levels deep in tree format.
|
"""List the contents of a directory up to 2 levels deep in tree format.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1318,7 +1318,7 @@ def ls_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, path:
|
|||||||
|
|
||||||
@tool("glob", parse_docstring=True)
|
@tool("glob", parse_docstring=True)
|
||||||
def glob_tool(
|
def glob_tool(
|
||||||
runtime: ToolRuntime[ContextT, ThreadState],
|
runtime: Runtime,
|
||||||
description: str,
|
description: str,
|
||||||
pattern: str,
|
pattern: str,
|
||||||
path: str,
|
path: str,
|
||||||
@@ -1368,7 +1368,7 @@ def glob_tool(
|
|||||||
|
|
||||||
@tool("grep", parse_docstring=True)
|
@tool("grep", parse_docstring=True)
|
||||||
def grep_tool(
|
def grep_tool(
|
||||||
runtime: ToolRuntime[ContextT, ThreadState],
|
runtime: Runtime,
|
||||||
description: str,
|
description: str,
|
||||||
pattern: str,
|
pattern: str,
|
||||||
path: str,
|
path: str,
|
||||||
@@ -1438,7 +1438,7 @@ def grep_tool(
|
|||||||
|
|
||||||
@tool("read_file", parse_docstring=True)
|
@tool("read_file", parse_docstring=True)
|
||||||
def read_file_tool(
|
def read_file_tool(
|
||||||
runtime: ToolRuntime[ContextT, ThreadState],
|
runtime: Runtime,
|
||||||
description: str,
|
description: str,
|
||||||
path: str,
|
path: str,
|
||||||
start_line: int | None = None,
|
start_line: int | None = None,
|
||||||
@@ -1493,18 +1493,19 @@ def read_file_tool(
|
|||||||
|
|
||||||
@tool("write_file", parse_docstring=True)
|
@tool("write_file", parse_docstring=True)
|
||||||
def write_file_tool(
|
def write_file_tool(
|
||||||
runtime: ToolRuntime[ContextT, ThreadState],
|
runtime: Runtime,
|
||||||
description: str,
|
description: str,
|
||||||
path: str,
|
path: str,
|
||||||
content: str,
|
content: str,
|
||||||
append: bool = False,
|
append: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Write text content to a file.
|
"""Write text content to a file. By default this overwrites the target file; set append to true to add content to the end without replacing existing content.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
description: Explain why you are writing to this file in short words. ALWAYS PROVIDE THIS PARAMETER FIRST.
|
description: Explain why you are writing to this file in short words. ALWAYS PROVIDE THIS PARAMETER FIRST.
|
||||||
path: The **absolute** path to the file to write to. ALWAYS PROVIDE THIS PARAMETER SECOND.
|
path: The **absolute** path to the file to write to. ALWAYS PROVIDE THIS PARAMETER SECOND.
|
||||||
content: The content to write to the file. ALWAYS PROVIDE THIS PARAMETER THIRD.
|
content: The content to write to the file. ALWAYS PROVIDE THIS PARAMETER THIRD.
|
||||||
|
append: Whether to append content to the end of the file instead of overwriting it. Defaults to false.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
sandbox = ensure_sandbox_initialized(runtime)
|
sandbox = ensure_sandbox_initialized(runtime)
|
||||||
@@ -1533,7 +1534,7 @@ def write_file_tool(
|
|||||||
|
|
||||||
@tool("str_replace", parse_docstring=True)
|
@tool("str_replace", parse_docstring=True)
|
||||||
def str_replace_tool(
|
def str_replace_tool(
|
||||||
runtime: ToolRuntime[ContextT, ThreadState],
|
runtime: Runtime,
|
||||||
description: str,
|
description: str,
|
||||||
path: str,
|
path: str,
|
||||||
old_str: str,
|
old_str: str,
|
||||||
|
|||||||
@@ -9,6 +9,29 @@ from .types import SKILL_MD_FILE, Skill, SkillCategory
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_allowed_tools(raw: object, skill_file: Path) -> list[str] | None:
|
||||||
|
"""Parse the optional allowed-tools frontmatter field.
|
||||||
|
|
||||||
|
Returns None when the field is omitted. Returns a list when the field is a
|
||||||
|
YAML sequence of strings, including an empty list for explicit no-tool
|
||||||
|
skills. Raises ValueError for malformed values.
|
||||||
|
"""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise ValueError(f"allowed-tools in {skill_file} must be a list of strings")
|
||||||
|
|
||||||
|
allowed_tools: list[str] = []
|
||||||
|
for item in raw:
|
||||||
|
if not isinstance(item, str):
|
||||||
|
raise ValueError(f"allowed-tools in {skill_file} must contain only strings")
|
||||||
|
tool_name = item.strip()
|
||||||
|
if not tool_name:
|
||||||
|
raise ValueError(f"allowed-tools in {skill_file} cannot contain empty tool names")
|
||||||
|
allowed_tools.append(tool_name)
|
||||||
|
return allowed_tools
|
||||||
|
|
||||||
|
|
||||||
def parse_skill_file(skill_file: Path, category: SkillCategory, relative_path: Path | None = None) -> Skill | None:
|
def parse_skill_file(skill_file: Path, category: SkillCategory, relative_path: Path | None = None) -> Skill | None:
|
||||||
"""Parse a SKILL.md file and extract metadata.
|
"""Parse a SKILL.md file and extract metadata.
|
||||||
|
|
||||||
@@ -64,6 +87,12 @@ def parse_skill_file(skill_file: Path, category: SkillCategory, relative_path: P
|
|||||||
if license_text is not None:
|
if license_text is not None:
|
||||||
license_text = str(license_text).strip() or None
|
license_text = str(license_text).strip() or None
|
||||||
|
|
||||||
|
try:
|
||||||
|
allowed_tools = parse_allowed_tools(metadata.get("allowed-tools"), skill_file)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.error("Invalid allowed-tools in %s: %s", skill_file, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
return Skill(
|
return Skill(
|
||||||
name=name,
|
name=name,
|
||||||
description=description,
|
description=description,
|
||||||
@@ -72,6 +101,7 @@ def parse_skill_file(skill_file: Path, category: SkillCategory, relative_path: P
|
|||||||
skill_file=skill_file,
|
skill_file=skill_file,
|
||||||
relative_path=relative_path or Path(skill_file.parent.name),
|
relative_path=relative_path or Path(skill_file.parent.name),
|
||||||
category=category,
|
category=category,
|
||||||
|
allowed_tools=allowed_tools,
|
||||||
enabled=True, # Actual state comes from the extensions config file.
|
enabled=True, # Actual state comes from the extensions config file.
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from deerflow.skills.types import Skill
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NamedTool(Protocol):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_tool_names_for_skills(skills: list[Skill]) -> set[str] | None:
|
||||||
|
"""Return the union of explicit skill allowed-tools declarations.
|
||||||
|
|
||||||
|
None means legacy allow-all behavior. It is returned only when no loaded
|
||||||
|
skill declares allowed-tools. Once any skill declares the field, legacy
|
||||||
|
skills without the field contribute no tools instead of disabling the
|
||||||
|
explicit restrictions from other skills.
|
||||||
|
"""
|
||||||
|
if not skills:
|
||||||
|
return None
|
||||||
|
|
||||||
|
allowed: set[str] = set()
|
||||||
|
has_explicit_declaration = False
|
||||||
|
for skill in skills:
|
||||||
|
if skill.allowed_tools is None:
|
||||||
|
continue
|
||||||
|
has_explicit_declaration = True
|
||||||
|
if not skill.allowed_tools:
|
||||||
|
logger.info("Skill %s declared empty allowed-tools", skill.name)
|
||||||
|
allowed.update(skill.allowed_tools)
|
||||||
|
|
||||||
|
if not has_explicit_declaration:
|
||||||
|
return None
|
||||||
|
return allowed
|
||||||
|
|
||||||
|
|
||||||
|
def filter_tools_by_skill_allowed_tools[ToolT: NamedTool](tools: list[ToolT], skills: list[Skill]) -> list[ToolT]:
|
||||||
|
allowed = allowed_tool_names_for_skills(skills)
|
||||||
|
if allowed is None:
|
||||||
|
return tools
|
||||||
|
|
||||||
|
return [tool for tool in tools if tool.name in allowed]
|
||||||
@@ -27,6 +27,7 @@ class Skill:
|
|||||||
skill_file: Path
|
skill_file: Path
|
||||||
relative_path: Path # Relative path from category root to skill directory
|
relative_path: Path # Relative path from category root to skill directory
|
||||||
category: SkillCategory # 'public' or 'custom'
|
category: SkillCategory # 'public' or 'custom'
|
||||||
|
allowed_tools: list[str] | None = None
|
||||||
enabled: bool = False # Whether this skill is enabled
|
enabled: bool = False # Whether this skill is enabled
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
from deerflow.skills.parser import parse_allowed_tools
|
||||||
from deerflow.skills.types import SKILL_MD_FILE
|
from deerflow.skills.types import SKILL_MD_FILE
|
||||||
|
|
||||||
# Allowed properties in SKILL.md frontmatter
|
# Allowed properties in SKILL.md frontmatter
|
||||||
@@ -84,4 +85,9 @@ def _validate_skill_frontmatter(skill_dir: Path) -> tuple[bool, str, str | None]
|
|||||||
if len(description) > 1024:
|
if len(description) > 1024:
|
||||||
return False, f"Description is too long ({len(description)} characters). Maximum is 1024 characters.", None
|
return False, f"Description is too long ({len(description)} characters). Maximum is 1024 characters.", None
|
||||||
|
|
||||||
|
try:
|
||||||
|
parse_allowed_tools(frontmatter.get("allowed-tools"), skill_md)
|
||||||
|
except ValueError as e:
|
||||||
|
return False, str(e).replace(str(skill_md), SKILL_MD_FILE), None
|
||||||
|
|
||||||
return True, "Skill is valid!", name
|
return True, "Skill is valid!", name
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class SubagentConfig:
|
|||||||
|
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
system_prompt: str
|
system_prompt: str | None = None
|
||||||
tools: list[str] | None = None
|
tools: list[str] | None = None
|
||||||
disallowed_tools: list[str] | None = field(default_factory=lambda: ["task"])
|
disallowed_tools: list[str] | None = field(default_factory=lambda: ["task"])
|
||||||
skills: list[str] | None = None
|
skills: list[str] | None = None
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ from deerflow.agents.thread_state import SandboxState, ThreadDataState, ThreadSt
|
|||||||
from deerflow.config import get_app_config
|
from deerflow.config import get_app_config
|
||||||
from deerflow.config.app_config import AppConfig
|
from deerflow.config.app_config import AppConfig
|
||||||
from deerflow.models import create_chat_model
|
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.subagents.config import SubagentConfig, resolve_subagent_model_name
|
from deerflow.subagents.config import SubagentConfig, resolve_subagent_model_name
|
||||||
|
from deerflow.subagents.token_collector import SubagentTokenCollector
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -68,6 +71,8 @@ class SubagentResult:
|
|||||||
started_at: datetime | None = None
|
started_at: datetime | None = None
|
||||||
completed_at: datetime | None = None
|
completed_at: datetime | None = None
|
||||||
ai_messages: list[dict[str, Any]] | None = None
|
ai_messages: list[dict[str, Any]] | None = None
|
||||||
|
token_usage_records: list[dict[str, int | str]] = field(default_factory=list)
|
||||||
|
usage_reported: bool = False
|
||||||
cancel_event: threading.Event = field(default_factory=threading.Event, repr=False)
|
cancel_event: threading.Event = field(default_factory=threading.Event, repr=False)
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
@@ -260,16 +265,16 @@ class SubagentExecutor:
|
|||||||
# Generate trace_id if not provided (for top-level calls)
|
# Generate trace_id if not provided (for top-level calls)
|
||||||
self.trace_id = trace_id or str(uuid.uuid4())[:8]
|
self.trace_id = trace_id or str(uuid.uuid4())[:8]
|
||||||
|
|
||||||
# Filter tools based on config
|
self._base_tools = _filter_tools(
|
||||||
self.tools = _filter_tools(
|
|
||||||
tools,
|
tools,
|
||||||
config.tools,
|
config.tools,
|
||||||
config.disallowed_tools,
|
config.disallowed_tools,
|
||||||
)
|
)
|
||||||
|
self.tools = self._base_tools
|
||||||
|
|
||||||
logger.info(f"[trace={self.trace_id}] SubagentExecutor initialized: {config.name} with {len(self.tools)} tools")
|
logger.info(f"[trace={self.trace_id}] SubagentExecutor initialized: {config.name} with {len(self.tools)} tools")
|
||||||
|
|
||||||
def _create_agent(self):
|
def _create_agent(self, tools: list[BaseTool] | None = None):
|
||||||
"""Create the agent instance."""
|
"""Create the agent instance."""
|
||||||
app_config = self.app_config or get_app_config()
|
app_config = self.app_config or get_app_config()
|
||||||
if self.model_name is None:
|
if self.model_name is None:
|
||||||
@@ -281,28 +286,18 @@ class SubagentExecutor:
|
|||||||
# Reuse shared middleware composition with lead agent.
|
# Reuse shared middleware composition with lead agent.
|
||||||
middlewares = build_subagent_runtime_middlewares(app_config=app_config, model_name=self.model_name, lazy_init=True)
|
middlewares = build_subagent_runtime_middlewares(app_config=app_config, model_name=self.model_name, lazy_init=True)
|
||||||
|
|
||||||
|
# system_prompt is included in initial state messages (see _build_initial_state)
|
||||||
|
# to avoid multiple SystemMessages which some LLM APIs don't support.
|
||||||
return create_agent(
|
return create_agent(
|
||||||
model=model,
|
model=model,
|
||||||
tools=self.tools,
|
tools=tools if tools is not None else self.tools,
|
||||||
middleware=middlewares,
|
middleware=middlewares,
|
||||||
system_prompt=self.config.system_prompt,
|
system_prompt=None,
|
||||||
state_schema=ThreadState,
|
state_schema=ThreadState,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _load_skill_messages(self) -> list[SystemMessage]:
|
async def _load_skills(self) -> list[Skill]:
|
||||||
"""Load skill content as conversation items based on config.skills.
|
"""Load enabled skill metadata based on config.skills."""
|
||||||
|
|
||||||
Aligned with Codex's pattern: each subagent loads its own skills
|
|
||||||
per-session and injects them as conversation items (developer messages),
|
|
||||||
not as system prompt text. The config.skills whitelist controls which
|
|
||||||
skills are loaded:
|
|
||||||
- None: load all enabled skills
|
|
||||||
- []: no skills
|
|
||||||
- ["skill-a", "skill-b"]: only these skills
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of SystemMessages containing skill content.
|
|
||||||
"""
|
|
||||||
if self.config.skills is not None and len(self.config.skills) == 0:
|
if self.config.skills is not None and len(self.config.skills) == 0:
|
||||||
logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} skills=[] — skipping skill loading")
|
logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} skills=[] — skipping skill loading")
|
||||||
return []
|
return []
|
||||||
@@ -316,8 +311,8 @@ class SubagentExecutor:
|
|||||||
all_skills = await asyncio.to_thread(storage.load_skills, enabled_only=True)
|
all_skills = await asyncio.to_thread(storage.load_skills, enabled_only=True)
|
||||||
logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} loaded {len(all_skills)} enabled skills from disk")
|
logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} loaded {len(all_skills)} enabled skills from disk")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(f"[trace={self.trace_id}] Failed to load skills for subagent {self.config.name}", exc_info=True)
|
logger.exception(f"[trace={self.trace_id}] Failed to load skills for subagent {self.config.name}")
|
||||||
return []
|
raise
|
||||||
|
|
||||||
if not all_skills:
|
if not all_skills:
|
||||||
logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} no enabled skills found")
|
logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} no enabled skills found")
|
||||||
@@ -326,10 +321,26 @@ class SubagentExecutor:
|
|||||||
# Filter by config.skills whitelist
|
# Filter by config.skills whitelist
|
||||||
if self.config.skills is not None:
|
if self.config.skills is not None:
|
||||||
allowed = set(self.config.skills)
|
allowed = set(self.config.skills)
|
||||||
skills = [s for s in all_skills if s.name in allowed]
|
return [s for s in all_skills if s.name in allowed]
|
||||||
else:
|
return all_skills
|
||||||
skills = all_skills
|
|
||||||
|
|
||||||
|
def _apply_skill_allowed_tools(self, skills: list[Skill]) -> list[BaseTool]:
|
||||||
|
return filter_tools_by_skill_allowed_tools(self._base_tools, skills)
|
||||||
|
|
||||||
|
async def _load_skill_messages(self, skills: list[Skill]) -> list[SystemMessage]:
|
||||||
|
"""Load skill content as conversation items based on config.skills.
|
||||||
|
|
||||||
|
Aligned with Codex's pattern: each subagent loads its own skills
|
||||||
|
per-session and injects them as conversation items (developer messages),
|
||||||
|
not as system prompt text. The config.skills whitelist controls which
|
||||||
|
skills are loaded:
|
||||||
|
- None: load all enabled skills
|
||||||
|
- []: no skills
|
||||||
|
- ["skill-a", "skill-b"]: only these skills
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of SystemMessages containing skill content.
|
||||||
|
"""
|
||||||
if not skills:
|
if not skills:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -347,21 +358,34 @@ class SubagentExecutor:
|
|||||||
|
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
async def _build_initial_state(self, task: str) -> dict[str, Any]:
|
async def _build_initial_state(self, task: str) -> tuple[dict[str, Any], list[BaseTool]]:
|
||||||
"""Build the initial state for agent execution.
|
"""Build the initial state for agent execution.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
task: The task description.
|
task: The task description.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Initial state dictionary.
|
Initial state dictionary and tools filtered by loaded skill metadata.
|
||||||
"""
|
"""
|
||||||
# Load skills as conversation items (Codex pattern)
|
|
||||||
skill_messages = await self._load_skill_messages()
|
|
||||||
|
|
||||||
messages: list = []
|
# Load skills as conversation items (Codex pattern)
|
||||||
# Skill content injected as developer/system messages before the task
|
skills = await self._load_skills()
|
||||||
messages.extend(skill_messages)
|
filtered_tools = self._apply_skill_allowed_tools(skills)
|
||||||
|
skill_messages = await self._load_skill_messages(skills)
|
||||||
|
|
||||||
|
# Combine system_prompt and skills into a single SystemMessage.
|
||||||
|
# Some LLM APIs reject multiple SystemMessages with
|
||||||
|
# "System message must be at the beginning."
|
||||||
|
system_parts: list[str] = []
|
||||||
|
if self.config.system_prompt:
|
||||||
|
system_parts.append(self.config.system_prompt)
|
||||||
|
for skill_msg in skill_messages:
|
||||||
|
system_parts.append(skill_msg.content)
|
||||||
|
|
||||||
|
messages: list[Any] = []
|
||||||
|
if system_parts:
|
||||||
|
messages.append(SystemMessage(content="\n\n".join(system_parts)))
|
||||||
|
|
||||||
# Then the actual task
|
# Then the actual task
|
||||||
messages.append(HumanMessage(content=task))
|
messages.append(HumanMessage(content=task))
|
||||||
|
|
||||||
@@ -375,7 +399,7 @@ class SubagentExecutor:
|
|||||||
if self.thread_data is not None:
|
if self.thread_data is not None:
|
||||||
state["thread_data"] = self.thread_data
|
state["thread_data"] = self.thread_data
|
||||||
|
|
||||||
return state
|
return state, filtered_tools
|
||||||
|
|
||||||
async def _aexecute(self, task: str, result_holder: SubagentResult | None = None) -> SubagentResult:
|
async def _aexecute(self, task: str, result_holder: SubagentResult | None = None) -> SubagentResult:
|
||||||
"""Execute a task asynchronously.
|
"""Execute a task asynchronously.
|
||||||
@@ -404,13 +428,20 @@ class SubagentExecutor:
|
|||||||
ai_messages = []
|
ai_messages = []
|
||||||
result.ai_messages = ai_messages
|
result.ai_messages = ai_messages
|
||||||
|
|
||||||
|
collector: SubagentTokenCollector | None = None
|
||||||
try:
|
try:
|
||||||
agent = self._create_agent()
|
state, filtered_tools = await self._build_initial_state(task)
|
||||||
state = await self._build_initial_state(task)
|
agent = self._create_agent(filtered_tools)
|
||||||
|
|
||||||
|
# Token collector for subagent LLM calls
|
||||||
|
collector_caller = f"subagent:{self.config.name}"
|
||||||
|
collector = SubagentTokenCollector(caller=collector_caller)
|
||||||
|
|
||||||
# Build config with thread_id for sandbox access and recursion limit
|
# Build config with thread_id for sandbox access and recursion limit
|
||||||
run_config: RunnableConfig = {
|
run_config: RunnableConfig = {
|
||||||
"recursion_limit": self.config.max_turns,
|
"recursion_limit": self.config.max_turns,
|
||||||
|
"callbacks": [collector],
|
||||||
|
"tags": [collector_caller],
|
||||||
}
|
}
|
||||||
context: dict[str, Any] = {}
|
context: dict[str, Any] = {}
|
||||||
if self.thread_id:
|
if self.thread_id:
|
||||||
@@ -433,6 +464,8 @@ class SubagentExecutor:
|
|||||||
result.status = SubagentStatus.CANCELLED
|
result.status = SubagentStatus.CANCELLED
|
||||||
result.error = "Cancelled by user"
|
result.error = "Cancelled by user"
|
||||||
result.completed_at = datetime.now()
|
result.completed_at = datetime.now()
|
||||||
|
if collector is not None:
|
||||||
|
result.token_usage_records = collector.snapshot_records()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async for chunk in agent.astream(state, config=run_config, context=context, stream_mode="values"): # type: ignore[arg-type]
|
async for chunk in agent.astream(state, config=run_config, context=context, stream_mode="values"): # type: ignore[arg-type]
|
||||||
@@ -447,6 +480,7 @@ class SubagentExecutor:
|
|||||||
result.status = SubagentStatus.CANCELLED
|
result.status = SubagentStatus.CANCELLED
|
||||||
result.error = "Cancelled by user"
|
result.error = "Cancelled by user"
|
||||||
result.completed_at = datetime.now()
|
result.completed_at = datetime.now()
|
||||||
|
result.token_usage_records = collector.snapshot_records()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
final_state = chunk
|
final_state = chunk
|
||||||
@@ -473,6 +507,7 @@ class SubagentExecutor:
|
|||||||
logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} captured AI message #{len(ai_messages)}")
|
logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} captured AI message #{len(ai_messages)}")
|
||||||
|
|
||||||
logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} completed async execution")
|
logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} completed async execution")
|
||||||
|
result.token_usage_records = collector.snapshot_records()
|
||||||
|
|
||||||
if final_state is None:
|
if final_state is None:
|
||||||
logger.warning(f"[trace={self.trace_id}] Subagent {self.config.name} no final state")
|
logger.warning(f"[trace={self.trace_id}] Subagent {self.config.name} no final state")
|
||||||
@@ -552,6 +587,8 @@ class SubagentExecutor:
|
|||||||
result.status = SubagentStatus.FAILED
|
result.status = SubagentStatus.FAILED
|
||||||
result.error = str(e)
|
result.error = str(e)
|
||||||
result.completed_at = datetime.now()
|
result.completed_at = datetime.now()
|
||||||
|
if collector is not None:
|
||||||
|
result.token_usage_records = collector.snapshot_records()
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Callback handler that collects LLM token usage within a subagent.
|
||||||
|
|
||||||
|
Each subagent execution creates its own collector. After the subagent
|
||||||
|
finishes, the collected records are transferred to the parent RunJournal
|
||||||
|
via :meth:`RunJournal.record_external_llm_usage_records`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langchain_core.callbacks import BaseCallbackHandler
|
||||||
|
|
||||||
|
|
||||||
|
class SubagentTokenCollector(BaseCallbackHandler):
|
||||||
|
"""Lightweight callback handler that collects LLM token usage within a subagent."""
|
||||||
|
|
||||||
|
def __init__(self, caller: str):
|
||||||
|
super().__init__()
|
||||||
|
self.caller = caller
|
||||||
|
self._records: list[dict[str, int | str]] = []
|
||||||
|
self._counted_run_ids: set[str] = set()
|
||||||
|
|
||||||
|
def on_llm_end(
|
||||||
|
self,
|
||||||
|
response: Any,
|
||||||
|
*,
|
||||||
|
run_id: Any,
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
rid = str(run_id)
|
||||||
|
if rid in self._counted_run_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
for generation in response.generations:
|
||||||
|
for gen in generation:
|
||||||
|
if not hasattr(gen, "message"):
|
||||||
|
continue
|
||||||
|
usage = getattr(gen.message, "usage_metadata", None)
|
||||||
|
usage_dict = dict(usage) if usage else {}
|
||||||
|
input_tk = usage_dict.get("input_tokens", 0) or 0
|
||||||
|
output_tk = usage_dict.get("output_tokens", 0) or 0
|
||||||
|
total_tk = usage_dict.get("total_tokens", 0) or 0
|
||||||
|
if total_tk <= 0:
|
||||||
|
total_tk = input_tk + output_tk
|
||||||
|
if total_tk <= 0:
|
||||||
|
continue
|
||||||
|
self._counted_run_ids.add(rid)
|
||||||
|
self._records.append(
|
||||||
|
{
|
||||||
|
"source_run_id": rid,
|
||||||
|
"caller": self.caller,
|
||||||
|
"input_tokens": input_tk,
|
||||||
|
"output_tokens": output_tk,
|
||||||
|
"total_tokens": total_tk,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
def snapshot_records(self) -> list[dict[str, int | str]]:
|
||||||
|
"""Return a copy of the accumulated usage records."""
|
||||||
|
return list(self._records)
|
||||||
@@ -2,10 +2,12 @@ from .clarification_tool import ask_clarification_tool
|
|||||||
from .present_file_tool import present_file_tool
|
from .present_file_tool import present_file_tool
|
||||||
from .setup_agent_tool import setup_agent
|
from .setup_agent_tool import setup_agent
|
||||||
from .task_tool import task_tool
|
from .task_tool import task_tool
|
||||||
|
from .update_agent_tool import update_agent
|
||||||
from .view_image_tool import view_image_tool
|
from .view_image_tool import view_image_tool
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"setup_agent",
|
"setup_agent",
|
||||||
|
"update_agent",
|
||||||
"present_file_tool",
|
"present_file_tool",
|
||||||
"ask_clarification_tool",
|
"ask_clarification_tool",
|
||||||
"view_image_tool",
|
"view_image_tool",
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from langchain.tools import InjectedToolCallId, ToolRuntime, tool
|
from langchain.tools import InjectedToolCallId, tool
|
||||||
from langchain_core.messages import ToolMessage
|
from langchain_core.messages import ToolMessage
|
||||||
from langgraph.config import get_config
|
from langgraph.config import get_config
|
||||||
from langgraph.types import Command
|
from langgraph.types import Command
|
||||||
from langgraph.typing import ContextT
|
|
||||||
|
|
||||||
from deerflow.agents.thread_state import ThreadState
|
|
||||||
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths
|
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
from deerflow.tools.types import Runtime
|
||||||
|
|
||||||
OUTPUTS_VIRTUAL_PREFIX = f"{VIRTUAL_PATH_PREFIX}/outputs"
|
OUTPUTS_VIRTUAL_PREFIX = f"{VIRTUAL_PATH_PREFIX}/outputs"
|
||||||
|
|
||||||
|
|
||||||
def _get_thread_id(runtime: ToolRuntime[ContextT, ThreadState]) -> str | None:
|
def _get_thread_id(runtime: Runtime) -> str | None:
|
||||||
"""Resolve the current thread id from runtime context or RunnableConfig."""
|
"""Resolve the current thread id from runtime context or RunnableConfig."""
|
||||||
thread_id = runtime.context.get("thread_id") if runtime.context else None
|
thread_id = runtime.context.get("thread_id") if runtime.context else None
|
||||||
if thread_id:
|
if thread_id:
|
||||||
@@ -32,7 +31,7 @@ def _get_thread_id(runtime: ToolRuntime[ContextT, ThreadState]) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_presented_filepath(
|
def _normalize_presented_filepath(
|
||||||
runtime: ToolRuntime[ContextT, ThreadState],
|
runtime: Runtime,
|
||||||
filepath: str,
|
filepath: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Normalize a presented file path to the `/mnt/user-data/outputs/*` contract.
|
"""Normalize a presented file path to the `/mnt/user-data/outputs/*` contract.
|
||||||
@@ -83,7 +82,7 @@ def _normalize_presented_filepath(
|
|||||||
|
|
||||||
@tool("present_files", parse_docstring=True)
|
@tool("present_files", parse_docstring=True)
|
||||||
def present_file_tool(
|
def present_file_tool(
|
||||||
runtime: ToolRuntime[ContextT, ThreadState],
|
runtime: Runtime,
|
||||||
filepaths: list[str],
|
filepaths: list[str],
|
||||||
tool_call_id: Annotated[str, InjectedToolCallId],
|
tool_call_id: Annotated[str, InjectedToolCallId],
|
||||||
) -> Command:
|
) -> Command:
|
||||||
|
|||||||
@@ -3,20 +3,21 @@ import logging
|
|||||||
import yaml
|
import yaml
|
||||||
from langchain_core.messages import ToolMessage
|
from langchain_core.messages import ToolMessage
|
||||||
from langchain_core.tools import tool
|
from langchain_core.tools import tool
|
||||||
from langgraph.prebuilt import ToolRuntime
|
|
||||||
from langgraph.types import Command
|
from langgraph.types import Command
|
||||||
|
|
||||||
from deerflow.config.agents_config import validate_agent_name
|
from deerflow.config.agents_config import validate_agent_name
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
|
from deerflow.runtime.user_context import resolve_runtime_user_id
|
||||||
|
from deerflow.tools.types import Runtime
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@tool
|
@tool(parse_docstring=True)
|
||||||
def setup_agent(
|
def setup_agent(
|
||||||
soul: str,
|
soul: str,
|
||||||
description: str,
|
description: str,
|
||||||
runtime: ToolRuntime,
|
runtime: Runtime,
|
||||||
skills: list[str] | None = None,
|
skills: list[str] | None = None,
|
||||||
) -> Command:
|
) -> Command:
|
||||||
"""Setup the custom DeerFlow agent.
|
"""Setup the custom DeerFlow agent.
|
||||||
@@ -34,7 +35,14 @@ def setup_agent(
|
|||||||
try:
|
try:
|
||||||
agent_name = validate_agent_name(agent_name)
|
agent_name = validate_agent_name(agent_name)
|
||||||
paths = get_paths()
|
paths = get_paths()
|
||||||
agent_dir = paths.agent_dir(agent_name) if agent_name else paths.base_dir
|
if agent_name:
|
||||||
|
# Custom agents are persisted under the current user's bucket so
|
||||||
|
# different users do not see each other's agents.
|
||||||
|
user_id = resolve_runtime_user_id(runtime)
|
||||||
|
agent_dir = paths.user_agent_dir(user_id, agent_name)
|
||||||
|
else:
|
||||||
|
# Default agent (no agent_name): SOUL.md lives at the global base dir.
|
||||||
|
agent_dir = paths.base_dir
|
||||||
is_new_dir = not agent_dir.exists()
|
is_new_dir = not agent_dir.exists()
|
||||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import uuid
|
|||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from typing import TYPE_CHECKING, Annotated, Any, cast
|
from typing import TYPE_CHECKING, Annotated, Any, cast
|
||||||
|
|
||||||
from langchain.tools import InjectedToolCallId, ToolRuntime, tool
|
from langchain.tools import InjectedToolCallId, tool
|
||||||
from langgraph.config import get_stream_writer
|
from langgraph.config import get_stream_writer
|
||||||
from langgraph.typing import ContextT
|
|
||||||
|
|
||||||
from deerflow.agents.thread_state import ThreadState
|
|
||||||
from deerflow.config import get_app_config
|
from deerflow.config import get_app_config
|
||||||
from deerflow.sandbox.security import LOCAL_BASH_SUBAGENT_DISABLED_MESSAGE, is_host_bash_allowed
|
from deerflow.sandbox.security import LOCAL_BASH_SUBAGENT_DISABLED_MESSAGE, is_host_bash_allowed
|
||||||
from deerflow.subagents import SubagentExecutor, get_available_subagent_names, get_subagent_config
|
from deerflow.subagents import SubagentExecutor, get_available_subagent_names, get_subagent_config
|
||||||
@@ -21,12 +19,132 @@ from deerflow.subagents.executor import (
|
|||||||
get_background_task_result,
|
get_background_task_result,
|
||||||
request_cancel_background_task,
|
request_cancel_background_task,
|
||||||
)
|
)
|
||||||
|
from deerflow.tools.types import Runtime
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from deerflow.config.app_config import AppConfig
|
from deerflow.config.app_config import AppConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Cache subagent token usage by tool_call_id so TokenUsageMiddleware can
|
||||||
|
# write it back to the triggering AIMessage's usage_metadata.
|
||||||
|
_subagent_usage_cache: dict[str, dict[str, int]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _token_usage_cache_enabled(app_config: "AppConfig | None") -> bool:
|
||||||
|
if app_config is None:
|
||||||
|
try:
|
||||||
|
app_config = get_app_config()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
return bool(getattr(getattr(app_config, "token_usage", None), "enabled", False))
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_subagent_usage(tool_call_id: str, usage: dict | None, *, enabled: bool = True) -> None:
|
||||||
|
if enabled and usage:
|
||||||
|
_subagent_usage_cache[tool_call_id] = usage
|
||||||
|
|
||||||
|
|
||||||
|
def pop_cached_subagent_usage(tool_call_id: str) -> dict | None:
|
||||||
|
return _subagent_usage_cache.pop(tool_call_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_subagent_terminal(result: Any) -> bool:
|
||||||
|
"""Return whether a background subagent result is safe to clean up."""
|
||||||
|
return result.status in {SubagentStatus.COMPLETED, SubagentStatus.FAILED, SubagentStatus.CANCELLED, SubagentStatus.TIMED_OUT} or getattr(result, "completed_at", None) is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def _await_subagent_terminal(task_id: str, max_polls: int) -> Any | None:
|
||||||
|
"""Poll until the background subagent reaches a terminal status or we run out of polls."""
|
||||||
|
for _ in range(max_polls):
|
||||||
|
result = get_background_task_result(task_id)
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
if _is_subagent_terminal(result):
|
||||||
|
return result
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _deferred_cleanup_subagent_task(task_id: str, trace_id: str, max_polls: int) -> None:
|
||||||
|
"""Keep polling a cancelled subagent until it can be safely removed."""
|
||||||
|
cleanup_poll_count = 0
|
||||||
|
while True:
|
||||||
|
result = get_background_task_result(task_id)
|
||||||
|
if result is None:
|
||||||
|
return
|
||||||
|
if _is_subagent_terminal(result):
|
||||||
|
cleanup_background_task(task_id)
|
||||||
|
return
|
||||||
|
if cleanup_poll_count >= max_polls:
|
||||||
|
logger.warning(f"[trace={trace_id}] Deferred cleanup for task {task_id} timed out after {cleanup_poll_count} polls")
|
||||||
|
return
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
cleanup_poll_count += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _log_cleanup_failure(cleanup_task: asyncio.Task[None], *, trace_id: str, task_id: str) -> None:
|
||||||
|
if cleanup_task.cancelled():
|
||||||
|
return
|
||||||
|
|
||||||
|
exc = cleanup_task.exception()
|
||||||
|
if exc is not None:
|
||||||
|
logger.error(f"[trace={trace_id}] Deferred cleanup failed for task {task_id}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_deferred_subagent_cleanup(task_id: str, trace_id: str, max_polls: int) -> None:
|
||||||
|
logger.debug(f"[trace={trace_id}] Scheduling deferred cleanup for cancelled task {task_id}")
|
||||||
|
cleanup_task = asyncio.create_task(_deferred_cleanup_subagent_task(task_id, trace_id, max_polls))
|
||||||
|
cleanup_task.add_done_callback(lambda task: _log_cleanup_failure(task, trace_id=trace_id, task_id=task_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _find_usage_recorder(runtime: Any) -> Any | None:
|
||||||
|
"""Find a callback handler with ``record_external_llm_usage_records`` in the runtime config."""
|
||||||
|
if runtime is None:
|
||||||
|
return None
|
||||||
|
config = getattr(runtime, "config", None)
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
return None
|
||||||
|
callbacks = config.get("callbacks", [])
|
||||||
|
if not callbacks:
|
||||||
|
return None
|
||||||
|
for cb in callbacks:
|
||||||
|
if hasattr(cb, "record_external_llm_usage_records"):
|
||||||
|
return cb
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_usage(records: list[dict] | None) -> dict | None:
|
||||||
|
"""Summarize token usage records into a compact dict for SSE events."""
|
||||||
|
if not records:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"input_tokens": sum(r.get("input_tokens", 0) or 0 for r in records),
|
||||||
|
"output_tokens": sum(r.get("output_tokens", 0) or 0 for r in records),
|
||||||
|
"total_tokens": sum(r.get("total_tokens", 0) or 0 for r in records),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _report_subagent_usage(runtime: Any, result: Any) -> None:
|
||||||
|
"""Report subagent token usage to the parent RunJournal, if available.
|
||||||
|
|
||||||
|
Each subagent task must be reported only once (guarded by usage_reported).
|
||||||
|
"""
|
||||||
|
if getattr(result, "usage_reported", True):
|
||||||
|
return
|
||||||
|
records = getattr(result, "token_usage_records", None) or []
|
||||||
|
if not records:
|
||||||
|
return
|
||||||
|
journal = _find_usage_recorder(runtime)
|
||||||
|
if journal is None:
|
||||||
|
logger.debug("No usage recorder found in runtime callbacks — subagent token usage not recorded")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
journal.record_external_llm_usage_records(records)
|
||||||
|
result.usage_reported = True
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to report subagent token usage", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def _get_runtime_app_config(runtime: Any) -> "AppConfig | None":
|
def _get_runtime_app_config(runtime: Any) -> "AppConfig | None":
|
||||||
context = getattr(runtime, "context", None)
|
context = getattr(runtime, "context", None)
|
||||||
@@ -50,12 +168,11 @@ def _merge_skill_allowlists(parent: list[str] | None, child: list[str] | None) -
|
|||||||
|
|
||||||
@tool("task", parse_docstring=True)
|
@tool("task", parse_docstring=True)
|
||||||
async def task_tool(
|
async def task_tool(
|
||||||
runtime: ToolRuntime[ContextT, ThreadState],
|
runtime: Runtime,
|
||||||
description: str,
|
description: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
subagent_type: str,
|
subagent_type: str,
|
||||||
tool_call_id: Annotated[str, InjectedToolCallId],
|
tool_call_id: Annotated[str, InjectedToolCallId],
|
||||||
max_turns: int | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Delegate a task to a specialized subagent that runs in its own context.
|
"""Delegate a task to a specialized subagent that runs in its own context.
|
||||||
|
|
||||||
@@ -91,9 +208,9 @@ async def task_tool(
|
|||||||
description: A short (3-5 word) description of the task for logging/display. ALWAYS PROVIDE THIS PARAMETER FIRST.
|
description: A short (3-5 word) description of the task for logging/display. ALWAYS PROVIDE THIS PARAMETER FIRST.
|
||||||
prompt: The task description for the subagent. Be specific and clear about what needs to be done. ALWAYS PROVIDE THIS PARAMETER SECOND.
|
prompt: The task description for the subagent. Be specific and clear about what needs to be done. ALWAYS PROVIDE THIS PARAMETER SECOND.
|
||||||
subagent_type: The type of subagent to use. ALWAYS PROVIDE THIS PARAMETER THIRD.
|
subagent_type: The type of subagent to use. ALWAYS PROVIDE THIS PARAMETER THIRD.
|
||||||
max_turns: Optional maximum number of agent turns. Defaults to subagent's configured max.
|
|
||||||
"""
|
"""
|
||||||
runtime_app_config = _get_runtime_app_config(runtime)
|
runtime_app_config = _get_runtime_app_config(runtime)
|
||||||
|
cache_token_usage = _token_usage_cache_enabled(runtime_app_config)
|
||||||
available_subagent_names = get_available_subagent_names(app_config=runtime_app_config) if runtime_app_config is not None else get_available_subagent_names()
|
available_subagent_names = get_available_subagent_names(app_config=runtime_app_config) if runtime_app_config is not None else get_available_subagent_names()
|
||||||
|
|
||||||
# Get subagent configuration
|
# Get subagent configuration
|
||||||
@@ -113,9 +230,6 @@ async def task_tool(
|
|||||||
# each subagent loads its own skills based on config, injected as conversation items).
|
# each subagent loads its own skills based on config, injected as conversation items).
|
||||||
# No longer appended to system_prompt here.
|
# No longer appended to system_prompt here.
|
||||||
|
|
||||||
if max_turns is not None:
|
|
||||||
overrides["max_turns"] = max_turns
|
|
||||||
|
|
||||||
# Extract parent context from runtime
|
# Extract parent context from runtime
|
||||||
sandbox_state = None
|
sandbox_state = None
|
||||||
thread_data = None
|
thread_data = None
|
||||||
@@ -232,23 +346,32 @@ async def task_tool(
|
|||||||
last_message_count = current_message_count
|
last_message_count = current_message_count
|
||||||
|
|
||||||
# Check if task completed, failed, or timed out
|
# Check if task completed, failed, or timed out
|
||||||
|
usage = _summarize_usage(getattr(result, "token_usage_records", None))
|
||||||
if result.status == SubagentStatus.COMPLETED:
|
if result.status == SubagentStatus.COMPLETED:
|
||||||
writer({"type": "task_completed", "task_id": task_id, "result": result.result})
|
_cache_subagent_usage(tool_call_id, usage, enabled=cache_token_usage)
|
||||||
|
_report_subagent_usage(runtime, result)
|
||||||
|
writer({"type": "task_completed", "task_id": task_id, "result": result.result, "usage": usage})
|
||||||
logger.info(f"[trace={trace_id}] Task {task_id} completed after {poll_count} polls")
|
logger.info(f"[trace={trace_id}] Task {task_id} completed after {poll_count} polls")
|
||||||
cleanup_background_task(task_id)
|
cleanup_background_task(task_id)
|
||||||
return f"Task Succeeded. Result: {result.result}"
|
return f"Task Succeeded. Result: {result.result}"
|
||||||
elif result.status == SubagentStatus.FAILED:
|
elif result.status == SubagentStatus.FAILED:
|
||||||
writer({"type": "task_failed", "task_id": task_id, "error": result.error})
|
_cache_subagent_usage(tool_call_id, usage, enabled=cache_token_usage)
|
||||||
|
_report_subagent_usage(runtime, result)
|
||||||
|
writer({"type": "task_failed", "task_id": task_id, "error": result.error, "usage": usage})
|
||||||
logger.error(f"[trace={trace_id}] Task {task_id} failed: {result.error}")
|
logger.error(f"[trace={trace_id}] Task {task_id} failed: {result.error}")
|
||||||
cleanup_background_task(task_id)
|
cleanup_background_task(task_id)
|
||||||
return f"Task failed. Error: {result.error}"
|
return f"Task failed. Error: {result.error}"
|
||||||
elif result.status == SubagentStatus.CANCELLED:
|
elif result.status == SubagentStatus.CANCELLED:
|
||||||
writer({"type": "task_cancelled", "task_id": task_id, "error": result.error})
|
_cache_subagent_usage(tool_call_id, usage, enabled=cache_token_usage)
|
||||||
|
_report_subagent_usage(runtime, result)
|
||||||
|
writer({"type": "task_cancelled", "task_id": task_id, "error": result.error, "usage": usage})
|
||||||
logger.info(f"[trace={trace_id}] Task {task_id} cancelled: {result.error}")
|
logger.info(f"[trace={trace_id}] Task {task_id} cancelled: {result.error}")
|
||||||
cleanup_background_task(task_id)
|
cleanup_background_task(task_id)
|
||||||
return "Task cancelled by user."
|
return "Task cancelled by user."
|
||||||
elif result.status == SubagentStatus.TIMED_OUT:
|
elif result.status == SubagentStatus.TIMED_OUT:
|
||||||
writer({"type": "task_timed_out", "task_id": task_id, "error": result.error})
|
_cache_subagent_usage(tool_call_id, usage, enabled=cache_token_usage)
|
||||||
|
_report_subagent_usage(runtime, result)
|
||||||
|
writer({"type": "task_timed_out", "task_id": task_id, "error": result.error, "usage": usage})
|
||||||
logger.warning(f"[trace={trace_id}] Task {task_id} timed out: {result.error}")
|
logger.warning(f"[trace={trace_id}] Task {task_id} timed out: {result.error}")
|
||||||
cleanup_background_task(task_id)
|
cleanup_background_task(task_id)
|
||||||
return f"Task timed out. Error: {result.error}"
|
return f"Task timed out. Error: {result.error}"
|
||||||
@@ -266,43 +389,34 @@ async def task_tool(
|
|||||||
if poll_count > max_poll_count:
|
if poll_count > max_poll_count:
|
||||||
timeout_minutes = config.timeout_seconds // 60
|
timeout_minutes = config.timeout_seconds // 60
|
||||||
logger.error(f"[trace={trace_id}] Task {task_id} polling timed out after {poll_count} polls (should have been caught by thread pool timeout)")
|
logger.error(f"[trace={trace_id}] Task {task_id} polling timed out after {poll_count} polls (should have been caught by thread pool timeout)")
|
||||||
writer({"type": "task_timed_out", "task_id": task_id})
|
_report_subagent_usage(runtime, result)
|
||||||
|
usage = _summarize_usage(getattr(result, "token_usage_records", None))
|
||||||
|
_cache_subagent_usage(tool_call_id, usage, enabled=cache_token_usage)
|
||||||
|
writer({"type": "task_timed_out", "task_id": task_id, "usage": usage})
|
||||||
return f"Task polling timed out after {timeout_minutes} minutes. This may indicate the background task is stuck. Status: {result.status.value}"
|
return f"Task polling timed out after {timeout_minutes} minutes. This may indicate the background task is stuck. Status: {result.status.value}"
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
# Signal the background subagent thread to stop cooperatively.
|
# Signal the background subagent thread to stop cooperatively.
|
||||||
# Without this, the thread (running in ThreadPoolExecutor with its
|
|
||||||
# own event loop via asyncio.run) would continue executing even
|
|
||||||
# after the parent task is cancelled.
|
|
||||||
request_cancel_background_task(task_id)
|
request_cancel_background_task(task_id)
|
||||||
|
|
||||||
async def cleanup_when_done() -> None:
|
# Wait (shielded) for the subagent to reach a terminal state so the
|
||||||
max_cleanup_polls = max_poll_count
|
# final token usage snapshot is reported to the parent RunJournal
|
||||||
cleanup_poll_count = 0
|
# before the parent worker persists get_completion_data().
|
||||||
|
terminal_result = None
|
||||||
|
try:
|
||||||
|
terminal_result = await asyncio.shield(_await_subagent_terminal(task_id, max_poll_count))
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
while True:
|
# Report whatever the subagent collected (even if we timed out).
|
||||||
result = get_background_task_result(task_id)
|
final_result = terminal_result or get_background_task_result(task_id)
|
||||||
if result is None:
|
if final_result is not None:
|
||||||
return
|
_report_subagent_usage(runtime, final_result)
|
||||||
|
if final_result is not None and _is_subagent_terminal(final_result):
|
||||||
if result.status in {SubagentStatus.COMPLETED, SubagentStatus.FAILED, SubagentStatus.CANCELLED, SubagentStatus.TIMED_OUT} or getattr(result, "completed_at", None) is not None:
|
cleanup_background_task(task_id)
|
||||||
cleanup_background_task(task_id)
|
else:
|
||||||
return
|
_schedule_deferred_subagent_cleanup(task_id, trace_id, max_poll_count)
|
||||||
|
_subagent_usage_cache.pop(tool_call_id, None)
|
||||||
if cleanup_poll_count > max_cleanup_polls:
|
raise
|
||||||
logger.warning(f"[trace={trace_id}] Deferred cleanup for task {task_id} timed out after {cleanup_poll_count} polls")
|
except Exception:
|
||||||
return
|
_subagent_usage_cache.pop(tool_call_id, None)
|
||||||
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
cleanup_poll_count += 1
|
|
||||||
|
|
||||||
def log_cleanup_failure(cleanup_task: asyncio.Task[None]) -> None:
|
|
||||||
if cleanup_task.cancelled():
|
|
||||||
return
|
|
||||||
|
|
||||||
exc = cleanup_task.exception()
|
|
||||||
if exc is not None:
|
|
||||||
logger.error(f"[trace={trace_id}] Deferred cleanup failed for task {task_id}: {exc}")
|
|
||||||
|
|
||||||
logger.debug(f"[trace={trace_id}] Scheduling deferred cleanup for cancelled task {task_id}")
|
|
||||||
asyncio.create_task(cleanup_when_done()).add_done_callback(log_cleanup_failure)
|
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"""update_agent tool — let a custom agent persist updates to its own SOUL.md / config.
|
||||||
|
|
||||||
|
Bound to the lead agent only when ``runtime.context['agent_name']`` is set
|
||||||
|
(i.e. inside an existing custom agent's chat). The default agent does not see
|
||||||
|
this tool, and the bootstrap flow continues to use ``setup_agent`` for the
|
||||||
|
initial creation handshake.
|
||||||
|
|
||||||
|
The tool writes back to ``{base_dir}/users/{user_id}/agents/{agent_name}/{config.yaml,SOUL.md}``
|
||||||
|
so an agent created by one user is never visible to (or mutable by) another.
|
||||||
|
Writes are staged into temp files first; both files are renamed into place only
|
||||||
|
after both temp files are successfully written, so a partial failure cannot leave
|
||||||
|
config.yaml updated while SOUL.md still holds stale content.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from langchain_core.messages import ToolMessage
|
||||||
|
from langchain_core.tools import tool
|
||||||
|
from langgraph.types import Command
|
||||||
|
|
||||||
|
from deerflow.config.agents_config import load_agent_config, validate_agent_name
|
||||||
|
from deerflow.config.app_config import get_app_config
|
||||||
|
from deerflow.config.paths import get_paths
|
||||||
|
from deerflow.runtime.user_context import resolve_runtime_user_id
|
||||||
|
from deerflow.tools.types import Runtime
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_temp(path: Path, text: str) -> Path:
|
||||||
|
"""Write ``text`` into a sibling temp file and return its path.
|
||||||
|
|
||||||
|
The caller is responsible for ``Path.replace``-ing the temp into the target
|
||||||
|
once every staged file is ready, or for unlinking it on failure.
|
||||||
|
"""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd = tempfile.NamedTemporaryFile(
|
||||||
|
mode="w",
|
||||||
|
dir=path.parent,
|
||||||
|
suffix=".tmp",
|
||||||
|
delete=False,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
fd.write(text)
|
||||||
|
fd.flush()
|
||||||
|
fd.close()
|
||||||
|
return Path(fd.name)
|
||||||
|
except BaseException:
|
||||||
|
fd.close()
|
||||||
|
Path(fd.name).unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_temps(temps: list[Path]) -> None:
|
||||||
|
"""Best-effort removal of staged temp files."""
|
||||||
|
for tmp in temps:
|
||||||
|
try:
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
logger.debug("Failed to clean up temp file %s", tmp, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
@tool(parse_docstring=True)
|
||||||
|
def update_agent(
|
||||||
|
runtime: Runtime,
|
||||||
|
soul: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
skills: list[str] | None = None,
|
||||||
|
tool_groups: list[str] | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
) -> Command:
|
||||||
|
"""Persist updates to the current custom agent's SOUL.md and config.yaml.
|
||||||
|
|
||||||
|
Use this when the user asks to refine the agent's identity, description,
|
||||||
|
skill whitelist, tool-group whitelist, or default model. Only the fields
|
||||||
|
you explicitly pass are updated; omitted fields keep their existing values.
|
||||||
|
|
||||||
|
Pass ``soul`` as the FULL replacement SOUL.md content — there is no patch
|
||||||
|
semantics, so always start from the current SOUL and apply your edits.
|
||||||
|
|
||||||
|
Pass ``skills=[]`` to disable all skills for this agent. Omit ``skills``
|
||||||
|
entirely to keep the existing whitelist.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
soul: Optional full replacement SOUL.md content.
|
||||||
|
description: Optional new one-line description.
|
||||||
|
skills: Optional skill whitelist. ``[]`` = no skills, omit = unchanged.
|
||||||
|
tool_groups: Optional tool-group whitelist. ``[]`` = empty, omit = unchanged.
|
||||||
|
model: Optional model override (must match a configured model name).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Command with a ToolMessage describing the result. Changes take effect
|
||||||
|
on the next user turn (when the lead agent is rebuilt with the fresh
|
||||||
|
SOUL.md and config.yaml).
|
||||||
|
"""
|
||||||
|
tool_call_id = runtime.tool_call_id
|
||||||
|
agent_name_raw: str | None = runtime.context.get("agent_name") if runtime.context else None
|
||||||
|
|
||||||
|
def _err(message: str) -> Command:
|
||||||
|
return Command(update={"messages": [ToolMessage(content=f"Error: {message}", tool_call_id=tool_call_id)]})
|
||||||
|
|
||||||
|
if soul is None and description is None and skills is None and tool_groups is None and model is None:
|
||||||
|
return _err("No fields provided. Pass at least one of: soul, description, skills, tool_groups, model.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
agent_name = validate_agent_name(agent_name_raw)
|
||||||
|
except ValueError as e:
|
||||||
|
return _err(str(e))
|
||||||
|
|
||||||
|
if not agent_name:
|
||||||
|
return _err("update_agent is only available inside a custom agent's chat. There is no agent_name in the current runtime context, so there is nothing to update. If you are inside the bootstrap flow, use setup_agent instead.")
|
||||||
|
|
||||||
|
# Resolve the active user so that updates only affect this user's agent.
|
||||||
|
# ``resolve_runtime_user_id`` prefers ``runtime.context["user_id"]`` (set by
|
||||||
|
# the gateway from the auth-validated request) and falls back to the
|
||||||
|
# contextvar, then DEFAULT_USER_ID. This matches setup_agent so a user
|
||||||
|
# creating an agent and later refining it always touches the same files,
|
||||||
|
# even if the contextvar gets lost across an async/thread boundary
|
||||||
|
# (issue #2782 / #2862 class of bugs).
|
||||||
|
user_id = resolve_runtime_user_id(runtime)
|
||||||
|
|
||||||
|
# Reject an unknown ``model`` *before* touching the filesystem. Otherwise
|
||||||
|
# ``_resolve_model_name`` silently falls back to the default at runtime
|
||||||
|
# and the user sees confusing repeated warnings on every later turn.
|
||||||
|
if model is not None and get_app_config().get_model_config(model) is None:
|
||||||
|
return _err(f"Unknown model '{model}'. Pass a model name that exists in config.yaml's models section.")
|
||||||
|
|
||||||
|
paths = get_paths()
|
||||||
|
agent_dir = paths.user_agent_dir(user_id, agent_name)
|
||||||
|
if not agent_dir.exists() and paths.agent_dir(agent_name).exists():
|
||||||
|
return _err(f"Agent '{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 updating.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
existing_cfg = load_agent_config(agent_name, user_id=user_id)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return _err(f"Agent '{agent_name}' does not exist for the current user. Use setup_agent to create a new agent first.")
|
||||||
|
except ValueError as e:
|
||||||
|
return _err(f"Agent '{agent_name}' has an unreadable config: {e}")
|
||||||
|
|
||||||
|
if existing_cfg is None:
|
||||||
|
return _err(f"Agent '{agent_name}' could not be loaded.")
|
||||||
|
|
||||||
|
updated_fields: list[str] = []
|
||||||
|
|
||||||
|
# Force the on-disk ``name`` to match the directory we are writing into,
|
||||||
|
# even if ``existing_cfg.name`` had drifted (e.g. from manual yaml edits).
|
||||||
|
config_data: dict[str, Any] = {"name": agent_name}
|
||||||
|
new_description = description if description is not None else existing_cfg.description
|
||||||
|
config_data["description"] = new_description
|
||||||
|
if description is not None and description != existing_cfg.description:
|
||||||
|
updated_fields.append("description")
|
||||||
|
|
||||||
|
new_model = model if model is not None else existing_cfg.model
|
||||||
|
if new_model is not None:
|
||||||
|
config_data["model"] = new_model
|
||||||
|
if model is not None and model != existing_cfg.model:
|
||||||
|
updated_fields.append("model")
|
||||||
|
|
||||||
|
new_tool_groups = tool_groups if tool_groups is not None else existing_cfg.tool_groups
|
||||||
|
if new_tool_groups is not None:
|
||||||
|
config_data["tool_groups"] = new_tool_groups
|
||||||
|
if tool_groups is not None and tool_groups != existing_cfg.tool_groups:
|
||||||
|
updated_fields.append("tool_groups")
|
||||||
|
|
||||||
|
new_skills = skills if skills is not None else existing_cfg.skills
|
||||||
|
if new_skills is not None:
|
||||||
|
config_data["skills"] = new_skills
|
||||||
|
if skills is not None and skills != existing_cfg.skills:
|
||||||
|
updated_fields.append("skills")
|
||||||
|
|
||||||
|
config_changed = bool({"description", "model", "tool_groups", "skills"} & set(updated_fields))
|
||||||
|
|
||||||
|
# Stage every file we intend to rewrite into a temp sibling. Only after
|
||||||
|
# *all* temp files exist do we rename them into place — so a failure on
|
||||||
|
# SOUL.md cannot leave config.yaml already replaced.
|
||||||
|
pending: list[tuple[Path, Path]] = []
|
||||||
|
staged_temps: list[Path] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if config_changed:
|
||||||
|
yaml_text = yaml.dump(config_data, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||||
|
config_target = agent_dir / "config.yaml"
|
||||||
|
config_tmp = _stage_temp(config_target, yaml_text)
|
||||||
|
staged_temps.append(config_tmp)
|
||||||
|
pending.append((config_tmp, config_target))
|
||||||
|
|
||||||
|
if soul is not None:
|
||||||
|
soul_target = agent_dir / "SOUL.md"
|
||||||
|
soul_tmp = _stage_temp(soul_target, soul)
|
||||||
|
staged_temps.append(soul_tmp)
|
||||||
|
pending.append((soul_tmp, soul_target))
|
||||||
|
updated_fields.append("soul")
|
||||||
|
|
||||||
|
# Commit phase. ``Path.replace`` is atomic per file on POSIX/NTFS and
|
||||||
|
# the staging step above means any earlier failure has already been
|
||||||
|
# reported. The remaining failure mode is a crash *between* two
|
||||||
|
# ``replace`` calls, which is reported via the partial-write error
|
||||||
|
# branch below so the caller knows which files are now on disk.
|
||||||
|
committed: list[Path] = []
|
||||||
|
try:
|
||||||
|
for tmp, target in pending:
|
||||||
|
tmp.replace(target)
|
||||||
|
committed.append(target)
|
||||||
|
except Exception as e:
|
||||||
|
_cleanup_temps([t for t, _ in pending if t not in committed])
|
||||||
|
if committed:
|
||||||
|
logger.error(
|
||||||
|
"[update_agent] Partial write for agent '%s' (user=%s): committed=%s, failed during rename: %s",
|
||||||
|
agent_name,
|
||||||
|
user_id,
|
||||||
|
[p.name for p in committed],
|
||||||
|
e,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return _err(f"Partial update for agent '{agent_name}': {[p.name for p in committed]} were updated, but the rest failed ({e}). Re-run update_agent to retry the remaining fields.")
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_cleanup_temps(staged_temps)
|
||||||
|
logger.error("[update_agent] Failed to update agent '%s' (user=%s): %s", agent_name, user_id, e, exc_info=True)
|
||||||
|
return _err(f"Failed to update agent '{agent_name}': {e}")
|
||||||
|
|
||||||
|
if not updated_fields:
|
||||||
|
return Command(update={"messages": [ToolMessage(content=f"No changes applied to agent '{agent_name}'. The provided values matched the existing config.", tool_call_id=tool_call_id)]})
|
||||||
|
|
||||||
|
logger.info("[update_agent] Updated agent '%s' (user=%s) fields: %s", agent_name, user_id, updated_fields)
|
||||||
|
return Command(
|
||||||
|
update={
|
||||||
|
"messages": [
|
||||||
|
ToolMessage(
|
||||||
|
content=(f"Agent '{agent_name}' updated successfully. Changed: {', '.join(updated_fields)}. The new configuration takes effect on the next user turn."),
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -3,13 +3,13 @@ import mimetypes
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from langchain.tools import InjectedToolCallId, ToolRuntime, tool
|
from langchain.tools import InjectedToolCallId, tool
|
||||||
from langchain_core.messages import ToolMessage
|
from langchain_core.messages import ToolMessage
|
||||||
from langgraph.types import Command
|
from langgraph.types import Command
|
||||||
from langgraph.typing import ContextT
|
|
||||||
|
|
||||||
from deerflow.agents.thread_state import ThreadDataState, ThreadState
|
from deerflow.agents.thread_state import ThreadDataState
|
||||||
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
|
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
|
||||||
|
from deerflow.tools.types import Runtime
|
||||||
|
|
||||||
_ALLOWED_IMAGE_VIRTUAL_ROOTS = (
|
_ALLOWED_IMAGE_VIRTUAL_ROOTS = (
|
||||||
f"{VIRTUAL_PATH_PREFIX}/workspace",
|
f"{VIRTUAL_PATH_PREFIX}/workspace",
|
||||||
@@ -48,7 +48,7 @@ def _sanitize_image_error(error: Exception, thread_data: ThreadDataState | None)
|
|||||||
|
|
||||||
@tool("view_image", parse_docstring=True)
|
@tool("view_image", parse_docstring=True)
|
||||||
def view_image_tool(
|
def view_image_tool(
|
||||||
runtime: ToolRuntime[ContextT, ThreadState],
|
runtime: Runtime,
|
||||||
image_path: str,
|
image_path: str,
|
||||||
tool_call_id: Annotated[str, InjectedToolCallId],
|
tool_call_id: Annotated[str, InjectedToolCallId],
|
||||||
) -> Command:
|
) -> Command:
|
||||||
|
|||||||
@@ -7,16 +7,15 @@ import logging
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from weakref import WeakValueDictionary
|
from weakref import WeakValueDictionary
|
||||||
|
|
||||||
from langchain.tools import ToolRuntime, tool
|
from langchain.tools import tool
|
||||||
from langgraph.typing import ContextT
|
|
||||||
|
|
||||||
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||||
from deerflow.agents.thread_state import ThreadState
|
|
||||||
from deerflow.mcp.tools import _make_sync_tool_wrapper
|
|
||||||
from deerflow.skills.security_scanner import scan_skill_content
|
from deerflow.skills.security_scanner import scan_skill_content
|
||||||
from deerflow.skills.storage import get_or_new_skill_storage
|
from deerflow.skills.storage import get_or_new_skill_storage
|
||||||
from deerflow.skills.storage.skill_storage import SkillStorage
|
from deerflow.skills.storage.skill_storage import SkillStorage
|
||||||
from deerflow.skills.types import SKILL_MD_FILE
|
from deerflow.skills.types import SKILL_MD_FILE
|
||||||
|
from deerflow.tools.sync import make_sync_tool_wrapper
|
||||||
|
from deerflow.tools.types import Runtime
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -31,7 +30,7 @@ def _get_lock(name: str) -> asyncio.Lock:
|
|||||||
return lock
|
return lock
|
||||||
|
|
||||||
|
|
||||||
def _get_thread_id(runtime: ToolRuntime[ContextT, ThreadState] | None) -> str | None:
|
def _get_thread_id(runtime: Runtime | None) -> str | None:
|
||||||
if runtime is None:
|
if runtime is None:
|
||||||
return None
|
return None
|
||||||
if runtime.context and runtime.context.get("thread_id"):
|
if runtime.context and runtime.context.get("thread_id"):
|
||||||
@@ -65,7 +64,7 @@ async def _to_thread(func, /, *args, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
async def _skill_manage_impl(
|
async def _skill_manage_impl(
|
||||||
runtime: ToolRuntime[ContextT, ThreadState],
|
runtime: Runtime,
|
||||||
action: str,
|
action: str,
|
||||||
name: str,
|
name: str,
|
||||||
content: str | None = None,
|
content: str | None = None,
|
||||||
@@ -204,7 +203,7 @@ async def _skill_manage_impl(
|
|||||||
|
|
||||||
@tool("skill_manage", parse_docstring=True)
|
@tool("skill_manage", parse_docstring=True)
|
||||||
async def skill_manage_tool(
|
async def skill_manage_tool(
|
||||||
runtime: ToolRuntime[ContextT, ThreadState],
|
runtime: Runtime,
|
||||||
action: str,
|
action: str,
|
||||||
name: str,
|
name: str,
|
||||||
content: str | None = None,
|
content: str | None = None,
|
||||||
@@ -236,4 +235,4 @@ async def skill_manage_tool(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
skill_manage_tool.func = _make_sync_tool_wrapper(_skill_manage_impl, "skill_manage")
|
skill_manage_tool.func = make_sync_tool_wrapper(_skill_manage_impl, "skill_manage")
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Utilities for invoking async tools from synchronous agent paths."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import atexit
|
||||||
|
import concurrent.futures
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Shared thread pool for sync tool invocation in async environments.
|
||||||
|
_SYNC_TOOL_EXECUTOR = concurrent.futures.ThreadPoolExecutor(max_workers=10, thread_name_prefix="tool-sync")
|
||||||
|
|
||||||
|
atexit.register(lambda: _SYNC_TOOL_EXECUTOR.shutdown(wait=False))
|
||||||
|
|
||||||
|
|
||||||
|
def make_sync_tool_wrapper(coro: Callable[..., Any], tool_name: str) -> Callable[..., Any]:
|
||||||
|
"""Build a synchronous wrapper for an asynchronous tool coroutine."""
|
||||||
|
|
||||||
|
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
loop = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if loop is not None and loop.is_running():
|
||||||
|
future = _SYNC_TOOL_EXECUTOR.submit(asyncio.run, coro(*args, **kwargs))
|
||||||
|
return future.result()
|
||||||
|
return asyncio.run(coro(*args, **kwargs))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error invoking tool %r via sync wrapper: %s", tool_name, e, exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
return sync_wrapper
|
||||||
@@ -7,7 +7,8 @@ from deerflow.config.app_config import AppConfig
|
|||||||
from deerflow.reflection import resolve_variable
|
from deerflow.reflection import resolve_variable
|
||||||
from deerflow.sandbox.security import is_host_bash_allowed
|
from deerflow.sandbox.security import is_host_bash_allowed
|
||||||
from deerflow.tools.builtins import ask_clarification_tool, present_file_tool, task_tool, view_image_tool
|
from deerflow.tools.builtins import ask_clarification_tool, present_file_tool, task_tool, view_image_tool
|
||||||
from deerflow.tools.builtins.tool_search import reset_deferred_registry
|
from deerflow.tools.builtins.tool_search import get_deferred_registry
|
||||||
|
from deerflow.tools.sync import make_sync_tool_wrapper
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -33,6 +34,13 @@ def _is_host_bash_tool(tool: object) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_sync_invocable_tool(tool: BaseTool) -> BaseTool:
|
||||||
|
"""Attach a sync wrapper to async-only tools used by sync agent callers."""
|
||||||
|
if getattr(tool, "func", None) is None and getattr(tool, "coroutine", None) is not None:
|
||||||
|
tool.func = make_sync_tool_wrapper(tool.coroutine, tool.name)
|
||||||
|
return tool
|
||||||
|
|
||||||
|
|
||||||
def get_available_tools(
|
def get_available_tools(
|
||||||
groups: list[str] | None = None,
|
groups: list[str] | None = None,
|
||||||
include_mcp: bool = True,
|
include_mcp: bool = True,
|
||||||
@@ -77,7 +85,7 @@ def get_available_tools(
|
|||||||
cfg.use,
|
cfg.use,
|
||||||
)
|
)
|
||||||
|
|
||||||
loaded_tools = [t for _, t in loaded_tools_raw]
|
loaded_tools = [_ensure_sync_invocable_tool(t) for _, t in loaded_tools_raw]
|
||||||
|
|
||||||
# Conditionally add tools based on config
|
# Conditionally add tools based on config
|
||||||
builtin_tools = BUILTIN_TOOLS.copy()
|
builtin_tools = BUILTIN_TOOLS.copy()
|
||||||
@@ -108,8 +116,6 @@ def get_available_tools(
|
|||||||
# made through the Gateway API (which runs in a separate process) are immediately
|
# made through the Gateway API (which runs in a separate process) are immediately
|
||||||
# reflected when loading MCP tools.
|
# reflected when loading MCP tools.
|
||||||
mcp_tools = []
|
mcp_tools = []
|
||||||
# Reset deferred registry upfront to prevent stale state from previous calls
|
|
||||||
reset_deferred_registry()
|
|
||||||
if include_mcp:
|
if include_mcp:
|
||||||
try:
|
try:
|
||||||
from deerflow.config.extensions_config import ExtensionsConfig
|
from deerflow.config.extensions_config import ExtensionsConfig
|
||||||
@@ -127,12 +133,51 @@ def get_available_tools(
|
|||||||
from deerflow.tools.builtins.tool_search import DeferredToolRegistry, set_deferred_registry
|
from deerflow.tools.builtins.tool_search import DeferredToolRegistry, set_deferred_registry
|
||||||
from deerflow.tools.builtins.tool_search import tool_search as tool_search_tool
|
from deerflow.tools.builtins.tool_search import tool_search as tool_search_tool
|
||||||
|
|
||||||
registry = DeferredToolRegistry()
|
# Reuse the existing registry if one is already set for
|
||||||
for t in mcp_tools:
|
# this async context. ``get_available_tools`` is
|
||||||
registry.register(t)
|
# re-entered whenever a subagent is spawned
|
||||||
set_deferred_registry(registry)
|
# (``task_tool`` calls it to build the child agent's
|
||||||
|
# toolset), and previously we used to unconditionally
|
||||||
|
# rebuild the registry — wiping out the parent agent's
|
||||||
|
# tool_search promotions. The
|
||||||
|
# ``DeferredToolFilterMiddleware`` then re-hid those
|
||||||
|
# tools from subsequent model calls, leaving the agent
|
||||||
|
# able to see a tool's name but unable to invoke it
|
||||||
|
# (issue #2884). ``contextvars`` already gives us the
|
||||||
|
# lifetime semantics we want: a fresh request / graph
|
||||||
|
# run starts in a new asyncio task with the
|
||||||
|
# ContextVar at its default of ``None``, so reuse is
|
||||||
|
# only triggered for re-entrant calls inside one run.
|
||||||
|
#
|
||||||
|
# Intentionally NOT reconciling against the current
|
||||||
|
# ``mcp_tools`` snapshot. The MCP cache only refreshes
|
||||||
|
# on ``extensions_config.json`` mtime changes, which
|
||||||
|
# in practice happens between graph runs — not inside
|
||||||
|
# one. And even if a refresh did happen mid-run, the
|
||||||
|
# already-built lead agent's ``ToolNode`` still holds
|
||||||
|
# the *previous* tool set (LangGraph binds tools at
|
||||||
|
# graph construction time), so a brand-new MCP tool
|
||||||
|
# couldn't actually be invoked anyway. The
|
||||||
|
# ``DeferredToolRegistry`` doesn't retain the names
|
||||||
|
# of previously-promoted tools (``promote()`` drops
|
||||||
|
# the entry entirely), so re-syncing the registry
|
||||||
|
# against a fresh ``mcp_tools`` list would
|
||||||
|
# mis-classify those promotions as new tools and
|
||||||
|
# re-register them as deferred — exactly the bug
|
||||||
|
# this fix exists to prevent.
|
||||||
|
existing_registry = get_deferred_registry()
|
||||||
|
if existing_registry is None:
|
||||||
|
registry = DeferredToolRegistry()
|
||||||
|
for t in mcp_tools:
|
||||||
|
registry.register(t)
|
||||||
|
set_deferred_registry(registry)
|
||||||
|
logger.info(f"Tool search active: {len(mcp_tools)} tools deferred")
|
||||||
|
else:
|
||||||
|
mcp_tool_names = {t.name for t in mcp_tools}
|
||||||
|
still_deferred = len(existing_registry)
|
||||||
|
promoted_count = max(0, len(mcp_tool_names) - still_deferred)
|
||||||
|
logger.info(f"Tool search active (preserved promotions): {still_deferred} tools deferred, {promoted_count} already promoted")
|
||||||
builtin_tools.append(tool_search_tool)
|
builtin_tools.append(tool_search_tool)
|
||||||
logger.info(f"Tool search active: {len(mcp_tools)} tools deferred")
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("MCP module not available. Install 'langchain-mcp-adapters' package to enable MCP tools.")
|
logger.warning("MCP module not available. Install 'langchain-mcp-adapters' package to enable MCP tools.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langchain.tools import ToolRuntime
|
||||||
|
|
||||||
|
from deerflow.agents.thread_state import ThreadState
|
||||||
|
|
||||||
|
# Concrete runtime type used by all DeerFlow tools.
|
||||||
|
# Using dict[str, Any] for the context parameter instead of the unbound ContextT
|
||||||
|
# TypeVar prevents PydanticSerializationUnexpectedValue warnings when LangChain
|
||||||
|
# calls model_dump() on a tool's auto-generated args_schema.
|
||||||
|
Runtime = ToolRuntime[dict[str, Any], ThreadState]
|
||||||
@@ -121,9 +121,11 @@ def open_upload_file_no_symlink(base_dir: Path, filename: str) -> tuple[Path, ob
|
|||||||
Upload directories may be mounted into local sandboxes. A sandbox process can
|
Upload directories may be mounted into local sandboxes. A sandbox process can
|
||||||
therefore leave a symlink at a future upload filename. Normal ``Path.write_bytes``
|
therefore leave a symlink at a future upload filename. Normal ``Path.write_bytes``
|
||||||
follows that link and can overwrite files outside the uploads directory with
|
follows that link and can overwrite files outside the uploads directory with
|
||||||
gateway privileges. This helper rejects symlink destinations and uses
|
gateway privileges. This helper rejects symlink destinations using ``O_NOFOLLOW``
|
||||||
``O_NOFOLLOW`` where available so the final path component cannot be raced into
|
on POSIX. On Windows (which lacks ``O_NOFOLLOW``), it uses dual ``lstat`` checks
|
||||||
a symlink between validation and open.
|
and ``fstat`` validation after ``open()`` to reduce the TOCTOU window; this does
|
||||||
|
not eliminate all races but makes exploitation significantly harder. Path-traversal
|
||||||
|
validation prevents escapes from *base_dir* in both cases.
|
||||||
"""
|
"""
|
||||||
safe_name = normalize_filename(filename)
|
safe_name = normalize_filename(filename)
|
||||||
dest = base_dir / safe_name
|
dest = base_dir / safe_name
|
||||||
@@ -138,23 +140,65 @@ def open_upload_file_no_symlink(base_dir: Path, filename: str) -> tuple[Path, ob
|
|||||||
|
|
||||||
validate_path_traversal(dest, base_dir)
|
validate_path_traversal(dest, base_dir)
|
||||||
|
|
||||||
if not hasattr(os, "O_NOFOLLOW"):
|
has_nofollow = hasattr(os, "O_NOFOLLOW")
|
||||||
raise UnsafeUploadPathError("Upload writes require O_NOFOLLOW support")
|
|
||||||
|
|
||||||
flags = os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW
|
if has_nofollow:
|
||||||
if hasattr(os, "O_NONBLOCK"):
|
# POSIX: O_NOFOLLOW makes open() fail with ELOOP if dest is a symlink.
|
||||||
flags |= os.O_NONBLOCK
|
flags = os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW
|
||||||
|
if hasattr(os, "O_NONBLOCK"):
|
||||||
|
flags |= os.O_NONBLOCK
|
||||||
|
|
||||||
|
try:
|
||||||
|
fd = os.open(dest, flags, 0o600)
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno in {errno.ELOOP, errno.EISDIR, errno.ENOTDIR, errno.ENXIO, errno.EAGAIN}:
|
||||||
|
raise UnsafeUploadPathError(f"Unsafe upload destination: {safe_name}") from exc
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
opened_stat = os.fstat(fd)
|
||||||
|
if not stat.S_ISREG(opened_stat.st_mode) or opened_stat.st_nlink != 1:
|
||||||
|
raise UnsafeUploadPathError(f"Upload destination is not an exclusive regular file: {safe_name}")
|
||||||
|
os.ftruncate(fd, 0)
|
||||||
|
fh = os.fdopen(fd, "wb")
|
||||||
|
fd = -1
|
||||||
|
finally:
|
||||||
|
if fd >= 0:
|
||||||
|
os.close(fd)
|
||||||
|
return dest, fh
|
||||||
|
|
||||||
|
# Windows: no O_NOFOLLOW available. Uses a second lstat immediately before open()
|
||||||
|
# to narrow the TOCTOU window, then fstat after open() as a further defence.
|
||||||
|
# Note: a narrow race window remains between the pre-open lstat and open(); the
|
||||||
|
# path-traversal check mitigates escapes from base_dir but cannot prevent an
|
||||||
|
# attacker who can atomically replace dest with a symlink after the check.
|
||||||
|
if st is not None and st.st_nlink > 1:
|
||||||
|
raise UnsafeUploadPathError(f"Upload destination has multiple links: {safe_name}")
|
||||||
|
|
||||||
|
flags = os.O_WRONLY | os.O_CREAT
|
||||||
|
if hasattr(os, "O_BINARY"):
|
||||||
|
flags |= os.O_BINARY
|
||||||
|
|
||||||
|
try:
|
||||||
|
pre_open_st = os.lstat(dest)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pre_open_st = None
|
||||||
|
|
||||||
|
if pre_open_st is not None and not stat.S_ISREG(pre_open_st.st_mode):
|
||||||
|
raise UnsafeUploadPathError(f"Upload destination is not a regular file: {safe_name}")
|
||||||
|
if pre_open_st is not None and pre_open_st.st_nlink > 1:
|
||||||
|
raise UnsafeUploadPathError(f"Upload destination has multiple links: {safe_name}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
fd = os.open(dest, flags, 0o600)
|
fd = os.open(dest, flags, 0o600)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
if exc.errno in {errno.ELOOP, errno.EISDIR, errno.ENOTDIR, errno.ENXIO, errno.EAGAIN}:
|
if exc.errno in {errno.EISDIR, errno.ENOTDIR, errno.ENXIO, errno.EAGAIN}:
|
||||||
raise UnsafeUploadPathError(f"Unsafe upload destination: {safe_name}") from exc
|
raise UnsafeUploadPathError(f"Unsafe upload destination: {safe_name}") from exc
|
||||||
raise
|
raise
|
||||||
|
|
||||||
try:
|
try:
|
||||||
opened_stat = os.fstat(fd)
|
opened_stat = os.fstat(fd)
|
||||||
if not stat.S_ISREG(opened_stat.st_mode) or opened_stat.st_nlink != 1:
|
if not stat.S_ISREG(opened_stat.st_mode) or opened_stat.st_nlink > 1:
|
||||||
raise UnsafeUploadPathError(f"Upload destination is not an exclusive regular file: {safe_name}")
|
raise UnsafeUploadPathError(f"Upload destination is not an exclusive regular file: {safe_name}")
|
||||||
os.ftruncate(fd, 0)
|
os.ftruncate(fd, 0)
|
||||||
fh = os.fdopen(fd, "wb")
|
fh = os.fdopen(fd, "wb")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ dependencies = [
|
|||||||
"deerflow-harness",
|
"deerflow-harness",
|
||||||
"fastapi>=0.115.0",
|
"fastapi>=0.115.0",
|
||||||
"httpx>=0.28.0",
|
"httpx>=0.28.0",
|
||||||
"python-multipart>=0.0.26",
|
"python-multipart>=0.0.27",
|
||||||
"sse-starlette>=2.1.0",
|
"sse-starlette>=2.1.0",
|
||||||
"uvicorn[standard]>=0.34.0",
|
"uvicorn[standard]>=0.34.0",
|
||||||
"lark-oapi>=1.4.0",
|
"lark-oapi>=1.4.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""One-time migration: move legacy thread dirs and memory into per-user layout.
|
"""One-time migration: move legacy thread dirs and memory into per-user layout.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
PYTHONPATH=. python scripts/migrate_user_isolation.py [--dry-run]
|
PYTHONPATH=. python scripts/migrate_user_isolation.py [--dry-run] [--user-id USER_ID]
|
||||||
|
|
||||||
The script is idempotent — re-running it after a successful migration is a no-op.
|
The script is idempotent — re-running it after a successful migration is a no-op.
|
||||||
"""
|
"""
|
||||||
@@ -69,6 +69,67 @@ def migrate_thread_dirs(
|
|||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_agents(
|
||||||
|
paths: Paths,
|
||||||
|
user_id: str = "default",
|
||||||
|
*,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Move legacy custom-agent directories into per-user layout.
|
||||||
|
|
||||||
|
Legacy layout: ``{base_dir}/agents/{name}/``
|
||||||
|
Per-user layout: ``{base_dir}/users/{user_id}/agents/{name}/``
|
||||||
|
|
||||||
|
Pre-existing per-user agents take precedence: if a destination already
|
||||||
|
exists for an agent name, the legacy copy is moved to
|
||||||
|
``{base_dir}/migration-conflicts/agents/{name}/`` for manual review.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
paths: Paths instance.
|
||||||
|
user_id: Target user to receive the legacy agents (defaults to
|
||||||
|
``"default"``, matching ``DEFAULT_USER_ID`` for no-auth setups).
|
||||||
|
dry_run: If True, only log what would happen.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of migration report entries, one per legacy agent directory found.
|
||||||
|
"""
|
||||||
|
report: list[dict] = []
|
||||||
|
legacy_agents = paths.agents_dir
|
||||||
|
if not legacy_agents.exists():
|
||||||
|
logger.info("No legacy agents directory found — nothing to migrate.")
|
||||||
|
return report
|
||||||
|
|
||||||
|
for agent_dir in sorted(legacy_agents.iterdir()):
|
||||||
|
if not agent_dir.is_dir():
|
||||||
|
continue
|
||||||
|
agent_name = agent_dir.name
|
||||||
|
dest = paths.user_agent_dir(user_id, agent_name)
|
||||||
|
|
||||||
|
entry = {"agent": agent_name, "user_id": user_id, "action": ""}
|
||||||
|
|
||||||
|
if dest.exists():
|
||||||
|
conflicts_dir = paths.base_dir / "migration-conflicts" / "agents" / agent_name
|
||||||
|
entry["action"] = f"conflict -> {conflicts_dir}"
|
||||||
|
if not dry_run:
|
||||||
|
conflicts_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.move(str(agent_dir), str(conflicts_dir))
|
||||||
|
logger.warning("Conflict for agent %s: moved legacy copy to %s", agent_name, conflicts_dir)
|
||||||
|
else:
|
||||||
|
entry["action"] = f"moved -> {dest}"
|
||||||
|
if not dry_run:
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.move(str(agent_dir), str(dest))
|
||||||
|
logger.info("Migrated agent %s -> user %s", agent_name, user_id)
|
||||||
|
|
||||||
|
report.append(entry)
|
||||||
|
|
||||||
|
# Clean up empty legacy agents dir
|
||||||
|
if not dry_run and legacy_agents.exists() and not any(legacy_agents.iterdir()):
|
||||||
|
legacy_agents.rmdir()
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
def migrate_memory(
|
def migrate_memory(
|
||||||
paths: Paths,
|
paths: Paths,
|
||||||
user_id: str = "default",
|
user_id: str = "default",
|
||||||
@@ -127,6 +188,12 @@ def _build_owner_map_from_db(paths: Paths) -> dict[str, str]:
|
|||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description="Migrate DeerFlow data to per-user layout")
|
parser = argparse.ArgumentParser(description="Migrate DeerFlow data to per-user layout")
|
||||||
parser.add_argument("--dry-run", action="store_true", help="Log actions without making changes")
|
parser.add_argument("--dry-run", action="store_true", help="Log actions without making changes")
|
||||||
|
parser.add_argument(
|
||||||
|
"--user-id",
|
||||||
|
default="default",
|
||||||
|
metavar="USER_ID",
|
||||||
|
help=("User ID to claim un-owned legacy data (global memory.json and legacy custom agents). Defaults to 'default'. In multi-user installs, set this to the operator account that should inherit those legacy artifacts."),
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||||
@@ -134,26 +201,42 @@ def main() -> None:
|
|||||||
paths = get_paths()
|
paths = get_paths()
|
||||||
logger.info("Base directory: %s", paths.base_dir)
|
logger.info("Base directory: %s", paths.base_dir)
|
||||||
logger.info("Dry run: %s", args.dry_run)
|
logger.info("Dry run: %s", args.dry_run)
|
||||||
|
logger.info("Claiming un-owned legacy data for user_id=%s", args.user_id)
|
||||||
|
|
||||||
owner_map = _build_owner_map_from_db(paths)
|
owner_map = _build_owner_map_from_db(paths)
|
||||||
logger.info("Found %d thread ownership records in DB", len(owner_map))
|
logger.info("Found %d thread ownership records in DB", len(owner_map))
|
||||||
|
|
||||||
report = migrate_thread_dirs(paths, owner_map, dry_run=args.dry_run)
|
report = migrate_thread_dirs(paths, owner_map, dry_run=args.dry_run)
|
||||||
migrate_memory(paths, user_id="default", dry_run=args.dry_run)
|
migrate_memory(paths, user_id=args.user_id, dry_run=args.dry_run)
|
||||||
|
agent_report = migrate_agents(paths, user_id=args.user_id, dry_run=args.dry_run)
|
||||||
|
|
||||||
if report:
|
if report:
|
||||||
logger.info("Migration report:")
|
logger.info("Thread migration report:")
|
||||||
for entry in report:
|
for entry in report:
|
||||||
logger.info(" thread=%s user=%s action=%s", entry["thread_id"], entry["user_id"], entry["action"])
|
logger.info(" thread=%s user=%s action=%s", entry["thread_id"], entry["user_id"], entry["action"])
|
||||||
else:
|
else:
|
||||||
logger.info("No threads to migrate.")
|
logger.info("No threads to migrate.")
|
||||||
|
|
||||||
|
if agent_report:
|
||||||
|
logger.info("Agent migration report:")
|
||||||
|
for entry in agent_report:
|
||||||
|
logger.info(" agent=%s user=%s action=%s", entry["agent"], entry["user_id"], entry["action"])
|
||||||
|
else:
|
||||||
|
logger.info("No agents to migrate.")
|
||||||
|
|
||||||
unowned = [e for e in report if e["user_id"] == "default"]
|
unowned = [e for e in report if e["user_id"] == "default"]
|
||||||
if unowned:
|
if unowned:
|
||||||
logger.warning("%d thread(s) had no owner and were assigned to 'default':", len(unowned))
|
logger.warning("%d thread(s) had no owner and were assigned to 'default':", len(unowned))
|
||||||
for e in unowned:
|
for e in unowned:
|
||||||
logger.warning(" %s", e["thread_id"])
|
logger.warning(" %s", e["thread_id"])
|
||||||
|
|
||||||
|
if agent_report:
|
||||||
|
logger.warning(
|
||||||
|
"%d legacy agent(s) were assigned to '%s'. If those agents belonged to other users, move them manually under {base_dir}/users/<user_id>/agents/.",
|
||||||
|
len(agent_report),
|
||||||
|
args.user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Shared helpers for user-isolation e2e tests on the custom-agent tooling.
|
||||||
|
|
||||||
|
Centralises the small fake-LLM shim and a few test-data builders that the
|
||||||
|
three e2e files in this PR (``test_setup_agent_e2e_user_isolation``,
|
||||||
|
``test_update_agent_e2e_user_isolation``, ``test_setup_agent_http_e2e_real_server``)
|
||||||
|
all need. The shim is what lets a real ``langchain.agents.create_agent``
|
||||||
|
graph run without an API key — every other layer in those tests is real
|
||||||
|
production code, which is the entire point of the test design.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
|
||||||
|
from langchain_core.messages import AIMessage
|
||||||
|
from langchain_core.runnables import Runnable
|
||||||
|
|
||||||
|
|
||||||
|
class FakeToolCallingModel(FakeMessagesListChatModel):
|
||||||
|
"""FakeMessagesListChatModel plus a no-op ``bind_tools`` for create_agent.
|
||||||
|
|
||||||
|
``langchain.agents.create_agent`` calls ``model.bind_tools(...)`` to
|
||||||
|
expose the tool schemas to the model; the upstream fake raises
|
||||||
|
``NotImplementedError`` there. We just return ``self`` because we
|
||||||
|
drive deterministic tool_call output via ``responses=...``, no schema
|
||||||
|
handling needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def bind_tools( # type: ignore[override]
|
||||||
|
self,
|
||||||
|
tools: Any,
|
||||||
|
*,
|
||||||
|
tool_choice: Any = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> Runnable:
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def build_single_tool_call_model(
|
||||||
|
*,
|
||||||
|
tool_name: str,
|
||||||
|
tool_args: dict[str, Any],
|
||||||
|
tool_call_id: str = "call_e2e_1",
|
||||||
|
final_text: str = "done",
|
||||||
|
) -> FakeToolCallingModel:
|
||||||
|
"""Build a fake model that emits exactly one tool_call then finishes.
|
||||||
|
|
||||||
|
Two-turn behaviour, identical across our e2e tests:
|
||||||
|
turn 1 → AIMessage with a single tool_call for *tool_name*
|
||||||
|
turn 2 → AIMessage with *final_text* (terminates the agent loop)
|
||||||
|
"""
|
||||||
|
return FakeToolCallingModel(
|
||||||
|
responses=[
|
||||||
|
AIMessage(
|
||||||
|
content="",
|
||||||
|
tool_calls=[
|
||||||
|
{
|
||||||
|
"name": tool_name,
|
||||||
|
"args": tool_args,
|
||||||
|
"id": tool_call_id,
|
||||||
|
"type": "tool_call",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
),
|
||||||
|
AIMessage(content=final_text),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -4,6 +4,8 @@ Sets up sys.path and pre-mocks modules that would cause circular import
|
|||||||
issues when unit-testing lightweight config/registry code in isolation.
|
issues when unit-testing lightweight config/registry code in isolation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -11,11 +13,16 @@ from types import SimpleNamespace
|
|||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from support.detectors.blocking_io import BlockingIOProbe, detect_blocking_io
|
||||||
|
|
||||||
# Make 'app' and 'deerflow' importable from any working directory
|
# Make 'app' and 'deerflow' importable from any working directory
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts"))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts"))
|
||||||
|
|
||||||
|
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
_blocking_io_probe = BlockingIOProbe(_BACKEND_ROOT)
|
||||||
|
_BLOCKING_IO_DETECTOR_ATTR = "_blocking_io_detector"
|
||||||
|
|
||||||
# Break the circular import chain that exists in production code:
|
# Break the circular import chain that exists in production code:
|
||||||
# deerflow.subagents.__init__
|
# deerflow.subagents.__init__
|
||||||
# -> .executor (SubagentExecutor, SubagentResult)
|
# -> .executor (SubagentExecutor, SubagentResult)
|
||||||
@@ -56,6 +63,92 @@ def provisioner_module():
|
|||||||
return module
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def blocking_io_detector():
|
||||||
|
"""Fail a focused test if blocking calls run on the event loop thread."""
|
||||||
|
with detect_blocking_io(fail_on_exit=True) as detector:
|
||||||
|
yield detector
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_addoption(parser: pytest.Parser) -> None:
|
||||||
|
group = parser.getgroup("blocking-io")
|
||||||
|
group.addoption(
|
||||||
|
"--detect-blocking-io",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Collect blocking calls made while an asyncio event loop is running and report a summary.",
|
||||||
|
)
|
||||||
|
group.addoption(
|
||||||
|
"--detect-blocking-io-fail",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Set a failing exit status when --detect-blocking-io records violations.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config: pytest.Config) -> None:
|
||||||
|
config.addinivalue_line("markers", "no_blocking_io_probe: skip the optional blocking IO probe")
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_sessionstart(session: pytest.Session) -> None:
|
||||||
|
if _blocking_io_probe_enabled(session.config):
|
||||||
|
_blocking_io_probe.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.hookimpl(hookwrapper=True)
|
||||||
|
def pytest_runtest_call(item: pytest.Item):
|
||||||
|
if not _blocking_io_probe_enabled(item.config) or _blocking_io_probe_skipped(item):
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
detector = detect_blocking_io(fail_on_exit=False, stack_limit=18)
|
||||||
|
detector.__enter__()
|
||||||
|
setattr(item, _BLOCKING_IO_DETECTOR_ATTR, detector)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.hookimpl(hookwrapper=True)
|
||||||
|
def pytest_runtest_teardown(item: pytest.Item):
|
||||||
|
yield
|
||||||
|
|
||||||
|
detector = getattr(item, _BLOCKING_IO_DETECTOR_ATTR, None)
|
||||||
|
if detector is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
detector.__exit__(None, None, None)
|
||||||
|
_blocking_io_probe.record(item.nodeid, detector.violations)
|
||||||
|
finally:
|
||||||
|
delattr(item, _BLOCKING_IO_DETECTOR_ATTR)
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_sessionfinish(session: pytest.Session) -> None:
|
||||||
|
if _blocking_io_fail_enabled(session.config) and _blocking_io_probe.violation_count and session.exitstatus == pytest.ExitCode.OK:
|
||||||
|
session.exitstatus = pytest.ExitCode.TESTS_FAILED
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_terminal_summary(terminalreporter: pytest.TerminalReporter) -> None:
|
||||||
|
if not _blocking_io_probe_enabled(terminalreporter.config):
|
||||||
|
return
|
||||||
|
|
||||||
|
header, *details = _blocking_io_probe.format_summary().splitlines()
|
||||||
|
terminalreporter.write_sep("=", header)
|
||||||
|
for line in details:
|
||||||
|
terminalreporter.write_line(line)
|
||||||
|
|
||||||
|
|
||||||
|
def _blocking_io_probe_enabled(config: pytest.Config) -> bool:
|
||||||
|
return bool(config.getoption("--detect-blocking-io") or config.getoption("--detect-blocking-io-fail"))
|
||||||
|
|
||||||
|
|
||||||
|
def _blocking_io_fail_enabled(config: pytest.Config) -> bool:
|
||||||
|
return bool(config.getoption("--detect-blocking-io-fail"))
|
||||||
|
|
||||||
|
|
||||||
|
def _blocking_io_probe_skipped(item: pytest.Item) -> bool:
|
||||||
|
return item.path.name == "test_blocking_io_detector.py" or item.get_closest_marker("no_blocking_io_probe") is not None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Auto-set user context for every test unless marked no_auto_user
|
# Auto-set user context for every test unless marked no_auto_user
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Shared test support helpers."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Runtime and static detectors used by tests."""
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"""Test helper for detecting blocking calls on an asyncio event loop.
|
||||||
|
|
||||||
|
The detector is intentionally test-only. It monkeypatches a small set of
|
||||||
|
well-known blocking entry points and their already-loaded module-level aliases,
|
||||||
|
then records calls only when they happen on a thread that is currently running
|
||||||
|
an asyncio event loop. Aliases captured in closures or default arguments remain
|
||||||
|
out of scope.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
from collections import Counter
|
||||||
|
from collections.abc import Callable, Iterable, Iterator
|
||||||
|
from contextlib import AbstractContextManager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import wraps
|
||||||
|
from pathlib import Path
|
||||||
|
from types import TracebackType
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
BlockingCallable = Callable[..., Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BlockingCallSpec:
|
||||||
|
"""Describes one blocking callable to wrap during a detector run."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
target: str
|
||||||
|
record_on_iteration: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BlockingCall:
|
||||||
|
"""One blocking call observed on an asyncio event loop thread."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
target: str
|
||||||
|
stack: tuple[traceback.FrameSummary, ...]
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_BLOCKING_CALL_SPECS: tuple[BlockingCallSpec, ...] = (
|
||||||
|
BlockingCallSpec("time.sleep", "time:sleep"),
|
||||||
|
BlockingCallSpec("requests.Session.request", "requests.sessions:Session.request"),
|
||||||
|
BlockingCallSpec("httpx.Client.request", "httpx:Client.request"),
|
||||||
|
BlockingCallSpec("os.walk", "os:walk", record_on_iteration=True),
|
||||||
|
BlockingCallSpec("pathlib.Path.resolve", "pathlib:Path.resolve"),
|
||||||
|
BlockingCallSpec("pathlib.Path.read_text", "pathlib:Path.read_text"),
|
||||||
|
BlockingCallSpec("pathlib.Path.write_text", "pathlib:Path.write_text"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_event_loop_thread() -> bool:
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
return False
|
||||||
|
return loop.is_running()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_target(target: str) -> tuple[object, str, BlockingCallable]:
|
||||||
|
module_name, attr_path = target.split(":", maxsplit=1)
|
||||||
|
owner: object = importlib.import_module(module_name)
|
||||||
|
parts = attr_path.split(".")
|
||||||
|
for part in parts[:-1]:
|
||||||
|
owner = getattr(owner, part)
|
||||||
|
|
||||||
|
attr_name = parts[-1]
|
||||||
|
original = getattr(owner, attr_name)
|
||||||
|
return owner, attr_name, original
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_detector_frames(stack: Iterable[traceback.FrameSummary]) -> tuple[traceback.FrameSummary, ...]:
|
||||||
|
return tuple(frame for frame in stack if frame.filename != __file__)
|
||||||
|
|
||||||
|
|
||||||
|
class BlockingIODetector(AbstractContextManager["BlockingIODetector"]):
|
||||||
|
"""Record blocking calls made from async runtime code.
|
||||||
|
|
||||||
|
By default the detector reports violations but does not fail on context
|
||||||
|
exit. Tests can set ``fail_on_exit=True`` or call
|
||||||
|
``assert_no_blocking_calls()`` explicitly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
specs: Iterable[BlockingCallSpec] = DEFAULT_BLOCKING_CALL_SPECS,
|
||||||
|
*,
|
||||||
|
fail_on_exit: bool = False,
|
||||||
|
patch_loaded_aliases: bool = True,
|
||||||
|
stack_limit: int = 12,
|
||||||
|
) -> None:
|
||||||
|
self._specs = tuple(specs)
|
||||||
|
self._fail_on_exit = fail_on_exit
|
||||||
|
self._patch_loaded_aliases_enabled = patch_loaded_aliases
|
||||||
|
self._stack_limit = stack_limit
|
||||||
|
self._patches: list[tuple[object, str, BlockingCallable]] = []
|
||||||
|
self._patch_keys: set[tuple[int, str]] = set()
|
||||||
|
self.violations: list[BlockingCall] = []
|
||||||
|
self._active = False
|
||||||
|
|
||||||
|
def __enter__(self) -> BlockingIODetector:
|
||||||
|
try:
|
||||||
|
self._active = True
|
||||||
|
alias_replacements: dict[int, BlockingCallable] = {}
|
||||||
|
for spec in self._specs:
|
||||||
|
owner, attr_name, original = _resolve_target(spec.target)
|
||||||
|
wrapper = self._wrap(spec, original)
|
||||||
|
self._patch_attribute(owner, attr_name, original, wrapper)
|
||||||
|
alias_replacements[id(original)] = wrapper
|
||||||
|
|
||||||
|
if self._patch_loaded_aliases_enabled:
|
||||||
|
self._patch_loaded_module_aliases(alias_replacements)
|
||||||
|
except Exception:
|
||||||
|
self._restore()
|
||||||
|
self._active = False
|
||||||
|
raise
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(
|
||||||
|
self,
|
||||||
|
exc_type: type[BaseException] | None,
|
||||||
|
exc_value: BaseException | None,
|
||||||
|
traceback_value: TracebackType | None,
|
||||||
|
) -> bool | None:
|
||||||
|
self._restore()
|
||||||
|
self._active = False
|
||||||
|
if exc_type is None and self._fail_on_exit:
|
||||||
|
self.assert_no_blocking_calls()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _restore(self) -> None:
|
||||||
|
for owner, attr_name, original in reversed(self._patches):
|
||||||
|
setattr(owner, attr_name, original)
|
||||||
|
self._patches.clear()
|
||||||
|
self._patch_keys.clear()
|
||||||
|
|
||||||
|
def _patch_attribute(self, owner: object, attr_name: str, original: BlockingCallable, replacement: BlockingCallable) -> None:
|
||||||
|
key = (id(owner), attr_name)
|
||||||
|
if key in self._patch_keys:
|
||||||
|
return
|
||||||
|
setattr(owner, attr_name, replacement)
|
||||||
|
self._patches.append((owner, attr_name, original))
|
||||||
|
self._patch_keys.add(key)
|
||||||
|
|
||||||
|
def _patch_loaded_module_aliases(self, replacements_by_id: dict[int, BlockingCallable]) -> None:
|
||||||
|
for module in tuple(sys.modules.values()):
|
||||||
|
namespace = getattr(module, "__dict__", None)
|
||||||
|
if not isinstance(namespace, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for attr_name, value in tuple(namespace.items()):
|
||||||
|
replacement = replacements_by_id.get(id(value))
|
||||||
|
if replacement is not None:
|
||||||
|
self._patch_attribute(module, attr_name, value, replacement)
|
||||||
|
|
||||||
|
def _wrap(self, spec: BlockingCallSpec, original: BlockingCallable) -> BlockingCallable:
|
||||||
|
@wraps(original)
|
||||||
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
if spec.record_on_iteration:
|
||||||
|
result = original(*args, **kwargs)
|
||||||
|
return self._wrap_iteration(spec, result)
|
||||||
|
self._record_if_blocking(spec)
|
||||||
|
return original(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
def _wrap_iteration(self, spec: BlockingCallSpec, iterable: Iterable[Any]) -> Iterator[Any]:
|
||||||
|
iterator = iter(iterable)
|
||||||
|
reported = False
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if not reported:
|
||||||
|
reported = self._record_if_blocking(spec)
|
||||||
|
try:
|
||||||
|
yield next(iterator)
|
||||||
|
except StopIteration:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _record_if_blocking(self, spec: BlockingCallSpec) -> bool:
|
||||||
|
if self._active and _is_event_loop_thread():
|
||||||
|
stack = _trim_detector_frames(traceback.extract_stack(limit=self._stack_limit))
|
||||||
|
self.violations.append(BlockingCall(spec.name, spec.target, stack))
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def assert_no_blocking_calls(self) -> None:
|
||||||
|
if self.violations:
|
||||||
|
raise AssertionError(format_blocking_calls(self.violations))
|
||||||
|
|
||||||
|
|
||||||
|
class BlockingIOProbe:
|
||||||
|
"""Collect detector output across tests and format a compact summary."""
|
||||||
|
|
||||||
|
def __init__(self, project_root: Path) -> None:
|
||||||
|
self._project_root = project_root.resolve()
|
||||||
|
self._observed: list[tuple[str, BlockingCall]] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def violation_count(self) -> int:
|
||||||
|
return len(self._observed)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def test_count(self) -> int:
|
||||||
|
return len({nodeid for nodeid, _violation in self._observed})
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._observed.clear()
|
||||||
|
|
||||||
|
def record(self, nodeid: str, violations: Iterable[BlockingCall]) -> None:
|
||||||
|
for violation in violations:
|
||||||
|
self._observed.append((nodeid, violation))
|
||||||
|
|
||||||
|
def format_summary(self, *, limit: int = 30) -> str:
|
||||||
|
if not self._observed:
|
||||||
|
return "blocking io probe: no violations"
|
||||||
|
|
||||||
|
call_sites: Counter[tuple[str, str, int, str, str]] = Counter()
|
||||||
|
for _nodeid, violation in self._observed:
|
||||||
|
frame = self._local_call_site(violation.stack)
|
||||||
|
if frame is None:
|
||||||
|
call_sites[(violation.name, "<unknown>", 0, "<unknown>", "")] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
call_sites[
|
||||||
|
(
|
||||||
|
violation.name,
|
||||||
|
self._relative(frame.filename),
|
||||||
|
frame.lineno,
|
||||||
|
frame.name,
|
||||||
|
(frame.line or "").strip(),
|
||||||
|
)
|
||||||
|
] += 1
|
||||||
|
|
||||||
|
lines = [f"blocking io probe: {self.violation_count} violations across {self.test_count} tests", "Top call sites:"]
|
||||||
|
for (name, filename, lineno, function, line), count in call_sites.most_common(limit):
|
||||||
|
lines.append(f"{count:4d} {name} {filename}:{lineno} {function} | {line}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _relative(self, filename: str) -> str:
|
||||||
|
try:
|
||||||
|
return str(Path(filename).resolve().relative_to(self._project_root))
|
||||||
|
except ValueError:
|
||||||
|
return filename
|
||||||
|
|
||||||
|
def _local_call_site(self, stack: tuple[traceback.FrameSummary, ...]) -> traceback.FrameSummary | None:
|
||||||
|
local_frames = [frame for frame in stack if str(self._project_root) in frame.filename and "/.venv/" not in frame.filename and not self._relative(frame.filename).startswith("tests/")]
|
||||||
|
if local_frames:
|
||||||
|
return local_frames[-1]
|
||||||
|
|
||||||
|
test_frames = [frame for frame in stack if str(self._project_root) in frame.filename and "/.venv/" not in frame.filename]
|
||||||
|
return test_frames[-1] if test_frames else None
|
||||||
|
|
||||||
|
|
||||||
|
def detect_blocking_io(
|
||||||
|
specs: Iterable[BlockingCallSpec] = DEFAULT_BLOCKING_CALL_SPECS,
|
||||||
|
*,
|
||||||
|
fail_on_exit: bool = False,
|
||||||
|
patch_loaded_aliases: bool = True,
|
||||||
|
stack_limit: int = 12,
|
||||||
|
) -> BlockingIODetector:
|
||||||
|
"""Create a detector context manager for a focused test scope."""
|
||||||
|
|
||||||
|
return BlockingIODetector(specs, fail_on_exit=fail_on_exit, patch_loaded_aliases=patch_loaded_aliases, stack_limit=stack_limit)
|
||||||
|
|
||||||
|
|
||||||
|
def format_blocking_calls(violations: Iterable[BlockingCall]) -> str:
|
||||||
|
"""Format detector output with enough stack context to locate call sites."""
|
||||||
|
|
||||||
|
lines = ["Blocking calls were executed on an asyncio event loop thread:"]
|
||||||
|
for index, violation in enumerate(violations, start=1):
|
||||||
|
lines.append(f"{index}. {violation.name} ({violation.target})")
|
||||||
|
lines.extend(_format_stack(violation.stack))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_stack(stack: Iterable[traceback.FrameSummary]) -> Iterator[str]:
|
||||||
|
for frame in stack:
|
||||||
|
location = f"{frame.filename}:{frame.lineno}"
|
||||||
|
lines = [f" at {frame.name} ({location})"]
|
||||||
|
if frame.line:
|
||||||
|
lines.append(f" {frame.line.strip()}")
|
||||||
|
yield from lines
|
||||||
@@ -4,10 +4,40 @@ import json
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
import yaml
|
import yaml
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from deerflow.config.agents_api_config import get_agents_api_config
|
import deerflow.config.app_config as app_config_module
|
||||||
|
from deerflow.config.acp_config import load_acp_config_from_dict
|
||||||
|
from deerflow.config.agents_api_config import get_agents_api_config, load_agents_api_config_from_dict
|
||||||
from deerflow.config.app_config import AppConfig, get_app_config, reset_app_config
|
from deerflow.config.app_config import AppConfig, get_app_config, reset_app_config
|
||||||
|
from deerflow.config.checkpointer_config import get_checkpointer_config, load_checkpointer_config_from_dict
|
||||||
|
from deerflow.config.guardrails_config import get_guardrails_config, load_guardrails_config_from_dict
|
||||||
|
from deerflow.config.memory_config import get_memory_config, load_memory_config_from_dict
|
||||||
|
from deerflow.config.stream_bridge_config import get_stream_bridge_config, load_stream_bridge_config_from_dict
|
||||||
|
from deerflow.config.subagents_config import get_subagents_app_config, load_subagents_config_from_dict
|
||||||
|
from deerflow.config.summarization_config import get_summarization_config, load_summarization_config_from_dict
|
||||||
|
from deerflow.config.title_config import get_title_config, load_title_config_from_dict
|
||||||
|
from deerflow.config.tool_search_config import get_tool_search_config, load_tool_search_config_from_dict
|
||||||
|
from deerflow.runtime.checkpointer import get_checkpointer, reset_checkpointer
|
||||||
|
from deerflow.runtime.store import get_store, reset_store
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_config_singletons() -> None:
|
||||||
|
load_title_config_from_dict({})
|
||||||
|
load_summarization_config_from_dict({})
|
||||||
|
load_memory_config_from_dict({})
|
||||||
|
load_agents_api_config_from_dict({})
|
||||||
|
load_subagents_config_from_dict({})
|
||||||
|
load_tool_search_config_from_dict({})
|
||||||
|
load_guardrails_config_from_dict({})
|
||||||
|
load_checkpointer_config_from_dict(None)
|
||||||
|
load_stream_bridge_config_from_dict(None)
|
||||||
|
load_acp_config_from_dict({})
|
||||||
|
reset_checkpointer()
|
||||||
|
reset_store()
|
||||||
|
reset_app_config()
|
||||||
|
|
||||||
|
|
||||||
def _write_config(path: Path, *, model_name: str, supports_thinking: bool) -> None:
|
def _write_config(path: Path, *, model_name: str, supports_thinking: bool) -> None:
|
||||||
@@ -53,6 +83,23 @@ def _write_config_with_agents_api(
|
|||||||
path.write_text(yaml.safe_dump(config), encoding="utf-8")
|
path.write_text(yaml.safe_dump(config), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_config_with_sections(path: Path, sections: dict | None = None) -> None:
|
||||||
|
config = {
|
||||||
|
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"name": "first-model",
|
||||||
|
"use": "langchain_openai:ChatOpenAI",
|
||||||
|
"model": "gpt-test",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if sections:
|
||||||
|
config.update(sections)
|
||||||
|
|
||||||
|
path.write_text(yaml.safe_dump(config), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def _write_extensions_config(path: Path) -> None:
|
def _write_extensions_config(path: Path) -> None:
|
||||||
path.write_text(json.dumps({"mcpServers": {}, "skills": {}}), encoding="utf-8")
|
path.write_text(json.dumps({"mcpServers": {}, "skills": {}}), encoding="utf-8")
|
||||||
|
|
||||||
@@ -175,3 +222,168 @@ def test_get_app_config_resets_agents_api_config_when_section_removed(tmp_path,
|
|||||||
assert get_agents_api_config().enabled is False
|
assert get_agents_api_config().enabled is False
|
||||||
finally:
|
finally:
|
||||||
reset_app_config()
|
reset_app_config()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_app_config_resets_singleton_configs_when_sections_removed(tmp_path, monkeypatch):
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
extensions_path = tmp_path / "extensions_config.json"
|
||||||
|
_write_extensions_config(extensions_path)
|
||||||
|
_write_config_with_sections(
|
||||||
|
config_path,
|
||||||
|
{
|
||||||
|
"title": {"enabled": False, "max_words": 3},
|
||||||
|
"summarization": {"enabled": True},
|
||||||
|
"memory": {"enabled": False, "max_facts": 50},
|
||||||
|
"subagents": {"timeout_seconds": 42, "agents": {"reviewer": {"max_turns": 2}}},
|
||||||
|
"tool_search": {"enabled": True},
|
||||||
|
"guardrails": {"enabled": True, "fail_closed": False},
|
||||||
|
"checkpointer": {"type": "memory"},
|
||||||
|
"stream_bridge": {"type": "memory", "queue_maxsize": 12},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
||||||
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
||||||
|
reset_app_config()
|
||||||
|
|
||||||
|
try:
|
||||||
|
get_app_config()
|
||||||
|
assert get_title_config().enabled is False
|
||||||
|
assert get_summarization_config().enabled is True
|
||||||
|
assert get_memory_config().enabled is False
|
||||||
|
assert get_subagents_app_config().timeout_seconds == 42
|
||||||
|
assert get_tool_search_config().enabled is True
|
||||||
|
assert get_guardrails_config().enabled is True
|
||||||
|
assert get_checkpointer_config() is not None
|
||||||
|
assert get_stream_bridge_config() is not None
|
||||||
|
|
||||||
|
_write_config_with_sections(config_path)
|
||||||
|
next_mtime = config_path.stat().st_mtime + 5
|
||||||
|
os.utime(config_path, (next_mtime, next_mtime))
|
||||||
|
|
||||||
|
get_app_config()
|
||||||
|
assert get_title_config().enabled is True
|
||||||
|
assert get_summarization_config().enabled is False
|
||||||
|
assert get_memory_config().enabled is True
|
||||||
|
assert get_subagents_app_config().timeout_seconds == 900
|
||||||
|
assert get_tool_search_config().enabled is False
|
||||||
|
assert get_guardrails_config().enabled is False
|
||||||
|
assert get_checkpointer_config() is None
|
||||||
|
assert get_stream_bridge_config() is None
|
||||||
|
finally:
|
||||||
|
_reset_config_singletons()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_app_config_resets_persistence_runtime_singletons_when_checkpointer_removed(tmp_path, monkeypatch):
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
extensions_path = tmp_path / "extensions_config.json"
|
||||||
|
_write_extensions_config(extensions_path)
|
||||||
|
_write_config_with_sections(config_path, {"checkpointer": {"type": "memory"}})
|
||||||
|
|
||||||
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
||||||
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
||||||
|
reset_checkpointer()
|
||||||
|
reset_store()
|
||||||
|
reset_app_config()
|
||||||
|
|
||||||
|
try:
|
||||||
|
get_app_config()
|
||||||
|
initial_checkpointer = get_checkpointer()
|
||||||
|
initial_store = get_store()
|
||||||
|
|
||||||
|
_write_config_with_sections(config_path)
|
||||||
|
next_mtime = config_path.stat().st_mtime + 5
|
||||||
|
os.utime(config_path, (next_mtime, next_mtime))
|
||||||
|
|
||||||
|
get_app_config()
|
||||||
|
|
||||||
|
assert get_checkpointer_config() is None
|
||||||
|
assert get_checkpointer() is not initial_checkpointer
|
||||||
|
assert get_store() is not initial_store
|
||||||
|
finally:
|
||||||
|
_reset_config_singletons()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_app_config_keeps_persistence_runtime_singletons_when_checkpointer_unchanged(tmp_path, monkeypatch):
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
extensions_path = tmp_path / "extensions_config.json"
|
||||||
|
_write_extensions_config(extensions_path)
|
||||||
|
_write_config_with_sections(
|
||||||
|
config_path,
|
||||||
|
{
|
||||||
|
"title": {"enabled": False},
|
||||||
|
"checkpointer": {"type": "memory"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
||||||
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
||||||
|
_reset_config_singletons()
|
||||||
|
|
||||||
|
try:
|
||||||
|
get_app_config()
|
||||||
|
initial_checkpointer = get_checkpointer()
|
||||||
|
initial_store = get_store()
|
||||||
|
|
||||||
|
_write_config_with_sections(
|
||||||
|
config_path,
|
||||||
|
{
|
||||||
|
"title": {"enabled": True},
|
||||||
|
"checkpointer": {"type": "memory"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
next_mtime = config_path.stat().st_mtime + 5
|
||||||
|
os.utime(config_path, (next_mtime, next_mtime))
|
||||||
|
|
||||||
|
get_app_config()
|
||||||
|
|
||||||
|
assert get_checkpointer() is initial_checkpointer
|
||||||
|
assert get_store() is initial_store
|
||||||
|
finally:
|
||||||
|
_reset_config_singletons()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_app_config_does_not_mutate_singletons_when_reload_validation_fails(tmp_path, monkeypatch):
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
extensions_path = tmp_path / "extensions_config.json"
|
||||||
|
_write_extensions_config(extensions_path)
|
||||||
|
_write_config_with_sections(
|
||||||
|
config_path,
|
||||||
|
{
|
||||||
|
"title": {"enabled": False},
|
||||||
|
"tool_search": {"enabled": True},
|
||||||
|
"checkpointer": {"type": "memory"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
||||||
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
||||||
|
_reset_config_singletons()
|
||||||
|
|
||||||
|
try:
|
||||||
|
previous_app_config = get_app_config()
|
||||||
|
initial_checkpointer = get_checkpointer()
|
||||||
|
initial_store = get_store()
|
||||||
|
|
||||||
|
_write_config_with_sections(
|
||||||
|
config_path,
|
||||||
|
{
|
||||||
|
"title": False,
|
||||||
|
"tool_search": False,
|
||||||
|
"checkpointer": {"type": "memory"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
next_mtime = config_path.stat().st_mtime + 5
|
||||||
|
os.utime(config_path, (next_mtime, next_mtime))
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
get_app_config()
|
||||||
|
|
||||||
|
assert app_config_module._app_config is previous_app_config
|
||||||
|
assert get_title_config().enabled is False
|
||||||
|
assert get_tool_search_config().enabled is True
|
||||||
|
assert get_checkpointer_config() is not None
|
||||||
|
assert get_checkpointer() is initial_checkpointer
|
||||||
|
assert get_store() is initial_store
|
||||||
|
finally:
|
||||||
|
_reset_config_singletons()
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from os import walk as imported_walk
|
||||||
|
from pathlib import Path
|
||||||
|
from time import sleep as imported_sleep
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
from support.detectors.blocking_io import (
|
||||||
|
BlockingCallSpec,
|
||||||
|
BlockingIOProbe,
|
||||||
|
detect_blocking_io,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
|
||||||
|
TIME_SLEEP_ONLY = (BlockingCallSpec("time.sleep", "time:sleep"),)
|
||||||
|
REQUESTS_ONLY = (BlockingCallSpec("requests.Session.request", "requests.sessions:Session.request"),)
|
||||||
|
HTTPX_ONLY = (BlockingCallSpec("httpx.Client.request", "httpx:Client.request"),)
|
||||||
|
OS_WALK_ONLY = (BlockingCallSpec("os.walk", "os:walk", record_on_iteration=True),)
|
||||||
|
PATH_READ_TEXT_ONLY = (BlockingCallSpec("pathlib.Path.read_text", "pathlib:Path.read_text"),)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_records_time_sleep_on_event_loop() -> None:
|
||||||
|
with detect_blocking_io(TIME_SLEEP_ONLY) as detector:
|
||||||
|
time.sleep(0)
|
||||||
|
|
||||||
|
assert [violation.name for violation in detector.violations] == ["time.sleep"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_records_already_imported_sleep_alias_on_event_loop() -> None:
|
||||||
|
original_alias = imported_sleep
|
||||||
|
|
||||||
|
with detect_blocking_io(TIME_SLEEP_ONLY) as detector:
|
||||||
|
imported_sleep(0)
|
||||||
|
|
||||||
|
assert imported_sleep is original_alias
|
||||||
|
assert [violation.name for violation in detector.violations] == ["time.sleep"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_can_disable_loaded_alias_patching() -> None:
|
||||||
|
with detect_blocking_io(TIME_SLEEP_ONLY, patch_loaded_aliases=False) as detector:
|
||||||
|
imported_sleep(0)
|
||||||
|
|
||||||
|
assert detector.violations == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_does_not_record_time_sleep_offloaded_to_thread() -> None:
|
||||||
|
with detect_blocking_io(TIME_SLEEP_ONLY) as detector:
|
||||||
|
await asyncio.to_thread(time.sleep, 0)
|
||||||
|
|
||||||
|
assert detector.violations == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fixture_allows_offloaded_sync_work(blocking_io_detector) -> None:
|
||||||
|
await asyncio.to_thread(time.sleep, 0)
|
||||||
|
|
||||||
|
assert blocking_io_detector.violations == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_does_not_record_sync_call_without_running_event_loop() -> None:
|
||||||
|
def call_sleep() -> list[str]:
|
||||||
|
with detect_blocking_io(TIME_SLEEP_ONLY) as detector:
|
||||||
|
time.sleep(0)
|
||||||
|
return [violation.name for violation in detector.violations]
|
||||||
|
|
||||||
|
assert await asyncio.to_thread(call_sleep) == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fail_on_exit_includes_call_site() -> None:
|
||||||
|
with pytest.raises(AssertionError) as exc_info:
|
||||||
|
with detect_blocking_io(TIME_SLEEP_ONLY, fail_on_exit=True):
|
||||||
|
time.sleep(0)
|
||||||
|
|
||||||
|
message = str(exc_info.value)
|
||||||
|
assert "time.sleep" in message
|
||||||
|
assert "test_fail_on_exit_includes_call_site" in message
|
||||||
|
|
||||||
|
|
||||||
|
async def test_records_requests_session_request_without_real_network(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
def fake_request(self: requests.Session, method: str, url: str, **kwargs: object) -> str:
|
||||||
|
return f"{method}:{url}"
|
||||||
|
|
||||||
|
monkeypatch.setattr(requests.sessions.Session, "request", fake_request)
|
||||||
|
|
||||||
|
with detect_blocking_io(REQUESTS_ONLY) as detector:
|
||||||
|
assert requests.get("https://example.invalid") == "get:https://example.invalid"
|
||||||
|
|
||||||
|
assert [violation.name for violation in detector.violations] == ["requests.Session.request"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_records_sync_httpx_client_request_without_real_network(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
def fake_request(self: httpx.Client, method: str, url: str, **kwargs: object) -> httpx.Response:
|
||||||
|
return httpx.Response(200, request=httpx.Request(method, url))
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx.Client, "request", fake_request)
|
||||||
|
|
||||||
|
with detect_blocking_io(HTTPX_ONLY) as detector:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response = client.get("https://example.invalid")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert [violation.name for violation in detector.violations] == ["httpx.Client.request"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_records_os_walk_on_event_loop(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "nested").mkdir()
|
||||||
|
|
||||||
|
with detect_blocking_io(OS_WALK_ONLY) as detector:
|
||||||
|
assert list(os.walk(tmp_path))
|
||||||
|
|
||||||
|
assert [violation.name for violation in detector.violations] == ["os.walk"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_records_already_imported_os_walk_alias_on_iteration(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "nested").mkdir()
|
||||||
|
original_alias = imported_walk
|
||||||
|
|
||||||
|
with detect_blocking_io(OS_WALK_ONLY) as detector:
|
||||||
|
assert list(imported_walk(tmp_path))
|
||||||
|
|
||||||
|
assert imported_walk is original_alias
|
||||||
|
assert [violation.name for violation in detector.violations] == ["os.walk"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_does_not_record_os_walk_before_iteration(tmp_path: Path) -> None:
|
||||||
|
with detect_blocking_io(OS_WALK_ONLY) as detector:
|
||||||
|
walker = os.walk(tmp_path)
|
||||||
|
|
||||||
|
assert list(walker)
|
||||||
|
assert detector.violations == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_does_not_record_os_walk_iterated_off_event_loop(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "nested").mkdir()
|
||||||
|
|
||||||
|
with detect_blocking_io(OS_WALK_ONLY) as detector:
|
||||||
|
walker = os.walk(tmp_path)
|
||||||
|
assert await asyncio.to_thread(lambda: list(walker))
|
||||||
|
|
||||||
|
assert detector.violations == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_records_path_read_text_on_event_loop(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "data.txt"
|
||||||
|
path.write_text("content", encoding="utf-8")
|
||||||
|
|
||||||
|
with detect_blocking_io(PATH_READ_TEXT_ONLY) as detector:
|
||||||
|
assert path.read_text(encoding="utf-8") == "content"
|
||||||
|
|
||||||
|
assert [violation.name for violation in detector.violations] == ["pathlib.Path.read_text"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_probe_formats_summary_for_recorded_violations(tmp_path: Path) -> None:
|
||||||
|
probe = BlockingIOProbe(Path(__file__).resolve().parents[1])
|
||||||
|
path = tmp_path / "data.txt"
|
||||||
|
path.write_text("content", encoding="utf-8")
|
||||||
|
|
||||||
|
with detect_blocking_io(PATH_READ_TEXT_ONLY, stack_limit=18) as detector:
|
||||||
|
assert path.read_text(encoding="utf-8") == "content"
|
||||||
|
|
||||||
|
probe.record("tests/test_example.py::test_example", detector.violations)
|
||||||
|
summary = probe.format_summary()
|
||||||
|
|
||||||
|
assert "blocking io probe: 1 violations across 1 tests" in summary
|
||||||
|
assert "pathlib.Path.read_text" in summary
|
||||||
|
|
||||||
|
|
||||||
|
async def test_probe_formats_empty_summary_and_can_be_cleared(tmp_path: Path) -> None:
|
||||||
|
probe = BlockingIOProbe(Path(__file__).resolve().parents[1])
|
||||||
|
|
||||||
|
assert probe.format_summary() == "blocking io probe: no violations"
|
||||||
|
|
||||||
|
path = tmp_path / "data.txt"
|
||||||
|
path.write_text("content", encoding="utf-8")
|
||||||
|
with detect_blocking_io(PATH_READ_TEXT_ONLY, stack_limit=18) as detector:
|
||||||
|
assert path.read_text(encoding="utf-8") == "content"
|
||||||
|
|
||||||
|
probe.record("tests/test_example.py::test_example", detector.violations)
|
||||||
|
assert probe.violation_count == 1
|
||||||
|
|
||||||
|
probe.clear()
|
||||||
|
|
||||||
|
assert probe.violation_count == 0
|
||||||
|
assert probe.format_summary() == "blocking io probe: no violations"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user