deer-flow/scripts/AGENTS.md

16 KiB

Service Startup Contracts

The root PORT value configures Docker's published nginx ingress only; local orchestration pins Next.js to 3000. Runtime commands launch from the already synchronized environment with uv run --no-sync. Production Compose probes Gateway /health, and deploy.sh waits for all services before reporting success; failures print Compose status and recent Gateway logs.

Shell Script Invocation Contract

Root Makefile recipes must invoke repository .sh files through RUN_SHELL_SCRIPT. On POSIX this expands to $(BASH); on Windows it uses the Git Bash wrapper. Shell scripts that invoke sibling repository scripts must likewise prefix the target with bash. This keeps documented make commands working when a source archive, core.fileMode=false, or a non-POSIX filesystem does not preserve executable bits.

Public Skill Review Waivers

review_changed_public_skills.py keeps the analyzer strict and applies narrow CI-only exceptions from .github/skill-review-waivers.v1.json. The manifest is versioned by contracts/skill_review/waiver_manifest.v1.schema.json; each entry must identify one current error by package, source, rule, path, line, and evidence, and pin the complete source file with SHA-256 plus an expiry date. An optional, bounded preapproved_file_sha256s list authorizes reviewed future full-file digests without relaxing the exact finding match. Blockers are never waivable, and waived errors are still printed with their original severity and justification.

For pull requests, only the base revision's manifest is effective. The head manifest is parsed and checked against the current analyzer output, but cannot self-authorize a finding in the same pull request. Push comparisons use the same before/after trust boundary. A waiver-only change can therefore land without weakening its own check, then become effective for later changes after it is part of the trusted base. Preapproved digests must be code-reviewed in that first change; after the corresponding file revision lands, promote the consumed digest to file_sha256 and remove it from the preapproval list.

Backend Static Analysis Commands

The root detect-thread-boundaries target statically inventories execution boundaries under backend/app/ and backend/packages/harness/deerflow/. It prints a concise count by execution domain and writes the complete, versioned JSON payload to .deer-flow/thread-boundary-inventory.json. Every finding has a stable boundary_kind: asyncio_default_executor, dedicated_executor, anyio_worker_thread, direct_event_loop_blocking, separate_event_loop, or unresolved_dynamic_boundary.

The AST inventory covers asyncio.to_thread, default and explicit run_in_executor submissions, imported aliases, simple same-module helper wrappers (after pre-registering dedicated executor targets), set_default_executor, ThreadPoolExecutor construction/submission, additional event loops, synchronous LangChain tools, and direct BaseChatModel fallback inheritance. It remains read-only and does not alter executor routing or sizing.

To supplement the static scan with configured runtime types, run:

python scripts/detect_thread_boundaries.py \
  --runtime-config config.yaml \
  --json-output .deer-flow/thread-boundary-inventory.json

Runtime inspection imports configured tool objects and model classes so it can record concrete tool names/types/modules, sync functions, async coroutines, and _agenerate/_astream ownership. It does not invoke tools, instantiate models, or call external services; import failures remain in the JSON as unresolved_dynamic_boundary records. The detector implementation and focused coverage live in tests/support/detectors/thread_boundaries.py and tests/test_detect_thread_boundaries.py.

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. The call graph also resolves multi-hop self./cls. attribute chains (self.store.flush()) and local variables or parameters traced back — within the same function only — to a self./cls. attribute (store = self.store; store.flush()); both fall back to the same bare-method-name resolution as an unresolvable receiver, so they share its over-report risk rather than adding a new kind. Deeper cross-function or cross-module aliasing is out of scope and stays an unreported false negative.

That same-function alias tracing is deliberately narrower than the symbolic names dotted_name() builds for blocking-call pattern matching elsewhere in this module: receiver/alias extraction uses a restricted extractor that only recognizes Name/Attribute chains, so a Call or Subscript result (e.g. factory().flush(), or client = factory(); client.flush() / client = clients[0]; client.flush()) is never treated as inheriting its base's alias-worthiness — including when the unsupported node is buried deeper in the chain (factory().client.flush(), clients[0].client.flush()): an unrecognized shape anywhere in the chain makes the whole receiver unresolved, it never falls back to just the chain's trailing attribute name, or that name alone could still collide with an unrelated traced parameter or local alias. Reassigning a traced name to a non-traceable value (anything other than a self./cls. attribute or an already-traced name) kills its alias instead of leaving it traceable, so a stale alias from an earlier assignment cannot keep exposing an unrelated same-named method after the variable is reassigned to something else; the assignment's right-hand side is always analyzed against the alias state as it stood before this kill-or-add update, matching Python's own evaluate-then-bind order, so client = client.flush() still resolves that call against client's prior (pre-reassignment) alias instead of the state after it's gone. if/else branches get isolated alias state — an alias added in one branch cannot leak into the other — and the state after the whole if is the union of what each branch produced (a conservative may-alias join), so the result no longer depends on which branch is textually body vs. orelse. This branch isolation is deliberately scoped to ast.If only; ast.Try/ast.Match have different, more complex control-flow semantics and keep the older unisolated traversal. Finally, a function's decorators and parameter defaults are analyzed in the enclosing scope rather than the new function's own, and parameter/return annotations get the same enclosing-scope treatment unless the module postpones annotation evaluation (from __future__ import annotations), in which case they are skipped entirely, in either scope — those expressions run at definition time, before the function has ever been called (or, when postponed, never run at all), so a call there is never attributed to the function being defined (it moves to whatever scope actually contains the def, e.g. the enclosing function, or disappears if that scope is module/class level and therefore never async-reachable). PEP 695 type-parameter bounds are not visited in either scope: CPython evaluates each one lazily, in its own hidden function, only if something like T.__bound__ is actually accessed, never as part of running the def statement itself. A lambda's body and a bare generator expression's element/filters/later for clauses are excluded from traversal ONLY while walking another function's own definition-time expressions (decorators, parameter defaults/ annotations, return annotation): there, we know structurally that the enclosing def statement is executing right now, and neither a lambda body nor a generator's element runs just because the lambda/generator object is created — only a lambda's own parameter defaults and a generator's outermost iterable are genuinely eager at that moment. This exclusion is absolute and has no exceptions: even a lambda that is immediately invoked at its own definition site ((lambda: ...)()), or a generator passed directly to an eager-consuming builtin, is still excluded when it appears inside another function's decorator/default/annotation — a narrow, intentional limitation given how rarely a definition-time expression contains an executed call at all, preferred over special-casing specific shapes there.

