* feat(authz): filter per-caller skill visibility on the listing surfaces (#4063 Phase 4) GET /api/skills, GET /api/skills/custom, and GET /api/skills/{name} now filter the user-scoped catalog through filter_resources(principal, "skill", ...) — mirroring list_models. Anonymous callers are unfiltered; provider errors follow authorization.fail_closed (fail-closed -> empty listing / 404, fail-open -> full listing). An invisible skill on the detail surface returns the standard 404 so the endpoint cannot become an existence oracle the filtered list closed. Management endpoints stay require_admin_user-gated; runtime activation is #4541's layer. resolve_skill_authorization joins resolve_model_authorization as a thin sibling over a shared _resolve_route_scoped_authorization core. * docs(authz): reflect per-caller skill visibility in OpenAPI metadata and implementation notes (#5489) Address the two non-blocking review findings on #5489: - The three user-facing GET routes (/skills, /skills/custom, /skills/{name}) now say in their /docs-visible descriptions that authorization filters the response (hidden skills 404 on detail). - Add the dated Phase 4 decision-log entry to the authorization implementation notes, per the convention of every prior merged authz PR: listing-visibility semantics, the 404-vs-403 existence-oracle rationale, anonymous-caller behavior, and the #4541 rebase reconciliation points (config.example.yaml roles comment + this file's decision log). * docs(authz): move route guidance into Gateway module guide --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
35 KiB
Middleware Chain
Compaction preserves all state-level SystemMessages as framework instructions,
including untagged legacy reminders. Transient instructions belong in request
wrappers. A fully rescued partition skips compaction.
After latest-user rescue, if the inherited trimmer empties an AI/Tool-only
window, format it and use _build_summary_input_text(strategy="last").
Keep normal human-anchored trimming and the final-message fallback for mixed
windows whose human anchor falls outside the token-limited tail; head-first
restoration can lose recent tool results. Tail truncation prefixes \n...\n
only when marker and content fit. Budget raw sections before HTML escaping,
wrappers, and prompt (not the final request); escape after trimming to preserve
entities. Pass trim_tokens_to_summarize=None explicitly through the factory;
omission restores LangChain's 4000-token default.
Persisted delegation verdicts are untrusted durable context; ledger rendering revalidates them and ignores malformed values. Completed is not accepted; retain useful work and address acceptance gaps.
Assembly order: tool_error_handling_middleware.py::_build_runtime_middlewares (exposed as build_lead_runtime_middlewares), then ../lead_agent/agent.py::build_middlewares appends lead-only entries. Optional entries require their config/runtime condition.
Message provenance. At injection/rewrite, always stamp additional_kwargs
via deerflow_extension_api.provenance.provenance_kwargs():
deerflow_content_kind, deerflow_producer_kind, optional
deerflow_producer_entity_id. All are server-owned inbound metadata; stamp even
without observers, since downstream cannot recover producers. Producers:
DynamicContext (reminder/memory), DurableContext (contract/data),
SystemMessageCoalescing, ViewImage, SkillActivation. Summarization/Title use
SystemOperationKind.SUMMARIZATION/.TITLE model-call attribution; summaries
enter via DurableContext's stamped durable_context_data, not separate
messages. Memory only queues extraction; recall uses DynamicContext's
dynamic_context_memory stamp.
Middleware self-description. Behaviour-configurable middleware implements
release_policy_parameters() -> dict[str, object] (duck-typed
deerflow_extension_api.release.ReleasePolicyProvider, no base class).
Use JSON-serialisable values and canonical_hash for long text, not prompt
copies. collect_release_policies() gathers stack declarations; update them
alongside every behaviour-affecting field.
Summarization declares enabled task_continuity retention settings (otherwise
None). DurableContext declares its normalized skills root, sorted read-tool
names and continuity switch, so each capture/injection policy affects assembly
identity without depending on private-field probing.
Continuity history readers share shape validation, including the capture failure
path and DurableContext rendering, so malformed persisted metadata cannot abort
ordinary compaction or a model call.
Removing tool calls. Use clone_ai_message_with_tool_calls, not a bare
tool_calls update: adapters resend stale content tool-call blocks, which
strict providers reject.
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. Caller markers are markeduntrusted_input, never stripped; scope is every turn. -
ToolOutputBudgetMiddleware - Caps model-bound tool output per app config. Externalizes oversized results to
tool_output.storage_subdir(default.tool-results, constantTOOL_RESULTS_DIRNAME) under thread outputs, leaving a typed synopsis +read_filereference. These process-feedback files are excluded from workspace-change scans and delivery verification.wrap_model_callelides successfulwrite_filecontent only in model-bound requests (#5328) after a later successful same-pathread_file/write_file/str_replace; the on-disk file becomes the reference. Preserves the newestkeep_recent_writeswrites. Pairs call occurrences viatool_call_args.pair_tool_call_resultsand rewrites through sharedtool_call_argshelpers; controls:elide_superseded_writes,superseded_write_min_chars. -
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 for the first-party web tools, plus every MCP-sourced tool via itsdeerflow_mcpmetadata tag, so an MCP server naming its fetcherfetch_urlis still coveredResult-rewriting middlewares between the raw callable boundary and the model-visible result append a declared entry to
additional_kwargs["deerflow_tool_transforms"]viaagents/middlewares/tool_transform_meta.py::append_tool_transform. The trail is ordered by application — the last entry produced the final visible bytes — so an observer classifies raw→visible transforms from facts rather than by sniffing output wording. -
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. The lead runtime normally owns the thread's physical Agent-skill projection; delegated subagents and the prompt-only bootstrap agent are non-owners, so their narrower discovery allowlists never rebuild the shared thread view or force eager sandbox acquisition. -
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 - Converts provider/model failures to recoverable assistant errors. Normal completions without visible text or tool-call intent (including whitespace/reasoning-only responses) get at most one retry per run, then a marked visible fallback; empties never count toward the circuit breaker. Cancellation during admission, execution, retry events or backoff releases only this call's half-open probe (assigned under the circuit lock), then propagates unchanged without retry or failure accounting.
-
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.Every guardrail decision path publishes a neutral
deerflow.authz.outcome.AuthorizationOutcomeinto the per-run runtime context, keyed bytool_call_idunder the__-prefixed__authorization_outcomekey (sobuild_run_configstrips caller-supplied forgeries). Consumers pop it; the publisher and the consumer share only that contract module. -
SandboxAuditMiddleware - Audits sandboxed shell/file operations before tool execution; command classification is defense-in-depth and audit, not a security boundary (the sandbox is the isolation boundary). Command substitution is judged by position, not the presence of
$(: command position ($(curl url),`curl url`, the word after|/&&/;, aneval/sourceargument) executes fetched content and is blocked; value position (x=$(curl url),echo $(curl url), an argument, aforword list) only captures output and passes (#4611). So_HIGH_RISK_COMMAND_POSITION_PATTERNSis matched anchored against each sub-command from_split_compound_command(split_pipes=True), never the whole string; pipe-spanning rules (| sh,base64 -d | ...) still use_classify_command's whole-command Pass 1._COMMAND_POSITION_PREFIXextends the anchor over leading assignments and exec wrappers (FOO=1 $(curl url),env/command/builtin/exec/nohup/time/sudo/doas); its assignment branch requires whitespace before the substitution, which keepsx=$(curl url)in value position. Two contexts are deliberately position-blind (matched whole-command in Pass 1, since they execute their input anywhere, e.g.xargs sh -c "$(curl url)"): aneval/sourceargument, and an interpreter code-string flag —-c(shells,python),-e(perl/ruby/node),-p(perl/node),-r(php) — plus the here-string (<<<) reaching the same place via stdin. All three substitution spellings ($(,<(,`) share one_RISKY_SUBSTITUTIONopener. An unquoted newline splits like;(elseecho hi\n$(curl url)evades the anchored rules). Heredoc bodies are data:_split_compound_commandrecords headers (<<EOF,<<-EOF,<<'EOF') and consumes their bodies verbatim, so a body line starting$(curl url)isn't promoted to a command position;<<<(here-string, needs look-ahead + look-behind) and a<<inside$(( ))/(( ))(bit shift, arithmetic depth tracked with the quote flags) must not open one. This is a heuristic, not shell parsing — an unterminated body consumes the rest of the string, an unclosed((only disables heredoc detection, and the failure direction is always toward more command positions, not fewer. Known gaps: process substitution outsideeval/source(. <(curl u)) is undetected, and two-step forms (x=$(curl u); eval "$x") need dataflow analysis. No config gate — appended unconditionally in_build_runtime_middlewares, for both lead and subagents. -
ReadBeforeWriteMiddleware - (optional,
read_before_write.enabled, default on) Outermost write gate (#3857):read_filestamps a content hash on its ToolMessage;write_file(existing file, incl. append) andstr_replaceare blocked unless the newest mark for the path matches its current hash. Sits outside ToolProgress/ToolErrorHandling (a block consumes no ToolProgress slot); blocked results self-stampdeerflow_tool_metaand carrydeerflow_write_block({path, tool}). Marks live on messages, so summarization dropping the read invalidates the gate; writes never refresh marks. Gate check + execution are serialized per (thread, path);"Error: ..."-string sandboxes (AIO/E2B) fail open. It owns the composed call's sandbox authorization scope;SandboxAuthorizationErrorbecomes an error ToolMessage. Itswrap_model_callswaps blocked calls' dead payload (content,old_str/new_str) for a deterministic placeholder in the model-bound request only (elide_blocked_payloads,elide_min_chars); state, receipts, and the journal keep the originals. Policy stays in the gate; the sharedtool_call_argshelper rewrites every arg surface together and every model-bound arg rewrite must use it. -
ToolProgressMiddleware - (optional, if
tool_progress.enabled) State-machine-based stagnation guard (RFC #3177). Outer wrapper around ToolErrorHandlingMiddleware so its tool wrapper receives results already stamped withdeerflow_tool_meta. Recoverable problems stay WARNED with hints; retryable non-recoverable problems escalate to BLOCKED; stop-category failures block immediately. It is independent of LoopDetection's turn-level call-pattern guard. Effective phase changes emitmiddleware:tool_progress, includingresetwhen a later agent invocation (for example, goal continuation) clears WARNED/BLOCKED state. The state machine supplies the action and count threshold (nullfor category rules, recovery, and reset); recorder calls happen after releasing the state lock. Events include the actual sync/async hook plus bounded status, error, and recovery fields. Server-owned recorder keys determine subagent attribution. Arguments, content, prompts, and hashes are never persisted; recorder failures are fail-open. See event semantics. -
ToolReceiptMiddleware + ToolErrorHandlingMiddleware -
ToolReceiptMiddlewareis (optional, ifverification.receipts_enabled, default on). It is the outermostwrap_tool_calllayer — registered ahead of entries 9-12 — because Guardrail/SandboxAudit/ReadBeforeWrite/ToolProgress can short-circuit a call with their own ToolMessage (and SandboxAudit rebuilds medium-risk results); an inner receipt layer would silently gap the ledger on those results (ordering constraints indeerflow.extensions.ordering). Normal results still carry thedeerflow_tool_metastatus ToolErrorHandlingMiddleware stamps on the inner return path; short-circuit messages self-stamp meta or fall back tomessage.status. It stamps deterministic provenance (tool name, status, args/output hashes, byte count, timestamp) onto directToolMessageresults and every matchingToolMessagecarried inCommand.update.messages, including delegatedtask,present_file,view_image, andtool_searchresults; before model calls it derives a hidden receipt ledger (display ids r1..rN) from message state, and when the 2,000-character budget is exceeded the newest receipts are retained in chronological order with their original ids plus an older-receipts omission marker. Rendering returns both the text and its retained receipt subset; every response that received a ledger carries only that exact server-owned subset, never omitted receipts. Snapshot validation accepts a strictly consecutive positive original-id range (for exampler24–r30) rather than requiringr1, so subagent terminal citation verification resolves ids against evidence present in the citing turn even when later summarization drops and renumbers tool messages. Model-generated citation IDs are digit-bounded before integer conversion; oversized IDs are ignored as malformed input rather than raising through task write-back. Citation parsing deduplicates exact(id, anchor)pairs, not IDs alone, so repeated identical references stay compact while every distinct anchor claim is verified. Gateway strips delegated receipts/verdicts from external messages.ToolErrorHandlingMiddlewarereceivesAppConfig, 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 is independent of enforcement. Gateway strips client identity overrides: only the server auth source sets is_internal, and only authenticated IM body.context supplies channel_user_id (never body.config). build_principal_from_context applies role defaults, strict provenance, and copied attributes; RBAC rejects unknown defaults. Delegation and GuardrailMiddleware share this identity. Layer 1 precedes deferred assembly across agent paths and its provider is reused for Layer 2; framework skill/memory ordering stays stable. Trusted DeerFlowClient.stream() accepts identity overrides. Its graph key always includes effective storage user_id and, when enforced, the full Principal; nested attributes are copied so mutation cannot hide stale cache state.
Gateway route permissions and per-caller skill visibility are documented in the Gateway guide. Runtime skill activation remains a separate layer.
Model authorization uses authz.py::resolve_model_authorization() (same cached-provider, internal-role, and principal-building path as route authorization) as the Gateway integration point for the models router: list_models filters names through filter_resources(principal, "model", ...), and get_model enforces authorize(resource="model", action="use") with a deny surfacing as 403; provider errors follow authorization.fail_closed (fail-open returns the unfiltered list / proceeds). At runtime, lead_agent/agent.py::_authorize_model_name — called from _make_lead_agent and from DeerFlowClient._ensure_agent — applies the same model:use check to the resolved model name. On deny it scans the filter_resources-visible names (excluding the denied model), re-verifying each candidate with authorize("model", "use") before falling back, because a custom provider may allow list while denying use; no usable fallback raises under fail_closed and keeps the original model under fail-open. The built-in RBAC provider maps this to the per-role models policy key. Tests: tests/test_models_authorization.py.
Sandbox authorization (sandbox:execute) gates every sandbox acquisition before provider.acquire — including this middleware's eager path (before_agent / abefore_agent skip acquisition on deny instead of raising, deferring to the lazy per-tool gate). See the sandbox module guide authorization-gate paragraph and tests/test_sandbox_authorization.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 date and optional memory outside the static prompt. Opt-out removes its frozen server memory but retains date/user messages. Date follows server-local time unless
DEER_FLOW_DATE_TIMEZONEnames an IANA zone (invalid values fall back locally). - 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) Compacts near token limits; memory flush follows runtime policy, while manual compaction trusts the checkpoint-bound agent, so opt-out never captures removed turns. It preserves the latest real user request by ID and tagged DynamicContext reminders while allowing stale ID-swap peers into the summary. Moving the cutoff backward can retain old AI/tool turns and make first-turn compaction a no-op.
- 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) Appends a hidden HumanMessage with base64 image data, identified by a reserved ID prefix plus a server-owned metadata marker, to
ModelRequest.messagesinwrap_model_call/awrap_model_call. The payload lives only in that request and is never returned as a state update, so no checkpoint carries it and an interrupted run cannot strand it in history; state keeps only the lightweightviewed_imagesmetadata. It owns that context and rebuilds it per call: its own message is swept out of the request first — a thread checkpointed by the earlierbefore_model/after_modelpair (which wrote the payload into state and took it back out withRemoveMessage) can carry one that reached state but was never removed, and leaving it in would resend that base64 in every later request for the life of the thread — then a freshly built one is appended when warranted. The sweep requires both the reserved ID prefix and the server-owned marker, so a client cannot get its own message dropped; unmarked leftovers predating the marker are left in place and merely not duplicated - McpRoutingMiddleware - Auto-promotes deferred schemas matching the latest real user message before
DeferredToolFilterMiddleware, without executing tools. New names emitmiddleware:tool_promotionwithsource=routing_hint; repeat passes emit nothing - DeferredToolPromotionAuditMiddleware - Observes final
tool_searchCommands; keep it outer ofSkillToolPolicyMiddlewareso denied names are excluded. It atomically claims new names per lead run or subagent execution to dedupe parallel searches, derives subagent attribution from the server-installed recorder, returns the originalCommand, and omits private payloads - 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 excess ordinarytasktool calls to enforce both the per-response concurrency limit (max_concurrent_subagents, resolved against startupsubagent_runtime.max_runningand the 1-64 safety range before construction) 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. Explicit durablebatch_taskcalls are a separate mode with persisted total/live/running limits and are not rewritten into ordinary ledger entries. If the ordinary 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 structured, raw, and content-block tool calls before forcing a final text answer; stampsloop_cappedviaconsume_stop_reason(#3875 Phase 2), symmetric toTokenBudgetMiddleware; persists warned-state transitions (first per call hash or per tool-frequency burst) and hard stops asmiddleware:loop_detection, attributed withis_subagentand the optionalagent_id, without tool arguments, message content, tool results, or argument-derived hashes. Ordinary task subagents get dedicated recorder keys through a parent-loop proxy; never passRunJournalinto their isolated loop. Durable batch subagents have no parent run journal and do not persist these transitions State is run-scoped: new user runs get fresh budgets; same-run goal continuations share history. Keep sibling warnings isolated and lifecycle hooks topology-stable. Before changing this guard, read Loop detection lifecycle for fallback identity, cleanup/LRU/reset, severity ordering, and test invariants. - TokenBudgetMiddleware -
token_budget.enabled: shares run-ID budgets across continuations; missing/invalid IDs clear invocation state. - Custom middlewares - (optional) Any
custom_middlewarespassed tobuild_middlewaresare injected here, before config-declared extensions and the terminal-response/safety/clarification tail - Configured extension middlewares -
extensions.middlewaresinconfig.yamlorextensions_config.jsonoptionally acceptsmodule.path:ClassNamestrings or{class, kwargs}objects.deerflow.reflection.resolve_classloadsAgentMiddlewareclasses; import, class, and constructor errors fail agent creation.kwargsmust be JSON-compatible; YAML dates/timestamps become ISO strings. Order: built-ins/custom and loop/token guards → extensions → terminal-response/safety/clarification tail. Subagents share the list before their safety tail; separate lead/subagent lists are unsupported. Trusted operator config only: paths instantiate arbitrary code. Gateway skill/MCP toggles preserve it in raw JSON; adding an API write path requires explicit trust-boundary review. - TerminalResponseMiddleware - After tools following the latest real user message, an assistant response without visible text or tool intent gets a marked visible fallback in the same step. Preserves content blocks; no graph retry or separate length-reason vocabulary.
- ModelLengthFinishReasonMiddleware - A length-detector match stamps
stop_reason=model_length_cappedand ends the tool loop. Suppresses all structured/raw calls and nativetool_useblocks, even fully parsed calls. Keeps text/thinking blocks; appends a length notice if no visible text exists. Audit metadata keeps detector, reason, call count/names, never suppressed arguments. - 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_clarification, writes a readableToolMessage.contentfallback plus a structuredToolMessage.artifact.human_inputpayload, and interrupts viaCommand(goto=END)(must be last).after_modeldrops same-turn sibling tool calls so they cannot run before the user answers; a malformedask_clarificationparked oninvalid_tool_callsis the same stop signal.disable_clarificationruns keep the siblings. Payloads are versioned — legacyfree_text/choice_with_otherstayversion: 1; the v2formmode (fromfields) isversion: 2so older frontends reject it and fall back to plain text. Field normalization is deterministic and lives in the middleware (it short-circuits before tool execution, so tool-arg typing gives no runtime validation), and it is atomic: any structurally broken entry — non-dict, bad/duplicate name, a name colliding with a JSObject.prototypemember (__proto__/constructor), or exceeding the caps (16 fields / 24 options per field / 200 chars per text /MAX_FORM_SERIALIZED_BYTES= 16KB UTF-8, the per-item caps alone admitting forms whose IM text fallback overruns channel limits) — degrades the whole form to the legacy option/free-text modes, so a card never renders "complete" while missing a field. Benign issues degrade locally (unknown types — incl. unhashable JSON liketype: [], which must not raise from the membership probe — and option-less selects becometext); options are trimmed/deduped with blanks dropped (form- and top-level) since the frontend rejects blank labels. XML-to-dict option payloads are recursively flattened from dict/list containers in source order, scalar leaves kept, residual XML tags stripped before that trimming. Checkboxes are booleans defaulting to "no";requiredon one means consent semantics. The response protocol is unchanged (v1text/option): form cards submit a text summary asresponse_kind: "text", so journal persistence needs no new allowlist entries. Because this middleware can short-circuit beforeon_tool_end,RunJournaldoes a root-run reconciliation forToolMessages whosetool_call_idcame from the current run, so cards survive checkpoint compaction. That reconciliation is notask_clarification-only — any middleware that answers a tool call has the same gap, and a result the user saw must not vanish on reload (#4666 —ReadBeforeWriteMiddlewareblocked-write errors reached the UI but not the event store). It is bounded by three conditions, not a name allowlist: the message is user-visible, the call belongs to this run's lead agent (_remember_current_run_tool_callsrecords lead-agent calls only; subagent results stay insubagent.step), and it is not already persisted. Human Input Card replies arehide_from_uiHumanMessages withadditional_kwargs.human_input_response;RunJournalpersists only allowlisted hidden sources (currentlyask_clarification) asllm.human.input.