* feat(skills): bind request-scoped secrets for in-context (autonomously invoked) skills Extends the #3861 binding point A (slash-activation only) to A+: the injection set is recomputed on every model call from two unioned sources — the run's most recent slash activation (persisted on the run context so the tool loop keeps the binding) and skills the model actually loaded in this thread (ThreadState.skill_context), re-validated against the live registry each call. Authorization stays three-gated regardless of activation style: skill enabled by the operator, values supplied per-request by the caller in context.secrets (never persisted server-side, never from the host env), names declared in the skill's required-secrets frontmatter. Because the set is replaced per call, eviction from skill_context or a caller that stops supplying a value revokes injection on the next call. New frontmatter field secrets-autonomous (default true) lets a skill restrict binding to explicit slash activation; malformed values fail closed to false. Binding changes are recorded as a middleware:skill_secrets journal event carrying names only. Design informed by a survey of peer systems (Claude Code, Codex CLI, opencode, pi, deepagents, hermes-agent, QwenPaw) and specs (agentskills.io, MCP 2025-11-25): the industry trust boundary is enable-time consent plus caller-scoped credentials, not per-invocation ceremony; no surveyed system scopes secrets to an activation turn. Part of #3914 * refactor(skills): centralize secret context keys, document intentional per-call reload Review follow-ups (no behavior change): move the two private binding keys (__slash_skill_secret_source, __skill_secrets_binding_audit) into secret_context.py and add them to REDACTED_CONTEXT_KEYS so the redaction allowlist stays a complete guard even though both keys hold names only. Document why _in_context_secret_sources reloads skills every call rather than caching: load_skills re-reads enabled state so an operator disabling a skill revokes its binding on the next model call — an mtime cache would miss enable/disable toggles and keep injecting after a disable. * fix(skills): match in-context secret bindings by path only, never by name Review finding (confused deputy): _in_context_secret_sources fell back to name matching when a skill_context path did not resolve. DeerFlow lets a custom skill shadow a same-named public/legacy one (load_skills de-dupes by name, custom wins), so a thread that read public/foo could bind the custom foo's declared secrets although the custom skill was never loaded in the thread. The recent user-isolation path changes make by-path misses (and thus the dangerous fallback) more likely. Drop the by-name fallback: match strictly by the exact container file path the model read; an unresolved path simply does not bind (the safe direction). Regression tests cover the shadowing case and a stale path. Part of #3914 * fix(skills): resolve secret-binding sources via registry; strip caller __-keys Security review (willem-bd, #3938): 1. Forged `__slash_skill_secret_source` bypassed the enabled/allowlist/ secrets-autonomous gates. runtime.context is caller-mergeable, and the slash source was trusted as authoritative (its stored requirements were injected directly). Now the slash source records only the activated skill's canonical container path, and BOTH the slash and in-context sources resolve the live registry skill by normalized path each call (_resolve_registry_skill) — binding only that real, enabled, allowlisted skill's own declared secrets. A forged path resolves to nothing. As defense in depth, build_run_config strips caller-supplied __-prefixed context keys at the gateway boundary. 2. Malformed caller requirements crashed the run (unguarded tuple unpack / DoS). The middleware no longer unpacks caller-provided requirement data at all — declarations come from the registry — so a malformed source fails closed instead of raising. 3. Path-normalization asymmetry silently disabled in-context binding on a trailing-slash container_path config. Both the registry keys and the lookup path are now posixpath.normpath'd. Regression tests: forged source rejected, forged-but-real path ignores caller requirements + allowlist, malformed source fails closed, trailing- slash config binds, gateway strips __-keys. Part of #3914 * docs(skills): correct _SLASH_SECRET_SOURCE_KEY comment and note fail-closed trade-off Post-review cleanup: the key now stores only the canonical container path (the comment still described the pre-fix skill-name+requirements shape), and document that a transient registry-load failure fails closed (drops the binding for that call) rather than trusting stale data. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
97 KiB
AGENTS.md
This file provides guidance to AI coding agents (Claude Code, Codex, and others) when working with code in this repository. It is the source of truth; the sibling CLAUDE.md imports it via @AGENTS.md.
Project Overview
DeerFlow is a LangGraph-based AI super agent system with a full-stack architecture. The backend provides a "super agent" with sandbox execution, persistent memory, subagent delegation, and extensible tool integration - all operating in per-thread isolated environments.
Architecture:
- Gateway API (port 8001): REST API plus embedded LangGraph-compatible agent runtime
- Frontend (port 3000): Next.js web interface
- Nginx (port 2026): Unified reverse proxy entry point
- Provisioner (port 8002, optional in Docker dev): Started only when sandbox is configured for provisioner/Kubernetes mode
Runtime:
make dev, Docker dev, and production all run the agent runtime in Gateway viaRunManager+run_agent()+StreamBridge(packages/harness/deerflow/runtime/). Nginx exposes that runtime at/api/langgraph/*and rewrites it to Gateway's native/api/*routers.- Scheduled-task executions must reuse that same Gateway run lifecycle. The scheduler may decide when work runs, but it must dispatch through the existing run path rather than introducing a parallel execution stack.
Project Structure:
deer-flow/
├── Makefile # Root commands (check, install, dev, stop)
├── config.yaml # Main application configuration
├── extensions_config.json # MCP servers and skills configuration
├── backend/ # Backend application (this directory)
│ ├── Makefile # Backend-only commands (dev, gateway, lint)
│ ├── langgraph.json # LangGraph Studio graph configuration
│ ├── packages/
│ │ └── harness/ # deerflow-harness package (import: deerflow.*)
│ │ ├── pyproject.toml
│ │ └── deerflow/
│ │ ├── agents/ # LangGraph agent system
│ │ │ ├── lead_agent/ # Main agent (factory + system prompt)
│ │ │ ├── middlewares/ # middleware components (see Middleware Chain section)
│ │ │ ├── memory/ # Memory extraction, queue, prompts
│ │ │ └── thread_state.py # ThreadState schema
│ │ ├── sandbox/ # Sandbox execution system
│ │ │ ├── local/ # Local filesystem provider
│ │ │ ├── sandbox.py # Abstract Sandbox interface
│ │ │ ├── tools.py # bash, ls, read/write/str_replace
│ │ │ └── middleware.py # Sandbox lifecycle management
│ │ ├── subagents/ # Subagent delegation system
│ │ │ ├── builtins/ # general-purpose, bash agents
│ │ │ ├── executor.py # Background execution engine
│ │ │ └── registry.py # Agent registry
│ │ ├── tools/builtins/ # Built-in tools (present_files, ask_clarification, view_image)
│ │ ├── mcp/ # MCP integration (tools, cache, client)
│ │ ├── models/ # Model factory with thinking/vision support
│ │ ├── skills/ # Skills discovery, loading, parsing
│ │ ├── config/ # Configuration system (app, model, sandbox, tool, etc.)
│ │ ├── community/ # Community tools (search/fetch/scrape, image search, AIO sandbox)
│ │ ├── reflection/ # Dynamic module loading (resolve_variable, resolve_class)
│ │ ├── utils/ # Utilities (network, readability)
│ │ └── client.py # Embedded Python client (DeerFlowClient)
│ ├── app/ # Application layer (import: app.*)
│ │ ├── gateway/ # FastAPI Gateway API
│ │ │ ├── app.py # FastAPI application
│ │ │ └── routers/ # FastAPI route modules (models, mcp, memory, skills, uploads, threads, artifacts, agents, suggestions, channels)
│ │ └── channels/ # IM platform integrations
│ ├── tests/ # Test suite
│ └── docs/ # Documentation
├── frontend/ # Next.js frontend application
└── skills/ # Agent skills directory
├── public/ # Public skills (committed)
└── custom/ # Custom skills (gitignored)
Important Development Guidelines
Documentation Update Policy
CRITICAL: Always update README.md and AGENTS.md after every code change
When making code changes, you MUST update the relevant documentation:
- Update
README.mdfor user-facing changes (features, setup, usage instructions) - Update
AGENTS.mdfor development changes (architecture, commands, workflows, internal systems).CLAUDE.mdimports it via@AGENTS.md, so editingAGENTS.mdupdates both. - Keep documentation synchronized with the codebase at all times
- Ensure accuracy and timeliness of all documentation
Commands
Root directory (for full application):
make check # Check system requirements
make install # Install all dependencies (frontend + backend)
make dev # Start all services (Gateway + Frontend + Nginx), with config.yaml preflight
make start # Start production services locally
make stop # Stop all services
Backend directory (for backend development only):
make install # Install backend dependencies
make dev # Run Gateway API with reload (port 8001)
make gateway # Run Gateway API only (port 8001)
make test # Run all backend tests
make test-blocking-io # Run strict Blockbuster runtime gate on tests/blocking_io/
make lint # Lint with ruff
make format # Format code with ruff
make migrate-rev MSG="..." # Autogenerate a new alembic revision (see Schema Migrations section)
The detect-blocking-io target parses app/, packages/harness/deerflow/,
and scripts/ with AST. By default it reports only blocking IO candidates that
are inside async code, reachable from async code in the same file, or reachable
from sync-only AgentMiddleware before/after hooks that LangGraph can execute
on the async graph path. It prints a concise summary and writes complete JSON
findings to .deer-flow/blocking-io-findings.json at the repository root
(both make detect-blocking-io from the repo root and cd backend && make detect-blocking-io resolve to the same repo-root path). JSON findings include
priority, location, blocking_call, event_loop_exposure, reason, and
code for model-assisted or manual review. priority is a deterministic
review ordering from operation type, not proof of a bug. Bare-name same-file
calls are resolved by function name, so duplicate helper names in one file can
conservatively over-report async reachability. It is intentionally
informational and is not run from CI in this round.
For a diff-scoped view of the same findings, scripts/scan_changed_blocking_io.py
(repo root) reports findings on the added lines of git diff <base>...HEAD
plus findings new versus the merge base (so a new async caller exposing an
untouched sync helper in the same file is still reported) — used by the
blocking-io-guard skill (.agent/skills/blocking-io-guard/) as the
deterministic scope step before routing each candidate to a fix and/or a
tests/blocking_io/ runtime anchor.
Regression tests related to Docker/provisioner behavior:
tests/test_docker_sandbox_mode_detection.py(mode detection fromconfig.yaml)tests/test_provisioner_kubeconfig.py(kubeconfig file/directory handling)
Blocking-IO runtime gate (tests/blocking_io/):
- Wraps every item under
tests/blocking_io/with a strict Blockbuster context scoped toapp.*anddeerflow.*(seetests/support/detectors/blocking_io_runtime.py). Any sync blocking IO call whose stack passes through DeerFlow business code while running on the asyncio event loop raisesBlockingErrorand fails the test. - Regression anchors live there:
test_skills_load.py(locks theasyncio.to_threadoffload aroundLocalSkillStorage.load_skills, fix for #1917);test_sqlite_lifespan.py(locks the offload around SQLite path resolution plusensure_sqlite_parent_dir, fix for #1912);test_jsonl_run_event_store.py(locksJsonlRunEventStore's async API offloading its file IO viaasyncio.to_thread);test_uploads_middleware.py(locksUploadsMiddleware.abefore_agentoffloading the uploads-directory scan off the event loop); andtest_uploads_router.py(locks Gateway upload/list/delete endpoints offloading upload directory creation, staged writes, chmod/cleanup, directory scans/deletes, and remote sandbox sync off the event loop). test_gate_smoke.pyis a meta-test asserting the gate actually catches unoffloaded blocking IO and that the@pytest.mark.allow_blocking_ioopt-out works.- Coverage boundary: the gate only sees code that test execution actually touches. Static AST coverage is a separate concern (out of scope for this PR).
- CI: runs on every PR via
.github/workflows/backend-blocking-io-tests.yml, hard-fail.
Boundary check (harness → app import firewall):
tests/test_harness_boundary.py— ensurespackages/harness/deerflow/never imports fromapp.*
CI runs these regression tests for every pull request via .github/workflows/backend-unit-tests.yml.
Architecture
Harness / App Split
The backend is split into two layers with a strict dependency direction:
- Harness (
packages/harness/deerflow/): Publishable agent framework package (deerflow-harness). Import prefix:deerflow.*. Contains agent orchestration, tools, sandbox, models, MCP, skills, config — everything needed to build and run agents. - App (
app/): Unpublished application code. Import prefix:app.*. Contains the FastAPI Gateway API and IM channel integrations (Feishu, Slack, Telegram, DingTalk).
Dependency rule: App imports deerflow, but deerflow never imports app. This boundary is enforced by tests/test_harness_boundary.py which runs in CI.
Import conventions:
# Harness internal
from deerflow.agents import make_lead_agent
from deerflow.models import create_chat_model
# App internal
from app.gateway.app import app
from app.channels.service import start_channel_service
# App → Harness (allowed)
from deerflow.config import get_app_config
# Harness → App (FORBIDDEN — enforced by test_harness_boundary.py)
# from app.gateway.routers.uploads import ... # ← will fail CI
Package import hygiene: the deerflow.agents and deerflow.subagents package
roots expose heavyweight graph/executor entrypoints lazily. Internal modules
that only need lightweight types, config, or registries should import the
concrete submodule instead of adding eager package-root imports that pull in the
tool graph or subagent executor during state/schema imports.
Agent System
Lead Agent (packages/harness/deerflow/agents/lead_agent/agent.py):
- Entry point:
make_lead_agent(config: RunnableConfig)registered inlanggraph.json - Dynamic model selection via
create_chat_model()with thinking/vision support - Tools loaded via
get_available_tools()- combines sandbox, built-in, MCP, community, and subagent tools - System prompt generated by
apply_prompt_template()with skills, memory, and subagent instructions
ThreadState (packages/harness/deerflow/agents/thread_state.py):
- Extends
AgentStatewith:sandbox,thread_data,title,artifacts,todos,uploaded_files,viewed_images,goal,promoted,delegations,skill_context,summary_text - Uses custom reducers:
merge_artifacts(deduplicate),merge_viewed_images(merge/clear),merge_goal(preserve the active goal across ordinary state updates unless the goal writer replaces it),merge_promoted(catalog-hash-scoped deferred tool promotions),merge_delegations(append task delegation entries, same id latest wins, terminal status never downgraded, capped to the most recent entries), andmerge_skill_context(dedupe active-skill references by path, keep the most recently read entries; entries store a name/path/description reference, not the SKILL.md body).summary_textis a LastValue channel updated by summarization and projected into model requests as durable context data instead of being stored as amessagesitem.
Runtime Configuration (via config.configurable):
thinking_enabled- Enable model's extended thinkingmodel_name- Select specific LLM modelis_plan_mode- Enable TodoList middlewaresubagent_enabled- Enable task delegation tool
Middleware Chain
Lead-agent middlewares are assembled in strict order across three functions: the shared base in packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py (_build_runtime_middlewares, exposed via build_lead_runtime_middlewares), then the lead-only middlewares appended in packages/harness/deerflow/agents/lead_agent/agent.py (build_middlewares). Items marked (optional) are appended only when their config/runtime condition holds, so the live chain length varies.
Shared runtime base (build_lead_runtime_middlewares; subagents reuse most of this via build_subagent_runtime_middlewares):
- InputSanitizationMiddleware - First, so it is the outermost
wrap_model_callwrapper; every inner middleware (including LLM retries) sees sanitized messages - ToolOutputBudgetMiddleware - Caps tool output size (per app config) before it re-enters the model context
- ThreadDataMiddleware - Creates per-thread directories under the user's isolation scope (
backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/{workspace,uploads,outputs}); resolvesuser_idviaget_effective_user_id()(falls back to"default"in no-auth mode) - UploadsMiddleware - Tracks and injects newly uploaded files into conversation (lead agent only)
- SandboxMiddleware - Acquires sandbox, stores
sandbox_idin state - DanglingToolCallMiddleware - Injects placeholder ToolMessages for AIMessage tool_calls that lack responses (e.g., user interruption), preserving raw provider tool-call payloads in
additional_kwargs["tool_calls"] - LLMErrorHandlingMiddleware - Normalizes provider/model invocation failures into recoverable assistant-facing errors before later stages run
- GuardrailMiddleware - (optional, if
guardrails.enabled) Pre-tool-call authorization via pluggableGuardrailProvider; returns an error ToolMessage on deny. Providers: built-inAllowlistProvider(zero deps), OAP policy providers (e.g.aport-agent-guardrails), or custom. See docs/GUARDRAILS.md - SandboxAuditMiddleware - Audits sandboxed shell/file operations for security logging before tool execution
- ReadBeforeWriteMiddleware - (optional, if
read_before_write.enabled, default on) Version gate on file writes (issue #3857):read_filestamps a content hash onto its ToolMessage;write_file(append/overwrite-existing) andstr_replaceare blocked unless the newest mark for that path matches the file's current hash. Marks live on messages, so summarization dropping the read result invalidates the gate automatically; writes never refresh marks, forcing a re-read between consecutive edits. Gate check + tool execution are serialized per (thread, path) so same-turn parallel writes cannot reuse one stale mark; on sandboxes whoseread_filereports failures as"Error: ..."strings instead of raising (AIO/E2B), uninspectable targets fail open (creation proceeds, no mark stamped) - ToolErrorHandlingMiddleware - Receives
AppConfig, converts tool exceptions into errorToolMessages so the run can continue instead of aborting, stamps structured metadata for task exception wrappers, and stamps skill-read metadata for downstream durable-context capture. Task tool result text is generated from the same status/result/error inputs as the structured metadata so callers do not hand-write a second protocol string.
Lead-only middlewares (build_middlewares, appended after the base):
- DynamicContextMiddleware - Injects the current date (and optionally memory) as a
<system-reminder>into the first HumanMessage, keeping the base system prompt fully static for prefix-cache reuse - SkillActivationMiddleware - Detects strict
/skill-name tasksyntax on the latest real user message, resolves only enabled and runtime-allowed skills, injects theSKILL.mdbody as hidden current-turn context, and records amiddleware:skill_activationaudit event - DurableContextMiddleware - Captures
taskdelegations intoThreadState.delegations(including in-progress dispatches and terminal result summaries) and loaded skill-file references (name/path/description, parsed in-memory - not the body) intoThreadState.skill_contextbefore summarization can compact the paired tool-call/result messages, then projects durable context into each model request. Static authority rules are injected as aSystemMessage; untrusted field values (summary_text, delegation results, skill descriptions) are injected separately as a hiddenHumanMessagedata block so compressed history, delegated work, and which skills are active stay visible without being stored asmessagesor promoted to system-role instructions. - SummarizationMiddleware - (optional, if enabled) Context reduction when approaching token limits
- TodoListMiddleware - (optional, if
is_plan_mode) Task tracking with thewrite_todostool - TokenUsageMiddleware - (optional, if
token_usage.enabled) Records token usage metrics; subagent usage is merged back into the dispatching AIMessage by message position - TitleMiddleware - Auto-generates the thread title after the first complete exchange and normalizes structured message content before prompting the title model. If a first-turn run is interrupted before this middleware can write a title,
runtime/runs/worker.pykeeps the run in a finalizing state, persists a local fallback title from the latest checkpoint or original run input, and then syncs it tothreads_meta.display_name. Replacement runs admitted bymultitask_strategy="interrupt"/"rollback"wait for older same-thread finalization before entering the graph; the interrupted run only skips the fallback title write once a later run has started and may have advanced the checkpoint. - MemoryMiddleware - Queues conversations for async memory update (filters to user + final AI responses)
- ViewImageMiddleware - (optional, if the model supports vision) Injects base64 image data before the LLM call
- DeferredToolFilterMiddleware - (optional, if
tool_search.enabled) Hides deferred (MCP) tool schemas from the bound model untiltool_searchpromotes them (reads per-thread promotions fromThreadState.promoted, hash-scoped) - SystemMessageCoalescingMiddleware - Merges every SystemMessage into a single leading SystemMessage per request; provider-agnostic fix for strict backends (vLLM/SGLang/Qwen/Anthropic) that reject non-leading system messages. Touches the per-request payload only (checkpoint state unchanged); on midnight crossings only the latest
dynamic_context_reminderSystemMessage survives - SubagentLimitMiddleware - (optional, if
subagent_enabled) Truncates excesstasktool calls to enforce theMAX_CONCURRENT_SUBAGENTSlimit - LoopDetectionMiddleware - (optional, if
loop_detection.enabled) Detects repeated tool-call loops; hard-stop clears both structuredtool_callsand raw provider tool-call metadata before forcing a final text answer - TokenBudgetMiddleware - (optional, if
token_budget.enabled) Enforces per-run token limits - Custom middlewares - (optional) Any
custom_middlewarespassed tobuild_middlewaresare injected here, before the safety/clarification tail - SafetyFinishReasonMiddleware - (optional, if
safety_finish_reason.enabled) Suppresses tool execution when the provider safety-terminated the response (e.g.finish_reason=content_filter); registered after custom middlewares so LangChain's reverse-orderafter_modeldispatch runs it first - ClarificationMiddleware - Intercepts
ask_clarificationtool calls, interrupts viaCommand(goto=END)(must be last)
Configuration System
Main Configuration (config.yaml):
Setup: Copy config.example.yaml to config.yaml in the project root directory.
Config Versioning: config.example.yaml has a config_version field. On startup, AppConfig.from_file() compares user version vs example version and emits a warning if outdated. Missing config_version = version 0. Run make config-upgrade to auto-merge missing fields. When changing the config schema, bump config_version in config.example.yaml.
Config Caching: get_app_config() caches the parsed config, but automatically reloads it when the resolved config path or file content signature changes. The signature includes file metadata and a content digest, so Gateway and LangGraph reads stay aligned with config.yaml edits even on object-store or network mounts where mtime can remain stale.
Config Hot-Reload Boundary: Gateway dependencies route through get_app_config() on every request, so per-run fields like models[*].max_tokens, summarization.*, title.*, memory.*, subagents.*, tools[*], and the agent system prompt pick up config.yaml edits on the next message. AppConfig is intentionally not cached on app.state — lifespan() keeps a local startup_config variable for one-shot bootstrap work and passes it to langgraph_runtime(app, startup_config).
Infrastructure fields are restart-required. The authoritative list lives in packages/harness/deerflow/config/reload_boundary.py::STARTUP_ONLY_FIELDS and is mirrored by the standardised "startup-only:" prefix on the corresponding Field(description=...) in AppConfig, so IDE hover on those fields surfaces the reason inline (no need to context-switch into this table). Currently registered: database, checkpointer, run_events, stream_bridge, sandbox, log_level, logging, channels, channel_connections. Adding a new restart-required field requires updating the registry; drift is pinned by tests/test_reload_boundary.py.
Persistence backend resolution: the unified database section selects the
Gateway's LangGraph checkpointer, LangGraph Store, and DeerFlow SQL repositories.
The deprecated checkpointer section remains backward compatible and, when
present, overrides database for the LangGraph checkpointer and Store only;
application repositories continue to use database.
Configuration priority:
- Explicit
config_pathargument DEER_FLOW_CONFIG_PATHenvironment variableconfig.yamlin current directory (backend/)config.yamlin parent directory (project root - recommended location)
Config values starting with $ are resolved as environment variables (e.g., $OPENAI_API_KEY).
ModelConfig also declares use_responses_api and output_version so OpenAI /v1/responses can be enabled explicitly while still using langchain_openai:ChatOpenAI.
Extensions Configuration (extensions_config.json):
MCP servers and skills are configured together in extensions_config.json in project root:
Configuration priority:
- Explicit
config_pathargument DEER_FLOW_EXTENSIONS_CONFIG_PATHenvironment variableextensions_config.jsonin current directory (backend/)extensions_config.jsonin parent directory (project root - recommended location)
Gateway API (app/gateway/)
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:
| Router | Endpoints |
|---|---|
Models (/api/models) |
GET / - list models; GET /{name} - model details |
Features (/api/features) |
GET / - report config-gated feature availability (currently agents_api.enabled) for frontend UI gating |
Console (/api/console) |
Read-only cross-thread observability for the current user (the data layer for an operations dashboard or external monitoring): GET /stats - headline counters (runs/threads/agents/tokens/cost); GET /runs - paginated run history joined with thread titles (per-run cost); GET /usage - zero-filled daily token series + per-model breakdown with spend. Queries runs/threads_meta directly as a reporting layer (no new RunStore methods); requires a SQL database backend — returns 503 on database.backend: memory. Real-cost estimation reads optional models[*].pricing (currency, input_per_million, output_per_million, input_cache_hit_per_million; ModelConfig is extra="allow", so no schema change) and prices each run from its token_usage_by_model input/output split. Pricing is cache-aware: RunJournal accumulates prompt-cache hits from usage_metadata.input_token_details.cache_read into a sparse cache_read_tokens bucket key (also threaded through SubagentTokenCollector → record_external_llm_usage_records), and cache-hit input tokens are billed at input_cache_hit_per_million (omitted → billed at the miss price, a conservative upper bound). Legacy rows fall back to run-level totals at model_name; unpriced models yield cost: null and cost fields are null when no pricing is configured |
MCP (/api/mcp) |
GET /config - get config; PUT /config - update config (saves to extensions_config.json) |
Skills (/api/skills) |
GET / - list skills; GET /{name} - details; PUT /{name} - update enabled; POST /install - install from .skill archive (accepts standard optional frontmatter like version, author, compatibility) |
Memory (/api/memory) |
GET / - memory data; POST /reload - force reload; GET /config - config; GET /status - config + data |
Uploads (/api/threads/{id}/uploads) |
POST / - upload files (auto-converts PDF/PPT/Excel/Word); GET /list - list; DELETE /{filename} - delete |
Threads (/api/threads/{id}) |
DELETE / - remove DeerFlow-managed local thread data after LangGraph thread deletion; GET /goal, PUT /goal, DELETE /goal - read, set, and clear the active thread goal; unexpected failures are logged server-side and return a generic 500 detail |
Artifacts (/api/threads/{id}/artifacts) |
GET /{path} - serve artifacts; active content types (text/html, application/xhtml+xml, image/svg+xml) are always forced as download attachments to reduce XSS risk; ?download=true still forces download for other file types |
Suggestions (/api/suggestions) |
GET /config - returns global suggestions config boolean; POST /threads/{id}/suggestions - generate follow-up questions; rich list/block model content is normalized and inline reasoning (<think>...</think>, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing |
Thread Runs (/api/threads/{id}/runs) |
POST / - create background run; POST /stream - create + SSE stream; POST /wait - create + block; POST /regenerate/prepare - prepare clean input + checkpoint metadata for regenerating the latest assistant answer; GET / - list runs; GET /{rid} - run details; POST /{rid}/cancel - cancel; GET /{rid}/join - join SSE; GET /{rid}/messages - paginated messages {data, has_more}; GET /{rid}/events - full event stream; GET /../messages - thread messages with feedback; GET /../token-usage - aggregate tokens |
Feedback (/api/threads/{id}/runs/{rid}/feedback) |
PUT / - upsert feedback; DELETE / - delete user feedback; POST / - create feedback; GET / - list feedback; GET /stats - aggregate stats; DELETE /{fid} - delete specific |
Runs (/api/runs) |
POST /stream - stateless run + SSE; POST /wait - stateless run + block; GET /{rid}/messages - paginated messages by run_id {data, has_more} (cursor: after_seq/before_seq); GET /{rid}/feedback - list feedback by run_id |
GitHub Webhooks (/api/webhooks/github) |
POST / - receive GitHub App / repo webhook deliveries. Verifies X-Hub-Signature-256 against GITHUB_WEBHOOK_SECRET; exempt from auth + CSRF because authenticity is enforced by HMAC. The route is fail-closed: mounted only when GITHUB_WEBHOOK_SECRET is set, or when explicit dev opt-in DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS=1 is set. Recognized events include ping, issues, issue_comment, pull_request, pull_request_review, and pull_request_review_comment; unknown events return 200 with handled=false. Fan-out runtime failures return 503 so GitHub retries; permanent/non-retryable conditions such as channels.github.enabled: false, unknown events, malformed payloads, or unavailable channel service return 200 with a skipped/handled response. |
| GitHub Event-Driven Agents | Custom agents can declare a github: block in their config.yaml to bind to repos and event triggers. Webhook fan-out publishes one InboundMessage per matching binding to the channel bus; GitHubChannel routes those messages through ChannelManager. The response dispatch summarizes matched/fired/skipped agents. |
RunManager / RunStore contract:
RunManager.get()is async; direct callers mustawaitit.- When a persistent
RunStoreis configured,get()andlist_by_thread()hydrate historical runs from the store. In-memory records win for the samerun_idso task, abort, and stream-control state stays attached to active local runs. cancel()andcreate_or_reject(..., multitask_strategy="interrupt"|"rollback")persist interrupted status throughRunStore.update_status(), matching normalset_status()transitions.- Store-only hydrated runs are readable history. If the current worker has no in-memory task/control state for that run, cancellation APIs can return 409 because this worker cannot stop the task.
POST /wait(both thread-scoped and/api/runs/wait) drains the stream bridge viawait_for_run_completion()instead of bareawait record.task, so it honours the run'son_disconnectsetting and cancels the background run on real client disconnect rather than returning a stale checkpoint (issue #3265).- Redis
StreamBridgekeys use a rolling retained-buffer TTL (stream_bridge.stream_ttl_seconds, refreshed onpublish()/publish_end()) as a leak safety net, not as a run timeout. Startup orphan recovery publishesEND_SENTINELand schedules stream cleanup for recovered runs; do not broaden this into a shared-database multi-pod reaper without adding worker ownership/liveness first. - Thread-scoped run creation accepts
checkpoint/checkpoint_id; Gateway validates the checkpoint belongs to the request thread before writingcheckpoint_id/checkpoint_nsintoconfig.configurablefor LangGraph branching. - Thread-scoped Gateway runs evaluate an active
ThreadState.goalafter the visible turn completes.runtime/goal.pyasks a non-thinking evaluator model to judge only visible conversation evidence and return a typed blocker; the evaluator model is created once per run and reused across hidden continuation checks. Satisfied goals are cleared; every non-satisfied evaluation — continuable or stand-down — is persisted withlast_evaluation(the blocker, reason, and evidence summary; outcomes that stop the loop additionally record astand_down_reasonfor observability), but onlygoal_not_met_yetevaluations are streamed as hiddenHumanMessagecontinuations, and only when a durable assistant end-of-turn checkpoint exists, the run has not been aborted, the thread did not change during evaluation, and the no-progress breaker has not fired. The continuation cap is 8 — a hard maximum in the0–8range; callers requesting more are clamped (set_goal/TUI) or rejected with 422 (PUT /goal). The no-progress breaker keys on the latest visible assistant evidence (not the evaluator's free-text reason, which an LLM rewords every turn), so two consecutive continuations that add no new visible assistant output stop the loop after 2 attempts. Model-response cleanup helpers such as think-block stripping and code-fence stripping live indeerflow.utils.llm_textsoruntime/goal.pyand Gateway suggestion parsing share the same JSON-prep behavior.
Proxied through nginx: /api/langgraph/* → Gateway LangGraph-compatible runtime, all other /api/* → Gateway REST APIs.
Sandbox System (packages/harness/deerflow/sandbox/)
Interface: Abstract Sandbox with execute_command(command, env=None), read_file, write_file, list_dir. The optional env injects per-call environment variables (request-scoped secrets — see Request-Scoped Secrets below); LocalSandbox merges it via subprocess.run(env=...) and AioSandbox routes env-bearing commands through the bash.exec(env=...) API on a fresh session.
Provider Pattern: SandboxProvider with acquire, acquire_async, get, release lifecycle. Async agent/tool paths call async sandbox lifecycle hooks so Docker sandbox creation, discovery, cross-process locking, readiness polling, and release stay off the event loop.
Environment policy (sandbox/env_policy.py): execute_command no longer inherits the full os.environ. build_sandbox_env() scrubs secret-looking names (*KEY*/*SECRET*/*TOKEN*/*PASSWORD*/*CREDENTIAL*) from the inherited environment before layering injected request secrets on top, so platform credentials (e.g. OPENAI_API_KEY) never leak into skill subprocesses. Benign vars (PATH, HOME, LANG, VIRTUAL_ENV, ...) are preserved.
Implementations:
LocalSandboxProvider- Local filesystem execution.acquire(thread_id)returns a per-threadLocalSandbox(idlocal:{thread_id}) whosepath_mappingsresolve/mnt/user-data/{workspace,uploads,outputs}and/mnt/acp-workspaceto that thread's host directories, so the publicSandboxAPI honours the/mnt/user-datacontract uniformly with AIO.acquire()/acquire(None)keeps the legacy generic singleton (idlocal) for callers without a thread context. Per-thread sandboxes are held in an LRU cache (default 256 entries) guarded by athreading.Lock.AioSandboxProvider(packages/harness/deerflow/community/) - Docker-based isolation. Active-cache and warm-pool entries are checked with the backend during acquire/reuse; definitively dead containers are dropped from all in-process maps so the thread can discover or create a fresh sandbox instead of reusing a stale client. Backend health-check failures are treated as unknown, not dead; local discovery likewise treats an unverifiable container as not adoptable and falls through to create rather than failing acquire.get()remains an in-memory lookup for event-loop-safe tool paths.
Virtual Path System:
- Agent sees:
/mnt/user-data/{workspace,uploads,outputs},/mnt/skills - Physical:
backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/...,deer-flow/skills/ - Translation:
LocalSandboxProviderbuilds per-threadPathMappings for the user-data prefixes at acquire time;tools.pykeepsreplace_virtual_path()/replace_virtual_paths_in_command()as a defense-in-depth layer (and for path validation). AIO has the directories volume-mounted at the same virtual paths inside its container, so both implementations accept/mnt/user-data/...natively. - Detection:
is_local_sandbox()accepts bothsandbox_id == "local"(legacy / no-thread) andsandbox_id.startswith("local:")(per-thread)
Sandbox Tools (in packages/harness/deerflow/sandbox/tools.py):
bash- Execute commands with path translation and error handling. ForLocalSandbox(host bash), POSIX output is captured through bounded pipe-drain threads and stdin is/dev/null, so a backgrounded long-lived process (server &) returns immediately instead of blocking the turn on an inherited pipe, while unredirected background output is drained without growing anonymous temp files. Commands that read stdin get immediate EOF. The command runs in its own process group with a wall-clock timeout (sandbox.bash_command_timeout, default 600s); on timeout the whole group is killed and the agent gets a notice telling it to background long-lived processes. The bash tool description itself also instructs the model to background long-lived processes (e.g. servers) up front so it doesn't waste the turn waiting on a foreground server. SeeLocalSandbox.execute_command/_run_posix_commandandbash_tool's docstring.ls- Directory listing (tree format, max 2 levels)read_file- Read file contents with optional line rangewrite_file- Write/append to files, creates directories; overwrites by default and exposes theappendargument in the model-facing schema for end-of-file writes; subject to the read-before-write gate whenread_before_write.enabled(see Middleware Chain)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; subject to the read-before-write gate whenread_before_write.enabled(see Middleware Chain)
Subagent System (packages/harness/deerflow/subagents/)
Built-in Agents: general-purpose (all tools except task) and bash (command specialist)
Execution: Dual thread pool - _scheduler_pool (3 workers) + _execution_pool (3 workers)
Concurrency: MAX_CONCURRENT_SUBAGENTS = 3 enforced by SubagentLimitMiddleware (truncates excess tool calls in after_model); default subagent timeout subagents.timeout_seconds=1800 (30 min) and built-in general-purpose max_turns=150 (raised from 100/15-min so deep-research subtasks stop hitting GraphRecursionError out of the box)
Flow: task() tool → SubagentExecutor → background thread → poll 5s → SSE events → result
Events: task_started, task_running, task_completed/task_failed/task_timed_out
Step capture & persistence (#3779): executor.py captures both assistant turns (AIMessage) and tool outputs (ToolMessage) via subagents/step_events.py::capture_new_step_messages, which walks the newly-appended tail of each stream_mode="values" chunk (not just messages[-1]) so a multi-tool-call turn — where LangGraph's ToolNode appends several ToolMessages in one super-step — keeps every tool output instead of dropping all but the last. runtime/runs/worker.py::_SubagentEventBuffer additionally persists these task_* custom events to the RunEventStore as subagent.start/subagent.step/subagent.end (category="subagent", task_id in metadata). It batches writes via put_batch (flushing on a terminal subagent.end, at FLUSH_THRESHOLD events, and in the worker's finally) rather than one put() per step, since put() is a documented low-frequency path (per-thread advisory lock per call) and a deep subagent (max_turns=150) emits hundreds of steps on the hot stream loop. build_subagent_step caps both the per-step text and each tool call's serialized args at SUBAGENT_STEP_MAX_CHARS (flagged truncated / args_truncated) so a large write_file/bash payload can't produce an unbounded row. The dedicated category keeps them out of list_messages (the thread feed) while list_events returns them for the frontend's fetch-on-expand backfill. list_events accepts task_id (filters on metadata["task_id"] — SQL-side in DbRunEventStore via event_metadata["task_id"].as_string(), in-memory in the JSONL/memory stores) plus an after_seq forward cursor, so the card pages through one subagent's steps without the run-wide limit truncating the tail (no schema migration: the filter rides the existing run-scoped index). step_events.py is a pure, unit-tested layer (build_subagent_step / subagent_run_event).
Deferred MCP tools (if tool_search.enabled): SubagentExecutor._build_initial_state assembles deferral after policy filtering via the shared assemble_deferred_tools (fail-closed), appends the tool_search tool, injects the <available-deferred-tools> section into the subagent's SystemMessage, and threads the setup to _create_agent, which attaches DeferredToolFilterMiddleware through build_subagent_runtime_middlewares(deferred_setup=...). Subagents thus withhold full MCP schemas until promotion, same as the lead agent; each task run gets a fresh ThreadState so promotion is isolated per run
Checkpointer isolation: Subagent graphs are compiled with checkpointer=False to avoid inheriting the parent run's checkpointer, since subagents are one-shot and never resume.
Tool System (packages/harness/deerflow/tools/)
get_available_tools(groups, include_mcp, model_name, subagent_enabled) assembles:
- Config-defined tools - Resolved from
config.yamlviaresolve_variable() - MCP tools - From enabled MCP servers (lazy initialized, cached with mtime invalidation)
- Built-in tools:
present_files- Make output files visible to user (only/mnt/user-data/outputs)ask_clarification- Request clarification (intercepted by ClarificationMiddleware → interrupts)view_image- Read image as base64 (added only if model supports vision)setup_agent- Bootstrap-only: persist a brand-new custom agent'sSOUL.mdandconfig.yaml. Bound only whenis_bootstrap=True.update_agent- Custom-agent-only: persist self-updates to the current agent'sSOUL.md/config.yamlfrom inside a normal chat (partial update + atomic write). Bound whenagent_nameis set andis_bootstrap=False.
- Subagent tool (if enabled):
task- Delegate to subagent (description, prompt, subagent_type)
Scheduled-task runtime note:
- Scheduled background runs set
context.non_interactive=trueand therefore excludeask_clarificationfrom the lead-agent tool list. This keeps scheduler-triggered runs from stalling on human confirmation mid-execution.non_interactiveis an internal-only context key: it is merged frombody.contextonly when the request authenticated as the process-internal user (the scheduler path), never from arbitrary HTTP/IM clients.
Community tools (packages/harness/deerflow/community/): optional integrations, each in its own subpackage and wired through config.yaml. Documented examples:
tavily/- Web search (5 results default) and web fetch (4KB limit)jina_ai/- Web fetch via Jina reader API with readability extractionfirecrawl/- Web scraping via Firecrawl APIimage_search/- Image search via DuckDuckGoaio_sandbox/- Docker-based isolation (AioSandboxProvider)
Additional providers also live here (brave, browserless, crawl4ai, ddg_search, e2b_sandbox, exa, fastcrw, groundroute, infoquest, searxng, serper); see each subpackage for specifics.
ACP agent tools:
invoke_acp_agent- Invokes external ACP-compatible agents fromconfig.yaml- ACP launchers must be real ACP adapters. The standard
codexCLI is not ACP-compatible by itself; configure a wrapper such asnpx -y @zed-industries/codex-acpor an installedcodex-acpbinary - Missing ACP executables now return an actionable error message instead of a raw
[Errno 2] - Each ACP agent uses a per-thread workspace at
{base_dir}/users/{user_id}/threads/{thread_id}/acp-workspace/. The workspace is accessible to the lead agent via the virtual path/mnt/acp-workspace/(read-only). In docker sandbox mode, the directory is volume-mounted into the container at/mnt/acp-workspace(read-only); in local sandbox mode, path translation is handled bytools.py
MCP System (packages/harness/deerflow/mcp/)
- Uses
langchain-mcp-adaptersMultiServerMCPClientfor multi-server management - Lazy initialization: Tools loaded on first use via
get_cached_mcp_tools() - Cache invalidation: Detects config file changes via mtime comparison
- Transports: stdio (command-based), SSE, HTTP
- OAuth (HTTP/SSE): Supports token endpoint flows (
client_credentials,refresh_token) with automatic token refresh + Authorization header injection - Stdio file outputs: Persistent stdio sessions are scoped by
user_id:thread_id. For stdio transports only, DeerFlow pins the subprocess defaultcwdto the thread workspace andTMPDIR/TMP/TEMPtoworkspace/.mcp/tmp/, unless the operator explicitly configuredcwdor temp env values. SSE/HTTP transports skip this filesystem prep entirely. - Stdio path translation: MCP-returned local file references are not copied. If a
ResourceLinkor conservative free-text path resolves to an existing file inside the thread's mounted user-data tree, it is translated deterministically to/mnt/user-data/...; paths outside that tree remain unchanged. - Runtime updates: Gateway API saves to extensions_config.json; the Gateway-embedded runtime detects changes via mtime
Skills System (packages/harness/deerflow/skills/)
- Location:
deer-flow/skills/{public,custom}/ - Format: Directory with
SKILL.md(YAML frontmatter: name, description, license, allowed-tools, required-secrets) - Loading:
load_skills()recursively scansskills/{public,custom}forSKILL.md, parses metadata, and reads enabled state from extensions_config.json - Injection (legacy / default): Enabled skills are listed in the agent system prompt with full metadata and container paths (
<available_skills>block). Controlled byskills.deferred_discovery: false(default). - Deferred discovery (
skills.deferred_discovery: true): Skills are listed by name only in a compact<skill_index>block, keeping the system prompt prefix-cache friendly. The agent calls thedescribe_skilltool at runtime to fetch full metadata for skills it wants to use, then loads the SKILL.md viaread_file. Two new modules support this path:skills/catalog.py—SkillCatalog(immutable, searchable; query forms:select:a,b,+prefix, free-text regex);select:returns all requested skills without a result cap; other modes cap atMAX_RESULTS=5.skills/describe.py—build_describe_skill_tool(catalog)builds thedescribe_skilltool as a closure;build_skill_search_setup(skills, enabled, ...)produces aSkillSearchSetup(describe_skill_tool, skill_names)that is wired into both the LangGraph agent factory (agent.py) and the embedded client (client.py).
- Slash activation:
/skill-name taskloads that enabled skill'sSKILL.mdfor the current model call only. The resolver rejects leading whitespace, missing separators, reserved channel commands (/new,/help,/bootstrap,/status,/models,/memory,/goal), disabled skills, and skills outside a custom agent's whitelist. - Installation:
POST /api/skills/installextracts .skill ZIP archive to custom/ directory
Request-Scoped Secrets (required-secrets)
Lets a caller pass per-request, short-lived end-user credentials (e.g. an ERP token) to a skill's sandbox scripts without the value entering the prompt, tool arguments, the executed command string, or traces (issue #3861).
- Declare: a skill lists the secrets it needs in
SKILL.mdfrontmatter —required-secrets:as a string list or{name, optional}mappings.nameis both the lookup key and the env var name exposed to scripts. Parsed byskills/parser.py::parse_required_secretsintoSkill.required_secrets(SecretRequirement); malformed entries are dropped with a warning. - Carry: the caller sends values out-of-band in the run request's
context.secretsmapping (never a message).runtime/secret_context.pyowns the contract (SECRETS_CONTEXT_KEY,extract_request_secrets). The existingcontextpassthrough carries it toruntime.contextwithout mirroring intoconfigurable.build_run_configstill setsconfigurable.thread_idon the context path — the checkpointer requires it. - Bind (point A+):
SkillActivationMiddleware._resolve_secret_bindingsrecomputes the injection set (runtime.context[__active_skill_secrets]) on every model call from two unioned sources, then REPLACES the key. (1) Slash: the run's most recent/skillactivation, persisted as a source on the run context (only the activated skill's canonical container path, never its declared secrets) so the whole tool loop after the activation call keeps the binding; a new activation replaces it. Slash reads the genuine user text viaget_original_user_content_text;InputSanitizationMiddlewarepreserves it (ORIGINAL_USER_CONTENT_KEY), so activation fires even after sanitization. (2) In-context (autonomous invocation): skills the model actually loaded in this thread —ThreadState.skill_contextentries. Both sources resolve the live registry skill by normalized container path on every call (_resolve_registry_skill) and bind only that skill's own declared secrets — enabled + allowlist checked for both; thesecrets-autonomous: falseopt-out (malformed values fail closed tofalse) additionally gates the in-context path but exempts explicit slash. Resolving by registry — not by trusting the source's stored data — is what makes a caller-forged__slash_skill_secret_sourceharmless (runtime.contextis caller-mergeable; the gateway also strips caller__-keys inbuild_run_config), #3938. Authorization is three-gated regardless of activation style: skill enabled by the operator × values supplied per-request by the caller (context.secrets) × names declared in frontmatter (∩ semantics). Because the set is recomputed per call, a skill evicted fromskill_context(capacity) or a caller that stops supplying a value loses injection on the next call. The injected value always comes from the caller's request, never the host environment (scrubbed first — see below), so a declared name that also exists in the host env is safe: the caller's value wins and the host value is dropped (the #3861 per-user-key-overrides-shared-key case). Missing required secrets are logged once per binding change, not injected; binding changes are recorded as amiddleware:skill_secretsjournal event (skill and secret names only, never values). - Inject:
bash_toolreads the injection set and passes it asexecute_command(env=...). Scope is the activation turn/run only — a run without/skillactivation injects nothing. - AIO image requirement: on
AioSandboxthe env path uses thebash.execAPI (POST /v1/bash/exec), which upstream all-in-one-sandbox only ships since1.9.3— older images (including alatesttag frozen on the1.0.0.xline) 404 the whole/v1/bash/*namespace.AioSandboxdetects the 404, remembers the capability gap on the instance, and fails fast with an actionable upgrade error instead of letting the model retry raw 404s; there is deliberately no fallback through the legacy shell path because none keeps the secret values out of the command string (#3921). Regression tests:tests/test_aio_sandbox.py::TestBashExecUnsupportedFailFast. - Inherited-env scrub:
execute_commandno longer leaks the Gateway'sos.environto skill subprocesses —env_policy.build_sandbox_envdrops secret-looking names (*KEY*/*SECRET*/*TOKEN*/*PASSWORD*/*CREDENTIAL*/*DSN*+ a connection-string denylist likeDATABASE_URL/REDIS_URL/GH_PAT) so platform credentials never reach a skill; a skill that needs one must declare it. - Leak surfaces sealed (verified by a real-gateway e2e run — secret reaches the sandbox but none of these): prompt (value never in a message), trace (
tracing/metadata.pynever copiescontext), checkpoint (secrets live onruntime.context, not graph state), audit (journal records names only), stdout (tools.py::mask_secret_valuesredacts injected values from bash output), and run-record persistence + run API (services.py::start_runstoresredact_config_secrets(body.config)soruns.kwargs_jsonandRunResponse.kwargsnever carry the secret). - Scope / non-goals: no persistence/vaulting — values are request-scoped and never stored server-side, so long-lived use means the caller re-supplies
context.secretson each request while the skill stays inskill_context; subagents do not inherit the injection set; the MCP per-user-credential gap (#3322) is a sibling, not covered here. Tests:tests/test_skill_request_scoped_secrets.py.
Model Factory (packages/harness/deerflow/models/factory.py)
create_chat_model(name, thinking_enabled)instantiates LLM from config via reflection- Supports
thinking_enabledflag with per-modelwhen_thinking_enabledoverrides - Supports vLLM-style thinking toggles via
when_thinking_enabled.extra_body.chat_template_kwargs.enable_thinkingfor Qwen reasoning models, while normalizing legacythinkingconfigs for backward compatibility - Supports
supports_visionflag for image understanding models - Config values starting with
$resolved as environment variables - Missing provider modules surface actionable install hints from reflection resolvers (for example
uv add langchain-google-genai)
vLLM Provider (packages/harness/deerflow/models/vllm_provider.py)
VllmChatModelsubclasseslangchain_openai:ChatOpenAIfor vLLM 0.19.0 OpenAI-compatible endpoints- Preserves vLLM's non-standard assistant
reasoningfield on full responses, streaming deltas, and follow-up tool-call turns - Designed for configs that enable thinking through
extra_body.chat_template_kwargs.enable_thinkingon vLLM 0.19.0 Qwen reasoning models, while accepting the olderthinkingalias
IM Channels System (app/channels/)
Bridges external messaging platforms (Feishu, Slack, Telegram, Discord, DingTalk, GitHub) to the DeerFlow agent via Gateway's LangGraph-compatible API.
Architecture: Channels communicate with Gateway through the langgraph-sdk HTTP client (same as the frontend), ensuring threads are created and managed server-side. The internal SDK client injects process-local internal auth plus a matching CSRF cookie/header pair so Gateway accepts state-changing thread/run requests from channel workers without relying on browser session cookies.
Components:
message_bus.py- Async pub/sub hub (InboundMessage→ queue → dispatcher;OutboundMessage→ callbacks → channels)store.py- JSON-file persistence mappingchannel_name:chat_id[:topic_id]→thread_id(keys arechannel:chatfor root conversations andchannel:chat:topicfor threaded conversations)manager.py- Core dispatcher: creates threads viaclient.threads.create(), routes commands including/goal(setting a goal persists it through Gateway and then routes the objective as a chat turn), keeps Slack/Discord onclient.runs.wait(), usesclient.runs.stream(["messages-tuple", "values"])for Feishu/Telegram incremental outbound updates, and switches toclient.runs.create()(fire-and-forget, returns once the run ispending) for channels whoseChannelRunPolicy.fire_and_forget=Trueso long autonomous runs do not hit the SDK default 300shttpx.ReadTimeoutbase.py- AbstractChannelbase class (start/stop/send lifecycle)service.py- Manages lifecycle of all configured channels fromconfig.yamlslack.py/feishu.py/telegram.py/discord.py/dingtalk.py- Platform-specific implementations (feishu.pytracks the running cardmessage_idin memory and patches the same card in place;telegram.pyregisters the "Working on it..." placeholder as the stream target and edits it in place viaeditMessageText;dingtalk.pyoptionally uses AI Card streaming for in-place updates whencard_template_idis configured)github.py- Webhook-driven GitHub channel. Inbound messages come fromPOST /api/webhooks/github; outbound is log-only because GitHub agents post explicitly withghfrom their sandbox when they choose to comment or create a PRapp/gateway/routers/channel_connections.py- Browser-facing user connection and disconnect APIsdeerflow.persistence.channel_connections- SQL-backed user-owned connection, optional credential, connect state, and conversation store
Message Flow:
- External platform -> Channel impl ->
MessageBus.publish_inbound()- For GitHub, the webhook router verifies the delivery then calls
fanout_event(bus, ...); matching agent bindings publish oneInboundMessageeach instead of a long-polling channel worker.
- For GitHub, the webhook router verifies the delivery then calls
ChannelManager._dispatch_loop()consumes from queue- For user-owned channel connections, incoming messages carry
connection_id,owner_user_id, andworkspace_id;owner_user_idbecomes the DeerFlow runuser_id, while the raw platform user id remainschannel_user_id. The Gateway forwardschannel_user_idfrombody.contextinto the runtime context only (neverconfigurable, which is checkpointed), andbash_toolexposes it to sandbox commands as the fixed env varDEERFLOW_CHANNEL_USER_ID— via a shell-quoted command-string prefix, NOT theexecute_command(env=...)channel, which is reserved for request-scoped secrets and would switchAioSandboxonto thebash.execpath (image >= 1.9.3, fresh session per call). Per-call injection keeps group-chat identity correct (one thread/sandbox, many senders) without depending on the AIO shell's session semantics: every IM-channel command carries an explicitexport VAR=<id>;(valid id) orunset VAR;(empty / non-str / over the 256-char cap, sincebody.contextis client-writable). The AIO no-env path reuses a persistent shell session (the reason for the class lock, #1433), so a bare command could otherwise resolve a stale id an earlier sender exported; theunsetcloses the window the length/type guard would open (a dropped id would inherit the previous sender's value). Non-IM runs (nochannel_user_idin context) are left untouched. Not injected on the Windows local sandbox (its PowerShell/cmd.exe fallback has noexport/unset). Propagates acrosstaskdelegation:task_toolcaptures the dispatching turn's id and the subagent executor forwards it into the subagent's runtime context, same as the guardrail attribution fields. The var is informational, never authorization-grade: any bash command can overwrite it (and web clients can setbody.context.channel_user_id), so skills must not treat it as authenticated identity. Tests:tests/test_channel_user_id_env.py - For chat: look up/create thread through Gateway's LangGraph-compatible API
- Feishu/Telegram chat:
runs.stream()→ accumulate AI text → publish multiple outbound updates (is_final=False) → publish final outbound (is_final=True) - Slack/Discord chat:
runs.wait()→ extract final response → publish outbound 6b. GitHub chat (ChannelRunPolicy.fire_and_forget=True):runs.create()returns once the run ispending; the manager does not wait for the final state and does not publish an outbound. The agent posts its own reply mid-run viaghfrom the sandbox.ConflictErroron a busy thread still trips the standardTHREAD_BUSY_MESSAGEpath (log-only on GitHub). - Feishu channel sends one running reply card up front, then patches the same card for each outbound update (card JSON sets
config.update_multi=truefor Feishu's patch API requirement) - Telegram streaming: the "Working on it..." placeholder message is registered as the stream target; non-final updates
editMessageTextit in place (channel-side throttle: 1s in private chats, 3s in groups due to Telegram's 20 msg/min group cap; 4096-char truncation; rate-limited updates dropped); the final update performs the last edit and splits >4096 texts into follow-up messages - DingTalk AI Card mode (when
card_template_idconfigured):runs.stream()→ create card with initial text → stream updates viaPUT /v1.0/card/streaming→ finalize onis_final=True. Falls back tosampleMarkdownif card creation or streaming fails - For commands (
/new,/status,/models,/memory,/goal,/help): handle locally or query Gateway API - Outbound → channel callbacks → platform reply
- GitHub is the exception: the channel logs the final assistant message and does not auto-post it to GitHub. Agents use the sandbox
ghCLI (gh issue comment,gh pr comment,gh pr create, etc.) for intentional writeback, so silence is cheap when several agents fan out on the same event.
- GitHub is the exception: the channel logs the final assistant message and does not auto-post it to GitHub. Agents use the sandbox
Owner-scoped file storage: inbound files, uploads, and output artifacts are staged under the DeerFlow owner's bucket so they land where the agent run reads/writes (users/{user_id}/threads/{thread_id}/user-data/{uploads,outputs}). ChannelManager._handle_chat resolves the storage owner once via _channel_storage_user_id(msg) (sanitized owner id, falling back to safe(msg.user_id) for unbound auth-enabled channels — mirroring _resolve_run_params's run identity; None only when no identity is available) and threads it as the user_id= kwarg through the file pipeline:
Channel.receive_file(msg, thread_id, user_id=...)— owner-bound channels persist downloaded files under the owner's bucket instead of the default bucket_ingest_inbound_files(...)and the underlyingensure_uploads_dir/get_uploads_dir— owner-scoped via the same kwarg_resolve_attachments/_prepare_artifact_delivery— resolve output artifacts from the bound owner's bucket The cached value is reused for both the blocking (runs.wait) and streaming (_handle_streaming_chat) paths, so uploads and artifact delivery always target the same bucket even if a channel returns a rewrittenInboundMessagefromreceive_file. The bucket id matches the memory bucket resolved by_resolve_memory_user_id(both normalize throughmake_safe_user_id).
Configuration (config.yaml -> channels):
langgraph_url- LangGraph-compatible Gateway API base URL (default:http://localhost:8001/api)gateway_url- Gateway API URL for auxiliary commands (default:http://localhost:8001)- In Docker Compose, IM channels run inside the
gatewaycontainer, solocalhostpoints back to that container. Usehttp://gateway:8001/apiforlanggraph_urlandhttp://gateway:8001forgateway_url, or setDEER_FLOW_CHANNELS_LANGGRAPH_URL/DEER_FLOW_CHANNELS_GATEWAY_URL. - Per-channel configs:
feishu(app_id, app_secret),slack(bot_token, app_token),telegram(bot_token),dingtalk(client_id, client_secret, optionalcard_template_idfor AI Card streaming),github(operator kill-switchenabled, plusdefault_mention_loginfor mention-required GitHub triggers)
User-owned channel connections (config.yaml -> channel_connections):
- Disabled by default. It is a user-binding layer on top of the existing
channels.*runtime config, not a replacement for provider bot credentials. - No public IP, OAuth callback URL, or provider webhook route is required by the current implementation.
- Telegram uses a deep-link
/start <code>flow over the existing long-polling worker. Slack, Discord, Feishu/Lark, DingTalk, WeChat, and WeCom use/connect <code>over their existing outbound channel workers. - Frontend APIs:
GET /api/channels/providers,GET /api/channels/connections,POST /api/channels/{provider}/connect, andDELETE /api/channels/connections/{connection_id}. - Browser APIs remain protected by normal Gateway auth/CSRF. Provider messages arrive through the already-configured channel workers.
- Provider-level
connection_statusreflects the user's newest connection row. With no binding it isnot_connected, except in auth-disabled local mode where a configured running channel reportsconnectedbecause all channel messages already route to the default user. - Slack replies use the configured operator bot token from
channels.slackunless per-connection credentials are present; unreadable or corrupt stored credentials are treated as unavailable. - Telegram, Slack, Discord, Feishu/Lark, DingTalk, WeChat, and WeCom workers resolve incoming platform identities to connection records before reaching
ChannelManager. - Connect-code ordering vs
allowed_users: inbound workers consume a valid/connect <code>(or Telegram/start <code>) before applying theallowed_usersfilter, so a newly allowlisted-but-unbound user can bootstrap their first bind via the browser flow. Consequence:allowed_usersis not a bind-time defense — any sender who possesses a valid code can consume it (not only allowlisted users). The bind security model rests on the code's confidentiality:secrets.token_urlsafe(16), 600 s TTL, one-timeconsume_oauth_state, and codes surfaced only in the initiating browser (never echoed to chat).allowed_usersstill gates ordinary (non-bind) messages. - Single-active-owner transfer semantics: an external identity is keyed by
(provider, external_account_id, workspace_id). The latest successful bind wins —upsert_connectionrevokes other owners' active rows for the same identity (ownership transfer). This invariant is enforced at the DB layer by the partial unique indexuq_channel_connection_active_identity(WHERE status != 'revoked'), so concurrent connects from different owners cannot both endconnected; the losing writer retries against the now-visible state.find_connection_by_external_identitytherefore resolves deterministically. - See
backend/docs/IM_CHANNEL_CONNECTIONS.mdfor provider setup and operational notes.
GitHub event-driven agents:
- Configure agent-level bindings in a custom agent's
config.yamlundergithub:. The globalconfig.yamlchannels.githubblock is only for the operator kill-switch (enabled) and the default mention login; per-agentinstallation_id,bot_login, repo bindings, and triggers live with the custom agent. - Bindings are opt-in by event.
DEFAULT_TRIGGERSonly supplies per-event field defaults for events a binding declared.GitHubAgentConfigenforces a single binding per repo per agent; merge trigger maps instead of duplicating a repo. - Threading is deterministic: fan-out sets
metadata["preferred_thread_id"]from UUID5 over(repo, PR/issue number, agent_name), andChannelManager._create_threadpasses it toclient.threads.create(thread_id=...). Different agents on the same PR intentionally get different LangGraph threads. ChannelStore usestopic_id = f"{number}:{agent_name}"so each agent's cached mapping is independent. - Thread-create race recovery is narrow by design: only
langgraph_sdk.errors.ConflictError(HTTP 409) is treated as a concurrent-create collision and followed bythreads.get(preferred_thread_id)verification. Other create failures propagate so the delivery can fail/retry rather than caching an unverified mapping. - Mention-handle precedence for
require_mentiontriggers istrigger.mention_login→github.bot_login→channels.github.default_mention_login→agent.name. Whitespace-only defaults are treated as unset. - Set
GITHUB_APP_IDandGITHUB_APP_PRIVATE_KEY_PATH(orGITHUB_APP_PRIVATE_KEY) to enable installation-token minting.ChannelManagermints a short-lived installation token from the binding'sinstallation_idon the bus-consumer side and passes the token string inrun_context["github_token"]; the bash tool exposes it to sandbox commands asGH_TOKEN/GITHUB_TOKENvia per-callextra_env. No globalos.environmutation is used, so concurrent GitHub runs for different repos do not clobber each other. - Tokens are not auto-refreshed past GitHub's 1h TTL. Long-running agents may need to finish GitHub writes before expiry until refresh is reintroduced. If minting fails, the agent still runs without push/write credentials.
Memory System (packages/harness/deerflow/agents/memory/)
Components:
updater.py- LLM-based memory updates with fact extraction, whitespace-normalized fact deduplication (trims leading/trailing whitespace before comparing), and atomic file I/Oqueue.py- Debounced update queue (per-thread deduplication, configurable wait time); capturesuser_idat enqueue time so it survives thethreading.Timerboundaryprompt.py- Prompt templates for memory updatesstorage.py- File-based storage with per-user isolation; cache keyed by(user_id, agent_name)tuple
Per-User Isolation:
- 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 - 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_idis resolved viaget_effective_user_id()fromdeerflow.runtime.user_context- The
/api/memory*endpoints resolve the owner through_resolve_memory_user_id(request): trusted internal callers (IM channel workers carrying theX-DeerFlow-Owner-User-Idheader, e.g. a bound/memorycommand) act for the connection owner; browser/API callers fall back toget_effective_user_id(). The header is only honored afterAuthMiddlewarevalidated the internal token, mirroringget_trusted_internal_owner_user_idused by the threads router - In no-auth mode,
user_iddefaults to"default"(constantDEFAULT_USER_ID) - Absolute
storage_pathin config opts out of per-user isolation - Migration: Run
PYTHONPATH=. python scripts/migrate_user_isolation.pyto move legacymemory.json,threads/, andagents/into per-user layout. Supports--dry-run(preview changes) and--user-id USER_ID(assign unowned legacy data to a user, defaults todefault).
Data Structure (stored in {base_dir}/users/{user_id}/memory.json):
- User Context:
workContext,personalContext,topOfMind(1-3 sentence summaries) - History:
recentMonths,earlierContext,longTermBackground - Facts: Discrete facts with
id,content,category(preference/knowledge/context/behavior/goal),confidence(0-1),createdAt,source
Workflow:
MemoryMiddlewarefilters messages (user inputs + final AI responses), capturesuser_idviaget_effective_user_id(), and queues conversation with the captureduser_id- Queue debounces (30s default), batches updates, deduplicates per-thread
- Background thread invokes LLM to extract context updates and facts, using the stored
user_id(not the contextvar, which is unavailable on timer threads) - Applies updates atomically (temp file + rename) with cache invalidation, skipping duplicate fact content before append
- Next interaction injects top 15 facts + context into
<memory>tags in system prompt
Token counting (packages/harness/deerflow/agents/memory/prompt.py):
_count_tokensbudgets the injection. In defaulttiktokenmode, the encoding is loaded lazily and cached.- Failed tiktoken loads are cached with a timestamp. During the fixed cooldown (
_TIKTOKEN_RETRY_COOLDOWN_S, 600s), callers fall back to char estimation immediately instead of re-triggering the blocking BPE download; after the cooldown, transient outages can self-heal without a restart. - In-flight loads are cached as a LOADING sentinel so concurrent callers fall back instead of spawning more blocking threads.
- Set
memory.token_counting: charto skip tiktoken entirely and use the network-free CJK-aware char estimate.
Focused regression coverage for the updater lives in backend/tests/test_memory_updater.py.
Configuration (config.yaml → memory):
enabled/injection_enabled- Master switchesstorage_path- Path to memory.json (absolute path opts out of per-user isolation)debounce_seconds- Wait time before processing (default: 30)model_name- LLM for updates (null = default model)max_facts/fact_confidence_threshold- Fact storage limits (100 / 0.7)max_injection_tokens- Token limit for prompt injection (2000)token_counting- Token counting strategy for the injection budget:tiktoken(default, accurate but may download BPE data from a public endpoint on first use — can block for a long time in network-restricted environments, see issues #3402/#3429) orchar(network-free CJK-aware char estimate, never touches tiktoken)
Reflection System (packages/harness/deerflow/reflection/)
resolve_variable(path)- Import module and return variable (e.g.,module.path:variable_name)resolve_class(path, base_class)- Import and validate class against base class
Schema Migrations (packages/harness/deerflow/persistence/migrations/)
DeerFlow's application tables (runs, threads_meta, feedback, users, run_events, plus the four channel_* tables) are owned by alembic via a hybrid bootstrap strategy. LangGraph's checkpointer tables (checkpoints, checkpoint_blobs, checkpoint_writes, checkpoint_migrations) live in the same database but are owned by LangGraph and excluded from alembic's view via migrations/_env_filters.py::include_object.
Convention: every ORM model change (new column, new table, new index) MUST ship as an alembic revision under migrations/versions/. The Gateway runs alembic upgrade head automatically on startup; users do not run alembic manually in production.
Hybrid bootstrap (persistence/bootstrap.py::bootstrap_schema, invoked from persistence/engine.py::init_engine):
| DB state | Action |
|---|---|
| empty (no DeerFlow tables) | create_all + alembic stamp head |
legacy (DeerFlow tables, no alembic_version) |
create_all (baseline tables only, backfill) + alembic stamp 0001_baseline + upgrade head |
versioned (alembic_version row exists) |
alembic upgrade head |
The legacy branch handles pre-alembic databases that already have at least one DeerFlow-owned table. create_all runs first because stamping at 0001_baseline makes alembic skip the baseline's own create_table DDL on the subsequent upgrade — so any baseline table introduced into Base.metadata after the user's DB was first provisioned (e.g. the channel_* tables from PR #1930 for users upgrading across multiple releases) would otherwise never be created, and the first request hitting that table would 500 with no such table. The backfill is restricted to _BASELINE_TABLE_NAMES so it does not also create tables that future revisions introduce — those revisions' own op.create_table would otherwise fail with relation already exists. A guard test pins _BASELINE_TABLE_NAMES against 0001_baseline.upgrade()'s actual output, so editing 0001 to add or remove a table forces a matching update to the constant. Column-level shape (pre-#3658 vs post-#3658 vs manual-ALTER for token_usage_by_model) is answered by each versions/*.py revision via the idempotent helpers in migrations/_helpers.py (safe_add_column / safe_drop_column) which no-op when the change is already present and logger.warning on shape drift. Adding a new ORM column / table only requires a new revision file — no edit to bootstrap.py is needed unless the new revision adds a new baseline table (rare; only happens when a new model is part of the baseline rather than introduced by its own revision).
The empty-DB path keeps using create_all because Base.metadata is the only authoritative schema source — create_all renders both SQLite (JSON, type affinity) and Postgres (JSONB, partial indexes) correctly without anyone having to keep a hand-written baseline in lockstep. 0001_baseline.upgrade() is therefore almost never executed in practice; it exists as a stamp target + chain root.
Concurrency safety: Postgres uses pg_advisory_lock to serialise concurrent Gateway instances. SQLite uses a per-engine asyncio.Lock for same-process startup and is best-effort across processes via SQLite's file-level write lock + PRAGMA busy_timeout; multi-instance deployments should use Postgres. Column revisions in versions/ additionally use idempotent helpers (_helpers.py::safe_add_column, safe_drop_column) so repeated post-baseline changes and retries are no-ops when the change is already present.
Authoring a new revision:
cd backend && make migrate-rev MSG="add foo column to runs"
This invokes alembic revision --autogenerate against the live ORM models. Review the generated file under migrations/versions/ and switch raw op.add_column / op.drop_column calls to the idempotent helpers from _helpers.py before committing. There is no make migrate / make migrate-stamp target on purpose — the only execution path is Gateway startup, which keeps operational mistakes off the table.
Where things live:
migrations/env.py— alembic env, delegates filter to_env_filters.py, setsrender_as_batch=Truefor SQLite ALTER supportmigrations/_env_filters.py::include_object— drops LangGraph checkpointer tables from alembic's viewmigrations/_helpers.py—safe_add_column/safe_drop_columnmigrations/versions/0001_baseline.py— chain root, matches the schemacreate_allproduces fromBase.metadatamigrations/versions/0002_runs_token_usage.py— fixes issue #3682persistence/bootstrap.py—bootstrap_schema(engine, backend=...), the three-branch decision + locking- Tests:
tests/test_persistence_bootstrap.py(branches),tests/test_persistence_bootstrap_concurrency.py(concurrency),tests/test_persistence_bootstrap_regression.py(issue #3682),tests/test_persistence_migrations_env.py(filter),tests/blocking_io/test_persistence_bootstrap.py(asyncio.to_thread anchor)
Terminal Workbench / TUI (packages/harness/deerflow/tui/)
A terminal-native UI over the embedded harness, exposed as the deerflow console script ([project.scripts] in packages/harness/pyproject.toml). It is a UI shell over DeerFlowClient and does not fork agent behavior. textual is an optional dependency (deerflow-harness[tui]; also in the backend dev group); the console script degrades to headless help when it is absent. Full guide: docs/TUI.md.
Module layout (all layers except app.py are pure / Textual-free and unit-tested directly):
cli.py—plan_launch()(pure launch-mode decision) + headless--print/--json+main()entry point. TTY → TUI, else headless help. Uses an absolutefrom deerflow.tui.app import run_tuiso theapp.pymodule name doesn't triptest_harness_boundary.py(which records relative import module names verbatim).view_state.py—ViewState+reduce(state, action), the testable heart. Rows: user / assistant / tool / system. Title captured fromvaluesevents.runtime.py—translate(StreamEvent) -> [Action](pure) +stream_actions()which brackets a run withRunStarted/RunEndedand turns model errors into anAssistantErrorrow.message_format.py/command_registry.py/input_history.py/render.py/theme.py— pure helpers (tool summaries, slash registry +resolve(), ↑/↓ history, Rich renderers).app.py— TextualApp. RunsDeerFlowClient.stream()(sync) on a worker thread and marshals actions to the UI thread viacall_from_thread. Slash palette with/goalmanagement + model/thread modal pickers; priority key bindings gated bycheck_actionso they never steal keys from overlays or the composer.session.py/persistence.py— builds the client + checkpointer and theThreadMetaWriter.
Web UI visibility: the Web UI lists threads from the threads_meta SQL table (user-scoped), not the checkpointer. persistence.py writes a threads_meta row under the default user ("default") into the same DB the Gateway reads — via the harness-only deerflow.persistence.engine.init_engine_from_config() — so TUI sessions appear in the Web UI sidebar without running the Gateway. Best-effort: a no-op on the memory backend. All DB work runs on one long-lived background event loop (a SQLAlchemy async engine is bound to its creating loop).
Tests: tests/test_tui_*.py — pure layers via plain pytest, the app/palette/overlays via Textual's pilot harness with a fake in-process session, and test_tui_persistence.py for the threads_meta round-trip.
Request Trace Context (packages/harness/deerflow/trace_context.py)
Request trace correlation is controlled by logging.enhance.enabled at both entry points, gated through the shared helper deerflow.config.app_config.is_trace_correlation_enabled so the Gateway and embedded paths cannot drift:
- Gateway HTTP:
app.gateway.trace_middleware.TraceMiddlewarebinds one request-level trace id per HTTP request, inheriting inboundX-Trace-Idwhen present or generating a new id otherwise. The middleware writes the final value to every HTTP response athttp.response.start, which covers SSE / streaming responses without consuming the body. - Embedded / TUI / CLI:
DeerFlowClient.stream()mints (or inherits) a request-level trace id per turn only when the flag is on. When it is off, no fresh id is minted — a caller that explicitly wrapsstream()inrequest_trace_context(...)still opts in, because the downstreamget_current_trace_id()read propagates that value into Langfuse metadata regardless of the flag. Becausestream()is a sync generator (which shares the caller's context), the id binding is set/reset around eachnext()step rather than aroundyield from: this keeps LangGraph node execution and its log records inside the binding, while returning control to the caller with the ContextVar restored — avoids cross-request leak between yields andValueError: <Token> was created in a different Contexton GC-driven close of an abandoned generator (regression pinned bytests/test_client_langfuse_metadata.py::test_stream_does_not_leak_trace_id_to_caller_context_between_yieldsand::test_stream_abandoned_generator_close_does_not_raise_cross_context).
The same ContextVar value is injected into enhanced log records as trace_id and into Langfuse metadata as deerflow_trace_id.
logging is registered as a restart-required field
(STARTUP_ONLY_FIELDS["logging"]): configure_logging() installs the trace-context
filter and enhanced formatter on root handlers only during app.py lifespan startup,
and TraceMiddleware captures logging.enhance.enabled once when the FastAPI app
is constructed (via resolve_trace_enabled(get_app_config()) in create_app(),
itself a thin alias for is_trace_correlation_enabled). This keeps the response
X-Trace-Id header, log trace_id fields, and Langfuse deerflow_trace_id
coherent — a runtime config.yaml edit to logging.enhance.* needs a Gateway
restart to take effect. The deerflow_trace_id chain inherits this guarantee
transitively because every injection point ultimately reads the same
trace_context ContextVar that the middleware alone populates. DeerFlowClient
reads its own self._app_config snapshot (captured at __init__) through the
same helper for the embedded gate.
deerflow_trace_id is a DeerFlow correlation metadata key, not Langfuse's native
trace id and not a DeerFlow run_id. Keep the existing subagent trace_id field
separate: that short id is still only for subagent execution logs/status.
Tracing System (packages/harness/deerflow/tracing/)
LangSmith and Langfuse are both supported. The wiring lives in two layers:
factory.py::build_tracing_callbacks()— returns the LangChainCallbackHandlerlist for the providers currently enabled via env vars (LANGSMITH_TRACING,LANGFUSE_TRACING, etc.). The handlers are attached at the graph invocation root for in-graph runs (make_lead_agentandDeerFlowClient.streamboth append them toconfig["callbacks"]before invoking the graph) so a single run produces one trace with all node / LLM / tool calls as child spans. Standalone callers — anything that invokes a model outside such a graph (e.g.MemoryUpdater) — keepcreate_chat_model's defaultattach_tracing=True, which falls back to model-level callback attachment.metadata.py::build_langfuse_trace_metadata()— builds the Langfuse-reserved trace attributes forRunnableConfig.metadata. The Langfuse v4langchain.CallbackHandlerlifts these onto the root trace (see its_parse_langfuse_trace_attributes), but only when it seeson_chain_start(parent_run_id=None)— which is why the callbacks have to live at the graph root, not the model.
Trace-attribute injection points: both runtime/runs/worker.py::run_agent (gateway path) and client.py::DeerFlowClient.stream (embedded path) merge the metadata into config["metadata"] right before constructing the graph. subagents/executor.py::_aexecute does the same for every subagent run so subagent traces group under the parent thread's session card (carrying the parent thread_id → langfuse_session_id, the user_id captured at task_tool → langfuse_user_id, and a subagent:<normalized-name> trace name). Caller-supplied keys win via setdefault, so an external session_id override is preserved. Field mapping:
| Langfuse field | Source |
|---|---|
langfuse_session_id |
LangGraph thread_id |
langfuse_user_id |
get_effective_user_id() (default in no-auth); for subagents, captured from runtime.context at task_tool time via resolve_runtime_user_id() |
langfuse_trace_name |
RunRecord.assistant_id / client agent_name (defaults to lead-agent); for subagents, subagent:<name> (lowercased, _ → -) |
langfuse_tags |
env:<DEER_FLOW_ENV> + model:<model_name> |
deerflow_trace_id |
Current request/entry trace id from deerflow.trace_context; matches X-Trace-Id for enhanced Gateway HTTP requests. Gated by logging.enhance.enabled in both gateway and embedded paths via is_trace_correlation_enabled — off by default; embedded callers can still opt in per-turn by wrapping stream() in request_trace_context(...) |
Returns {} when Langfuse is not in the enabled providers — LangSmith-only deployments are unaffected. Set DEER_FLOW_ENV (or ENVIRONMENT) to tag traces by deployment environment. Tests live in tests/test_tracing_factory.py, tests/test_tracing_metadata.py, tests/test_worker_langfuse_metadata.py, tests/test_client_langfuse_metadata.py, and tests/test_subagent_executor.py::TestSubagentTracingWiring.
Config Schema
config.yaml key sections:
models[]- LLM configs withuseclass path,supports_thinking,supports_vision, provider-specific fieldslogging.enhance- Optional request trace correlation (enabled,format) for GatewayX-Trace-Id, logtrace_id, and Langfusedeerflow_trace_id- vLLM reasoning models should use
deerflow.models.vllm_provider:VllmChatModel; for Qwen-style parsers preferwhen_thinking_enabled.extra_body.chat_template_kwargs.enable_thinking, and DeerFlow will also normalize the olderthinkingalias tools[]- Tool configs withusevariable path andgrouptool_groups[]- Logical groupings for toolssandbox.use- Sandbox provider class pathskills.path/skills.container_path- Host and container paths to skills directoryskills.deferred_discovery- Whentrue, replaces the full-metadata<available_skills>prompt block with a compact<skill_index>(names only) and registers thedescribe_skilltool so the agent fetches metadata on demand. Defaults tofalse(legacy full-metadata injection)title- Auto-title generation (enabled, max_words, max_chars, model_name; null model_name uses fast local fallback, explicit model_name uses the prompt_template LLM path)summarization- Context summarization (enabled, trigger conditions, keep policy)subagents.enabled- Master switch for subagent delegationmemory- Memory system (enabled, storage_path, debounce_seconds, model_name, max_facts, fact_confidence_threshold, injection_enabled, max_injection_tokens)
extensions_config.json:
mcpServers- Map of server name → config (enabled, type, command, args, env, url, headers, oauth, description)skills- Map of skill name → state (enabled)
Both can be modified at runtime via Gateway API endpoints or DeerFlowClient methods.
Embedded Client (packages/harness/deerflow/client.py)
DeerFlowClient provides direct in-process access to all DeerFlow capabilities without HTTP services. All return types align with the Gateway API response schemas, so consumer code works identically in HTTP and embedded modes.
Architecture: Imports the same deerflow modules that Gateway API uses. Shares the same config files and data directories. No FastAPI dependency.
Agent Conversation:
chat(message, thread_id)— synchronous, accumulates streaming deltas per message-id and returns the final AI textstream(message, thread_id)— subscribes to LangGraphstream_mode=["values", "messages", "custom"]and yieldsStreamEvent:"values"— full state snapshot (title, messages, artifacts); AI text already delivered viamessagesmode is not re-synthesized here to avoid duplicate deliveries"messages-tuple"— per-chunk update: for AI text this is a delta (concat peridto rebuild the full message); tool calls and tool results are emitted once each"custom"— forwarded fromStreamWriter"end"— stream finished (carries cumulativeusagecounted once per message id)
- Agent created lazily via
create_agent()+build_middlewares(), same asmake_lead_agent - Supports
checkpointerparameter for state persistence across turns reset_agent()forces agent recreation (e.g. after memory or skill changes)- See docs/STREAMING.md for the full design: why Gateway and DeerFlowClient are parallel paths, LangGraph's
stream_modesemantics, the per-id dedup invariants, and regression testing strategy
Gateway Equivalent Methods (replaces Gateway API):
| Category | Methods | Return format |
|---|---|---|
| Models | list_models(), get_model(name) |
{"models": [...]}, {name, display_name, ...} |
| MCP | get_mcp_config(), update_mcp_config(servers) |
{"mcp_servers": {...}} |
| Skills | list_skills(), get_skill(name), update_skill(name, enabled), install_skill(path) |
{"skills": [...]} |
| Goals | get_goal(thread_id), set_goal(thread_id, objective, max_continuations=8), clear_goal(thread_id) |
{"goal": {...}} or {"goal": None} |
| Memory | get_memory(), reload_memory(), get_memory_config(), get_memory_status() |
dict |
| Uploads | upload_files(thread_id, files), list_uploads(thread_id), delete_upload(thread_id, filename) |
{"success": true, "files": [...]}, {"files": [...], "count": N} |
| Artifacts | get_artifact(thread_id, path) → (bytes, mime_type) |
tuple |
Key difference from Gateway: Upload accepts local Path objects instead of HTTP UploadFile, rejects directory paths before copying, and reuses a single worker when document conversion must run inside an active event loop. Artifact returns (bytes, mime_type) instead of HTTP Response. The new Gateway-only thread cleanup route deletes .deer-flow/threads/{thread_id} after LangGraph thread deletion; there is no matching DeerFlowClient method yet. update_mcp_config() and update_skill() automatically invalidate the cached agent.
Tests: tests/test_client.py (77 unit tests including TestGatewayConformance), tests/test_client_live.py (live integration tests, requires config.yaml)
Gateway Conformance Tests (TestGatewayConformance): Validate that every dict-returning client method conforms to the corresponding Gateway Pydantic response model. Each test parses the client output through the Gateway model — if Gateway adds a required field that the client doesn't provide, Pydantic raises ValidationError and CI catches the drift. Covers: ModelsListResponse, ModelResponse, SkillsListResponse, SkillResponse, SkillInstallResponse, McpConfigResponse, UploadResponse, MemoryConfigResponse, MemoryStatusResponse.
Development Workflow
Test-Driven Development (TDD) — MANDATORY
Every new feature or bug fix MUST be accompanied by unit tests. No exceptions.
- Write tests in
backend/tests/following the existing naming conventiontest_<feature>.py - Run the full suite before and after your change:
make test - Tests must pass before a feature is considered complete
- For lightweight config/utility modules, prefer pure unit tests with no external dependencies
- If a module causes circular import issues in tests, add a
sys.modulesmock intests/conftest.py(see existing example fordeerflow.subagents.executor)
# Run all tests
make test
# Run a specific test file
PYTHONPATH=. uv run pytest tests/test_<feature>.py -v
Running the Full Application
From the project root directory:
make dev
This starts all services and makes the application available at http://localhost:2026.
All startup modes:
| Local Foreground | Local Daemon | Docker Dev | Docker Prod | |
|---|---|---|---|---|
| Dev | ./scripts/serve.sh --devmake dev |
./scripts/serve.sh --dev --daemonmake dev-daemon |
./scripts/docker.sh startmake docker-start |
— |
| Prod | ./scripts/serve.sh --prodmake start |
./scripts/serve.sh --prod --daemonmake start-daemon |
— | ./scripts/deploy.shmake up |
| Action | Local | Docker Dev | Docker Prod |
|---|---|---|---|
| Stop | ./scripts/serve.sh --stopmake stop |
./scripts/docker.sh stopmake docker-stop |
./scripts/deploy.sh downmake down |
| Restart | ./scripts/serve.sh --restart [flags] |
./scripts/docker.sh restart |
— |
Nginx routing:
/api/langgraph/*→ Gateway embedded runtime (8001), rewritten to/api/*/api/*(other) → Gateway API (8001)/(non-API) → Frontend (3000)
Running Backend Services Separately
From the backend directory:
# Gateway API
make gateway
Direct access (without nginx):
- Gateway:
http://localhost:8001
Frontend Configuration
The frontend uses environment variables to connect to backend services:
NEXT_PUBLIC_LANGGRAPH_BASE_URL- Defaults to/api/langgraph(through nginx)NEXT_PUBLIC_BACKEND_BASE_URL- Defaults to empty string (through nginx)
When using make dev from root, the frontend automatically connects through nginx.
Key Features
File Upload
Multi-file upload with automatic document conversion:
- Endpoint:
POST /api/threads/{thread_id}/uploads - Supports: PDF, PPT, Excel, Word documents (converted via
markitdown) - Rejects directory inputs before copying so uploads stay all-or-nothing
- Reuses one conversion worker per request when called from an active event loop
- Files stored in thread-isolated directories under the resolving user's bucket (
users/{user_id}/threads/{thread_id}/user-data/uploads). For IM channels the owner is threaded explicitly via theuser_id=kwarg (see IM Channels → Owner-scoped file storage); HTTP/embedded callers resolve it fromget_effective_user_id() - Duplicate filenames in a single upload request are auto-renamed with
_Nsuffixes so later files do not truncate earlier files - Gateway HTTP uploads stage bytes as
.upload-*.partfiles and atomically replace the destination only after size validation. These staging files are hidden from upload listings, agent upload context, and sandbox listing/search tools, and swept on Gateway startup if a hard crash leaves one behind. - Gateway HTTP upload/list/delete handlers offload filesystem work through
deerflow.utils.file_io.run_file_io, a dedicated ContextVar-preserving file IO executor. Non-mounted sandbox uploads acquire sandboxes withSandboxProvider.acquire_async()and offloadread_bytes()plussandbox.update_file()together. - Agent receives uploaded file list via
UploadsMiddleware
See docs/FILE_UPLOAD.md for details.
Plan Mode
TodoList middleware for complex multi-step tasks:
- Controlled via runtime config:
config.configurable.is_plan_mode = True - Provides
write_todostool for task tracking - One task in_progress at a time, real-time updates
See docs/plan_mode_usage.md for details.
Context Summarization
Automatic conversation summarization when approaching token limits:
- Configured in
config.yamlundersummarizationkey - Trigger types: tokens, messages, or fraction of max input
- Keeps recent messages while summarizing older ones
See docs/summarization.md for details.
Vision Support
For models with supports_vision: true:
ViewImageMiddlewareprocesses images in conversationview_image_tooladded to agent's toolset- Images automatically converted to base64 and injected into state
Code Style
- Uses
rufffor linting and formatting - Line length: 240 characters
- Python 3.12+ with type hints
- Double quotes, space indentation
Documentation
See docs/ directory for detailed documentation:
- CONFIGURATION.md - Configuration options
- ARCHITECTURE.md - Architecture details
- API.md - API reference
- SETUP.md - Setup guide
- FILE_UPLOAD.md - File upload feature
- PATH_EXAMPLES.md - Path types and usage
- summarization.md - Context summarization
- plan_mode_usage.md - Plan mode with TodoList