Everywhere else — module level, class bodies, and ordinary function-body statements — a lambda body or generator expression's element is scanned unconditionally, the same conservative, over-report-rather-than-infer stance this file already takes for reachability elsewhere (the ast.If may-alias union, the bare-name call-graph resolution). This file does not attempt to distinguish a lambda that is invoked immediately, invoked later through a stored variable, passed as a callback, or never called at all, nor a generator that is consumed by an eager builtin (list, sum, any, etc.), wrapped in another lazy iterator (map, filter), or never consumed — telling these apart in the general case would mean inferring evaluation order and consumption across arbitrary code rather than reading a fixed, structural fact, so none of them are special-cased; all are scanned the same way. This 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 from config.yaml)
  • tests/test_provisioner_kubeconfig.py (kubeconfig file/directory handling)
  • tests/test_provisioner_request_threading.py (keeps provisioner sandbox CRUD endpoints as sync FastAPI handlers so synchronous K8s client calls run in the Starlette worker pool instead of on the ASGI event loop)

Blocking-IO runtime gate (tests/blocking_io/):

  • Wraps every item under tests/blocking_io/ with a strict Blockbuster context scoped to app.* and deerflow.* (see tests/support/detectors/blocking_io_runtime.py). Any sync blocking IO call whose stack passes through DeerFlow business code while running on the asyncio event loop raises BlockingError and fails the test.
  • Regression anchors live there: test_skills_load.py (locks the asyncio.to_thread offload around LocalSkillStorage.load_skills, fix for #1917); test_sqlite_lifespan.py (locks the offload around SQLite path resolution plus ensure_sqlite_parent_dir, fix for #1912); test_jsonl_run_event_store.py (locks JsonlRunEventStore's async API — including idempotent singleton-event writes — offloading its file IO via asyncio.to_thread); test_run_journal_callbacks.py (locks RunJournal.run_inline tool callbacks to in-memory/event-loop-safe work); test_integrations_router.py (locks Lark integration install and auth completion route handlers offloading archive filesystem work and lark-cli subprocesses); test_uploads_middleware.py (locks UploadsMiddleware.abefore_agent offloading the uploads-directory scan off the event loop); test_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_feishu_receive_file.py (locks Feishu attachment path preparation and persistence plus remote sandbox acquisition/sync off the event loop, and skips redundant sandbox sync when thread data is already mounted); test_channel_outbound_files.py (locks Feishu, Telegram, and WeCom outbound attachment open/read/hash work off the event loop); test_openviking_memory_backend.py (locks the official OpenViking backend's async add/context/search entrypoints offloading synchronous SDK and cursor filesystem IO); and test_workspace_changes_recorder.py (locks the offload around the snapshot text cache lifecycle — roots resolution, mkdtemp, and the shutil.rmtree on both the capture-failure branch and record_workspace_changes' finally).
  • test_gate_smoke.py is a meta-test asserting the gate actually catches unoffloaded blocking IO and that the @pytest.mark.allow_blocking_io opt-out works.
  • Coverage boundary: the gate only sees code that test execution actually touches. Static AST coverage is a separate concern (out of scope for this PR).
  • CI: runs on every PR via .github/workflows/backend-blocking-io-tests.yml, hard-fail.

Boundary check (harness → app import firewall):

  • tests/test_harness_boundary.py — ensures packages/harness/deerflow/ never imports from app.*

Memory backend async boundary:

  • MemoryMiddleware.aafter_agent calls MemoryManager.aadd; network-backed managers must override their a* methods to offload or use native async I/O.
  • The mem0 backend requires an HTTPS base_url by default because requests carry an API token. Plain HTTP requires the explicit backend_config.allow_insecure_http: true local-development opt-in.
  • Gateway memory routes offload the synchronous management contract with asyncio.to_thread, so backend file or HTTP I/O does not run on the ASGI event loop. Gateway startup and shutdown also resolve the manager off-loop, because a backend's from_config may perform a fail-fast connectivity check.
  • A backend may set requires_passive_writes_in_tool_mode = True when tool-mode search is supported but durable writes still depend on conversation-level extraction. Such backends receive memory tools and retain MemoryMiddleware.
  • Prompt recall rethrows MemoryManagerError only when backend config declares failure_policy.read: fail_closed; other recall errors preserve the existing log-and-empty-context behavior.

CI runs these regression tests for every pull request via .github/workflows/backend-unit-tests.yml.

Agentic browser sessions are process-local. The Gateway startup safety gate rejects GATEWAY_WORKERS > 1 when browser_navigate is configured, because ordinary uvicorn worker dispatch does not provide thread affinity for browser tools, REST navigation, and the Live WebSocket.

Browser Live screenshots remain JPEG bytes inside the harness and the Gateway's bounded, drop-oldest frame queue. WebSocket clients that request frame_format=binary receive binary messages; control metadata remains JSON. The legacy no-parameter protocol still base64-encodes frames into JSON at the Gateway boundary for backward compatibility. Unknown frame_format values receive a JSON error and close code 1008.