* feat(subagents): inject date-only runtime context * refactor(middleware): deduplicate date reminder formatting
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):
- InputSanitizationMiddleware - First, so it is the outermost
wrap_model_callwrapper; every inner middleware (including LLM retries) sees sanitized messages.additional_kwargs.original_user_contentis 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. - 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 constantTOOL_RESULTS_DIRNAME) under the thread outputs dir with a typed synopsis +read_filereference 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 - 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. MirrorsInputSanitizationMiddleware's user-input guardrail for the other untrusted-content entry point; sits inner ofToolOutputBudgetMiddleware(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 - 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 viaresolve_runtime_user_id(runtime), including Gateway runtime context and standalone LangGraph Server auth, then falls back to the request ContextVar /"default" - 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
- 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"]; malformed tool-call names and arguments are sanitized in the model-bound request so strict OpenAI-compatible providers do not reject the next request - LLMErrorHandlingMiddleware - Normalizes provider/model invocation failures into recoverable assistant-facing errors before later stages run
- Authorization / GuardrailMiddleware - Up to two independent pre-tool-call gates run here. When
authorization.enabled, theAuthorizationProviderinstance already used for Layer 1 capability filtering is wrapped byGuardrailAuthorizationAdapterand reused for Layer 2 execution checks. A generatedtool_searchbypasses 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. Whenguardrails.enabled, the explicitly configuredGuardrailProvideris appended after authorization and still evaluates every call, includingtool_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-ToolMessagebehavior. See the authorization RFC and docs/GUARDRAILS.md. - 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 anyeval/sourceargument) executes fetched or interpreted content and is blocked, while value position (x=$(curl url),echo $(curl url), an argument, aforword list) only captures output and passes (#4611)._HIGH_RISK_COMMAND_POSITION_PATTERNSis 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_PREFIXextends 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 keepsx=$(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)"): aneval/sourceargument, 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_SUBSTITUTIONopener 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 letecho hi\n$(curl url)evade the anchored rules that its;spelling triggers. A heredoc body is data rather than statements, so_split_compound_commandrecords 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 delimitertext), 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 outsideeval/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 laterevalneeds 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. - ReadBeforeWriteMiddleware - (optional, if
read_before_write.enabled, default on) Outermost write gate (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. Sits outside ToolProgressMiddleware and ToolErrorHandlingMiddleware so a blocked write returns immediately without consuming a ToolProgress slot. Blocked results callnormalize_tool_resultdirectly to stampdeerflow_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 whoseread_filereports failures as"Error: ..."strings instead of raising (AIO/E2B), uninspectable targets fail open (creation proceeds, no mark stamped) - ToolProgressMiddleware - (optional, if
tool_progress.enabled) State-machine-based stagnation guard (RFC #3177). Outer wrapper around ToolErrorHandlingMiddleware so itswrap_tool_callreceives results already stamped withdeerflow_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 afterwarn_escalation_countmore 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. - ToolErrorHandlingMiddleware - Receives
AppConfig, converts tool exceptions into errorToolMessages so the run can continue instead of aborting, stamps every result withdeerflow_tool_meta(status / error_type / recoverable_by_model / recommended_next_action / source) viatool_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):
- 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 - SkillToolPolicyMiddleware - Applies
allowed-toolsonly 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 suppressesskill_contextas a policy source, so reading another skill cannot widen the explicit skill's tools; without slash activation, skills captured after configuredread_fileloads 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_searchanddescribe_skillremain 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 byruntime.secret_contextand included inREDACTED_CONTEXT_KEYSfor 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 asbash catare not captured, and bounded autonomousskill_contextcan evict old entries.taskis not framework-exempt, so a restricted skill cannot delegate around its policy. The middleware must remain immediately afterSkillActivationMiddleware(which publishes the slash source throughruntime.secret_context's public path helpers authenticated by a required token shared only within the assembled middleware chain) and immediately beforeDurableContextMiddleware; assembly and compiled-graph tests pin ordering, token sharing, schema filtering, and execution blocking. - 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.build_subagent_runtime_middlewaresalso attaches this middleware immediately before subagent summarization so a compactedsummary_textis projected ahead of a preserved assistant/tool tail instead of leaving strict providers with an assistant-first request. - 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 read from terminalToolMessage.additional_kwargsin the current run and merged back into the dispatching AIMessage by message position. The same state update marks the ToolMessage withsubagent_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. - 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); captures the runtime-resolved user so standalone LangGraph Server reads and writes stay in the same bucket
- 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, andafter_modelare separate graph nodes, thebefore_modelandmodelnode checkpoints for that call still contain the payload;after_model/aafter_modelthen emitsRemoveMessage, so subsequent checkpoints do not retain it - McpRoutingMiddleware - (optional, if
tool_search.enabledand PR1 MCP routing metadata produce a routing index) Auto-promotes matching deferred MCP tool schemas before the model call by writing a minimalpromotedstate update. It matches only the latest realHumanMessage, uses the globaltool_search.auto_promote_top_klimit (default 3, clamped to 1..5), never executes tools, and must be installed beforeDeferredToolFilterMiddleware - DeferredToolFilterMiddleware - (optional, if
tool_search.enabled) Hides deferred (MCP) tool schemas from the bound model untiltool_searchorMcpRoutingMiddlewarepromotes 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. 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 - SubagentLimitMiddleware - (optional, if
subagent_enabled) Truncates excesstasktool 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_subagentsruntime override orsubagents.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 withrun_idwhen 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 remainingtaskcalls, forcesfinish_reason="stop", and appends a visible limit note so the run can synthesize existing results instead of ending with an empty tool-call response. - 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; stampsloop_cappedviaconsume_stop_reason(#3875 Phase 2), symmetric toTokenBudgetMiddleware - TokenBudgetMiddleware - (optional, if
token_budget.enabled) Enforces per-run token limits - Custom middlewares - (optional) Any
custom_middlewarespassed tobuild_middlewaresare injected here, before config-declared extensions and the terminal-response/safety/clarification tail - Configured extension middlewares - (optional, if
extensions.middlewaresis set inconfig.yamlorextensions_config.json) Zero-argumentAgentMiddlewareclasses loaded frommodule.path:ClassNameentries viadeerflow.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 throughto_file_dict()but must not add a write path forextensions.middlewareswithout an explicit trust-boundary review. Lead-only vs subagent-only middleware lists and per-context constructor parameters are not expressible in this MVP. - TerminalResponseMiddleware - When a provider returns an empty terminal
AIMessageafter 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 - ModelLengthFinishReasonMiddleware - Records
stop_reason=model_length_cappedwhen provider-specific length detectors match a terminalAIMessagewithout tool-call intent (finish_reason=length/MAX_TOKENS, orstop_reason=max_tokens), preserving the original assistant content and never reparsing textual tool-call-like envelopes - 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-orderafter_modeldispatch runs it first - ClarificationMiddleware - Intercepts
ask_clarificationtool calls, writes a readableToolMessage.contentfallback plus structuredToolMessage.artifact.human_inputrequest payload, and interrupts viaCommand(goto=END)(must be last). Payloads are versioned: legacy modes (free_text/choice_with_other) keepversion: 1unchanged, while the v2formmode (fromfields) carriesversion: 2so 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 JSObject.prototypemember like__proto__/constructor, exceeding the caps of 16 fields / 24 options per field / 200 chars per text, or the whole normalized definition exceedingMAX_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 liketype: [], which must never raise from the membership probe — and option-less selects becometext), 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";requiredon a checkbox means must-agree/consent semantics. The response protocol is deliberately unchanged (v1text/optiononly): form cards submit a readable text summary asresponse_kind: "text", so journal persistence and answered-card recovery need no new allowlist entries. Because this middleware can short-circuit tool execution before LangChain emitson_tool_end,RunJournalperforms a root-run final reconciliation for allowlisted clarificationToolMessages whosetool_call_idwas produced by the current run, so human-input request cards remain recoverable fromrun_eventsafter checkpoint compaction. Human Input Card replies are submitted ashide_from_uiHumanMessages withadditional_kwargs.human_input_response;RunJournalpersists only allowlisted hidden response sources (currentlyask_clarification) asllm.human.input, which preserves answered-card state after compaction without exposing generic internal hidden context.