Zhou Kai e4a7a04719
feat(subagents): add isolated date-only context (#4797)
* feat(subagents): inject date-only runtime context

* refactor(middleware): deduplicate date reminder formatting
2026-08-13 23:37:46 +08:00

27 KiB

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):

  1. InputSanitizationMiddleware - First, so it is the outermost wrap_model_call wrapper; every inner middleware (including LLM retries) sees sanitized messages. additional_kwargs.original_user_content is server-owned provenance: Gateway strips caller-supplied values for non-internal run requests, trusted IM calls may carry the string they captured before adding transport/file context, and the middleware replaces any non-string value before wrapping. Uploads and sanitization retain first-writer-wins only for validated strings.
  2. ToolOutputBudgetMiddleware - Caps tool output size (per app config) before it re-enters the model context. Oversized results are externalized to tool_output.storage_subdir (default .tool-results, shared constant TOOL_RESULTS_DIRNAME) under the thread outputs dir with a typed synopsis + read_file reference left in context; those files are process feedback, so the workspace-changes scanner excludes that directory and run delivery verification never counts them as produced artifacts
  3. ToolResultSanitizationMiddleware - Neutralizes framework/injection tags (e.g. <system-reminder>) and boundary markers in remote-content tool results (web_fetch/web_search/image_search/web_capture) so attacker-controlled fetched pages cannot forge trusted framework context. Mirrors InputSanitizationMiddleware's user-input guardrail for the other untrusted-content entry point; sits inner of ToolOutputBudgetMiddleware (neutralizes the raw output, then the budget truncates). Local tool output (bash/read_file) is left untouched. Scope is a name-based allowlist, so MCP remote-content tools registered under other names (e.g. fetch_url) are not yet covered — a metadata-tagging follow-up is tracked in the middleware source
  4. ThreadDataMiddleware - Creates per-thread directories under the user's isolation scope (backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/{workspace,uploads,outputs}); resolves identity via resolve_runtime_user_id(runtime), including Gateway runtime context and standalone LangGraph Server auth, then falls back to the request ContextVar / "default"
  5. UploadsMiddleware - Tracks and injects newly uploaded files into conversation (lead agent only); upload existence checks use the same runtime-resolved user bucket as thread-data creation
  6. SandboxMiddleware - Acquires sandbox, stores sandbox_id in state
  7. 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"]; malformed tool-call names and arguments are sanitized in the model-bound request so strict OpenAI-compatible providers do not reject the next request
  8. LLMErrorHandlingMiddleware - Normalizes provider/model invocation failures into recoverable assistant-facing errors before later stages run
  9. Authorization / GuardrailMiddleware - Up to two independent pre-tool-call gates run here. When authorization.enabled, the AuthorizationProvider instance already used for Layer 1 capability filtering is wrapped by GuardrailAuthorizationAdapter and reused for Layer 2 execution checks. A generated tool_search bypasses the adapter's second provider call only when the current build has a concrete deferred setup; its catalog was already filtered by Layer 1, and an ordinary same-named tool without that deferred setup receives no exemption. When guardrails.enabled, the explicitly configured GuardrailProvider is appended after authorization and still evaluates every call, including tool_search. Authorization therefore runs outermost and can deny before an external guardrail call; both use the existing middleware's fail-closed, audit, sync/async, and error-ToolMessage behavior. See the authorization RFC and docs/GUARDRAILS.md.
  10. SandboxAuditMiddleware - Audits sandboxed shell/file operations for security logging before tool execution. Command classification is defense-in-depth and audit, not a security boundary — the sandbox itself is the isolation boundary. Command substitution is judged by position, not by the presence of $(: a substitution in command position ($(curl url), `curl url`, the word after a |/&&/;, or any eval/source argument) executes fetched or interpreted content and is blocked, while value position (x=$(curl url), echo $(curl url), an argument, a for word list) only captures output and passes (#4611). _HIGH_RISK_COMMAND_POSITION_PATTERNS is therefore matched anchored against each split sub-command, never against the whole compound string, and _split_compound_command(split_pipes=True) supplies those sub-commands; rules that span a pipe (| sh, base64 -d | ...) still rely on _classify_command's whole-command Pass 1. _COMMAND_POSITION_PREFIX extends the anchor over leading variable assignments and exec wrappers (FOO=1 $(curl url), env/command/builtin/exec/nohup/time/sudo/doas), which are still command position; its assignment branch requires whitespace before the substitution, which is exactly what keeps x=$(curl url) in value position. Two execution contexts are deliberately position-blind and matched against the whole command in Pass 1, because they execute what they receive wherever they appear (including as an argument to something else, e.g. xargs sh -c "$(curl url)"): an eval/source argument, and an interpreter's code-string flag-c (shells, python), -e (perl/ruby/node), -p (perl/node), -r (php) — plus the here-string (<<<) that reaches the same place through stdin. All three substitution spellings ($(cmd, <(cmd, `cmd) share one _RISKY_SUBSTITUTION opener so a rule cannot cover one spelling and miss another. An unquoted newline splits like ;, because it separates statements the same way: leaving it joined let echo hi\n$(curl url) evade the anchored rules that its ; spelling triggers. A heredoc body is data rather than statements, so _split_compound_command records headers (<<EOF, <<-EOF, <<'EOF') and consumes their bodies verbatim at the newline that starts them — otherwise a body line beginning with $(curl url) would be promoted to a command position the shell never creates. Two things that look like headers must not open one, or a body that never terminates swallows every following statement: <<< is a here-string (both a lookahead and a lookbehind are needed, or the trailing << of <<< "text" reads as a heredoc with delimiter text), and a << inside $(( ... )) / (( ... )) is a bit shift, so arithmetic depth is tracked alongside the quote flags. That is a heuristic, not shell parsing: it exists only to avoid manufacturing command positions and to avoid destroying real ones. An unterminated body consumes the rest of the string; an unclosed (( only disables heredoc detection, so newlines keep splitting and the failure direction stays towards seeing more command positions rather than fewer. Known, deliberate gaps: process substitution outside eval/source (. <(curl u)) is not detected — closing it would require real shell parsing, which is out of scope for this layer. Two-step forms (x=$(curl u); eval "$x") are inherent rather than incidental: any rule that allows output capture allows the first statement, and connecting it to the later eval needs dataflow analysis, not pattern matching. There is currently no config gate: the middleware is appended unconditionally in _build_runtime_middlewares, so it applies to both the lead agent and subagents.
  11. ReadBeforeWriteMiddleware - (optional, if read_before_write.enabled, default on) Outermost write gate (issue #3857): read_file stamps a content hash onto its ToolMessage; write_file (append/overwrite-existing) and str_replace are blocked unless the newest mark for that path matches the file's current hash. Sits outside ToolProgressMiddleware and ToolErrorHandlingMiddleware so a blocked write returns immediately without consuming a ToolProgress slot. Blocked results call normalize_tool_result directly to stamp deerflow_tool_meta (recoverable_by_model=True) before returning, keeping the result well-formed for any outer consumer. 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 whose read_file reports failures as "Error: ..." strings instead of raising (AIO/E2B), uninspectable targets fail open (creation proceeds, no mark stamped)
  12. ToolProgressMiddleware - (optional, if tool_progress.enabled) State-machine-based stagnation guard (RFC #3177). Outer wrapper around ToolErrorHandlingMiddleware so its wrap_tool_call receives results already stamped with deerflow_tool_meta. Tracks per-(thread, tool) consecutive "no-new-info" calls across three error categories: (a) recoverable_by_model=True (no_results, not_found, permission, Jaccard-duplicate success): ACTIVE → WARNED (terminal — hint re-injected on each subsequent problem); (b) recoverable_by_model=False, action≠stop (rate_limited, transient): ACTIVE → WARNED → BLOCKED after warn_escalation_count more problems; (c) recoverable_by_model=False, action=stop (auth, config, internal): immediately BLOCKED on first occurrence. Division of labor with LoopDetectionMiddleware: ToolProgressMiddleware is a result-quality guard — fires after tool execution and blocks specific tools that stop producing new information; LoopDetectionMiddleware is a call-pattern guard — fires after the model responds and hard-stops the whole turn when the model repeatedly issues identical tool_calls. Both can inject HumanMessage hints in the same model call without conflict; neither reads the other's internal state.
  13. ToolErrorHandlingMiddleware - Receives AppConfig, converts tool exceptions into error ToolMessages so the run can continue instead of aborting, stamps every result with deerflow_tool_meta (status / error_type / recoverable_by_model / recommended_next_action / source) via tool_result_meta.normalize_tool_result, 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.

Authorization identity plumbing is independent of whether authorization enforcement is enabled. Gateway removes client-supplied is_internal / authz_attributes / channel_user_id, derives is_internal only from the server-owned request.state.auth_source, and accepts channel_user_id only from an internally authenticated IM caller's top-level body.context; free-form body.config can never supply it. build_principal_from_context is the shared Principal builder for assembly-time authorization and GuardrailAuthorizationAdapter; it applies default_role, strict-boolean internal provenance, and copy-on-read authz_attributes. The built-in RBAC provider validates authorization.default_role during provider resolution so an unknown fallback role fails agent construction instead of degrading into an empty tool set. Task delegation carries is_internal plus copied attributes through SubagentExecutor, while GuardrailMiddleware maps the same runtime fields into GuardrailRequest. Phase 1B applies Layer 1 before deferred-tool assembly on the lead, native-subagent, and embedded-client paths, then passes the same provider instance into Layer 2. Framework-provided describe_skill and memory tools are included in Layer 1 but restored to their legacy post-tool_search ordering afterward. DeerFlowClient.stream() treats its in-process caller as trusted and accepts the same identity fields as keyword overrides; it includes the complete Principal in its agent cache key and deep-copies nested attributes so caller mutation cannot make a stale tool set look current.

Gateway route authorization uses authz.py::resolve_route_permissions() as the single provider integration point for both AuthMiddleware and decorator-only authentication. When enabled, it evaluates the six registered threads:* / runs:* permissions as resource="route" requests whose targets are the full resource:action strings. Decisions use the async provider API and are cached for the request in AuthContext; decorators do not call the provider again. Provider resolution or decision errors follow authorization.fail_closed, scoped per permission for decision errors. When authorization is disabled, the legacy complete permission set is returned without resolving a provider. Existing owner_check enforcement and require_admin_user() management gates remain independent and unchanged. Tests: tests/test_authorization_route_permissions.py, tests/test_auth.py, and tests/test_auth_middleware.py.

Before changing a later authorization phase, read the authorization RFC and its implementation notes. The notes are the cumulative handoff record for merged PR behavior, reviewer feedback, trust-boundary decisions, deferred scope, and required regression coverage.

Lead-only middlewares (build_middlewares, appended after the base):

  1. 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
  2. SkillActivationMiddleware - Detects strict /skill-name task syntax on the latest real user message, resolves only enabled and runtime-allowed skills, injects the SKILL.md body as hidden current-turn context, and records a middleware:skill_activation audit event
  3. SkillToolPolicyMiddleware - Applies allowed-tools only after real activation; passive enabled skills and a custom agent's configured skill allowlist do not clamp the lead toolset. A run-scoped slash activation is authoritative and suppresses skill_context as a policy source, so reading another skill cannot widen the explicit skill's tools; without slash activation, skills captured after configured read_file loads retain the existing union semantics. The middleware filters model-visible schemas and blocks unauthorized execution, resolving canonical paths against the live enabled/agent-allowed registry on every model call, then stores a versioned, JSON-safe, middleware-token-bound decision signed by policy source plus active paths in run context for the resulting tool calls to reuse. The next model call always refreshes it, and malformed, foreign, stale, or unmatched decisions fall back to live resolution. tool_search and describe_skill remain framework-safe discovery tools under a restrictive policy; they may reveal or promote metadata, but a deferred business tool must still be declared by the active policy before its schema or execution can survive the policy middleware. The decision's owner token is authorization-sensitive, so its reserved context key is owned by runtime.secret_context and included in REDACTED_CONTEXT_KEYS for observable and persisted context copies. Registry load failures and a non-empty active set with no authorized skill fail closed to framework-safe tools; an individual stale path is skipped only when at least one valid active skill remains. This is best-effort behavioral scoping rather than a hard security boundary: alternate loads such as bash cat are not captured, and bounded autonomous skill_context can evict old entries. task is not framework-exempt, so a restricted skill cannot delegate around its policy. The middleware must remain immediately after SkillActivationMiddleware (which publishes the slash source through runtime.secret_context's public path helpers authenticated by a required token shared only within the assembled middleware chain) and immediately before DurableContextMiddleware; assembly and compiled-graph tests pin ordering, token sharing, schema filtering, and execution blocking.
  4. DurableContextMiddleware - Captures task delegations into ThreadState.delegations (including in-progress dispatches and terminal result summaries) and loaded skill-file references (name/path/description, parsed in-memory - not the body) into ThreadState.skill_context before summarization can compact the paired tool-call/result messages, then projects durable context into each model request. Static authority rules are injected as a SystemMessage; untrusted field values (summary_text, delegation results, skill descriptions) are injected separately as a hidden HumanMessage data block so compressed history, delegated work, and which skills are active stay visible without being stored as messages or promoted to system-role instructions. build_subagent_runtime_middlewares also attaches this middleware immediately before subagent summarization so a compacted summary_text is projected ahead of a preserved assistant/tool tail instead of leaving strict providers with an assistant-first request.
  5. SummarizationMiddleware - (optional, if enabled) Context reduction when approaching token limits
  6. TodoListMiddleware - (optional, if is_plan_mode) Task tracking with the write_todos tool
  7. TokenUsageMiddleware - (optional, if token_usage.enabled) Records token usage metrics; subagent usage is read from terminal ToolMessage.additional_kwargs in the current run and merged back into the dispatching AIMessage by message position. The same state update marks the ToolMessage with subagent_token_usage_attributed=true, so checkpoint replay or middleware re-entry cannot add the cumulative snapshot twice; missing/malformed usage or a result with no matching dispatch remains unmarked and retryable.
  8. 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.py keeps the run in a finalizing state, persists a local fallback title from the latest checkpoint or original run input, and then syncs it to threads_meta.display_name. Replacement runs admitted by multitask_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.
  9. MemoryMiddleware - Queues conversations for async memory update (filters to user + final AI responses); captures the runtime-resolved user so standalone LangGraph Server reads and writes stay in the same bucket
  10. ViewImageMiddleware - (optional, if the model supports vision) Injects a hidden HumanMessage with base64 image data, identified by a reserved ID prefix plus a server-owned metadata marker, before the LLM call. Because before_model, model, and after_model are separate graph nodes, the before_model and model node checkpoints for that call still contain the payload; after_model / aafter_model then emits RemoveMessage, so subsequent checkpoints do not retain it
  11. McpRoutingMiddleware - (optional, if tool_search.enabled and PR1 MCP routing metadata produce a routing index) Auto-promotes matching deferred MCP tool schemas before the model call by writing a minimal promoted state update. It matches only the latest real HumanMessage, uses the global tool_search.auto_promote_top_k limit (default 3, clamped to 1..5), never executes tools, and must be installed before DeferredToolFilterMiddleware
  12. DeferredToolFilterMiddleware - (optional, if tool_search.enabled) Hides deferred (MCP) tool schemas from the bound model until tool_search or McpRoutingMiddleware promotes them (reads per-thread promotions from ThreadState.promoted, hash-scoped)
  13. 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_reminder SystemMessage survives. The subagent builder places its date-only context middleware immediately before this coalescer, so the built-in subagent prompt and hidden date reminder still reach providers as one leading system block
  14. SubagentLimitMiddleware - (optional, if subagent_enabled) Truncates excess task tool calls to enforce both the per-response concurrency limit (max_concurrent_subagents, clamped to 1-4) and the per-run total delegation cap (max_total_subagents runtime override or subagents.max_total_per_run, default 6, clamped to 1-50). The total cap counts current-run entries in the durable delegation ledger (entries are tagged with run_id when captured), so repeated planning checkpoints in one run cannot keep launching legal-sized batches indefinitely, while later user turns in the same thread get a fresh run budget. If the cap is exhausted, the middleware strips remaining task calls, forces finish_reason="stop", and appends a visible limit note so the run can synthesize existing results instead of ending with an empty tool-call response.
  15. LoopDetectionMiddleware - (optional, if loop_detection.enabled) Detects repeated tool-call loops; hard-stop clears both structured tool_calls and raw provider tool-call metadata before forcing a final text answer; stamps loop_capped via consume_stop_reason (#3875 Phase 2), symmetric to TokenBudgetMiddleware
  16. TokenBudgetMiddleware - (optional, if token_budget.enabled) Enforces per-run token limits
  17. Custom middlewares - (optional) Any custom_middlewares passed to build_middlewares are injected here, before config-declared extensions and the terminal-response/safety/clarification tail
  18. Configured extension middlewares - (optional, if extensions.middlewares is set in config.yaml or extensions_config.json) Zero-argument AgentMiddleware classes loaded from module.path:ClassName entries via deerflow.reflection.resolve_class. Missing packages, invalid classes, and broken modules fail loudly at agent creation. These run after built-ins/programmatic custom middleware and after the lead/subagent loop/token guards, but before the terminal-response/safety/clarification tail; subagents receive the same configured extension middleware class list before their safety tail. Treat these files as trusted operator config because middleware paths instantiate arbitrary code. Gateway skill/MCP toggle endpoints preserve this field through to_file_dict() but must not add a write path for extensions.middlewares without an explicit trust-boundary review. Lead-only vs subagent-only middleware lists and per-context constructor parameters are not expressible in this MVP.
  19. TerminalResponseMiddleware - When a provider returns an empty terminal AIMessage after tool execution, injects a hidden recovery prompt and retries the model once; a second empty response is replaced in checkpoint state by a visible error fallback marked for the run worker, so the run finishes as an error instead of a silent success
  20. ModelLengthFinishReasonMiddleware - Records stop_reason=model_length_capped when provider-specific length detectors match a terminal AIMessage without tool-call intent (finish_reason=length / MAX_TOKENS, or stop_reason=max_tokens), preserving the original assistant content and never reparsing textual tool-call-like envelopes
  21. 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 terminal-response/custom/configured middlewares so LangChain's reverse-order after_model dispatch runs it first
  22. ClarificationMiddleware - Intercepts ask_clarification tool calls, writes a readable ToolMessage.content fallback plus structured ToolMessage.artifact.human_input request payload, and interrupts via Command(goto=END) (must be last). Payloads are versioned: legacy modes (free_text / choice_with_other) keep version: 1 unchanged, while the v2 form mode (from fields) carries version: 2 so older frontends reject the payload and degrade to the plain-text fallback. Field normalization is deterministic and lives in the middleware, not the tool schema — the middleware short-circuits before tool execution, so tool-arg typing alone provides no runtime validation. Validation is atomic: any structurally broken entry (non-dict, bad/duplicate name, a name colliding with a JS Object.prototype member like __proto__/constructor, exceeding the caps of 16 fields / 24 options per field / 200 chars per text, or the whole normalized definition exceeding MAX_FORM_SERIALIZED_BYTES = 16KB UTF-8 — the per-item caps alone admit forms whose IM text fallback would blow channel delivery limits and truncate away trailing fields) degrades the whole form to the legacy option/free-text modes, so a card can never render "complete" while silently missing a business field; benign issues keep local degradation (unknown types — including unhashable JSON like type: [], which must never raise from the membership probe — and option-less selects become text), and options are trimmed/deduped with blanks dropped (both form-level and top-level) because the frontend parser rejects blank option labels. Model-produced XML-to-dict option payloads are recursively flattened from dict/list containers in source order, scalar string/number leaves are retained, and residual XML tags are removed before the same trimming and deduplication. Checkbox fields are booleans that default to an explicit "no"; required on a checkbox means must-agree/consent semantics. The response protocol is deliberately unchanged (v1 text/option only): form cards submit a readable text summary as response_kind: "text", so journal persistence and answered-card recovery need no new allowlist entries. Because this middleware can short-circuit tool execution before LangChain emits on_tool_end, RunJournal performs a root-run final reconciliation for allowlisted clarification ToolMessages whose tool_call_id was produced by the current run, so human-input request cards remain recoverable from run_events after checkpoint compaction. Human Input Card replies are submitted as hide_from_ui HumanMessages with additional_kwargs.human_input_response; RunJournal persists only allowlisted hidden response sources (currently ask_clarification) as llm.human.input, which preserves answered-card state after compaction without exposing generic internal hidden context.