feat(agents): persist tool artifacts as resolvable handles (#4676) (#4929)

* feat(agents): persist tool artifacts as resolvable handles (#4676)

MCP and sandbox tools return file paths, URLs, and task ids that are
lost when context compaction summarizes the conversation. This adds a
per-thread artifact handle registry so the model can reference
artifacts by short deterministic handles (art_xxxxxxxx) that survive
compaction and resolve to real references at the tool-call boundary.

- ThreadState.tool_artifacts: reducer channel capped at 100 entries,
  same-handle latest-wins, survives summarization compaction
- artifact_registry: deterministic handle derivation from
  (thread_id, tool_call_id, call_index); extraction precedence
  structured_content -> file/image blocks -> conservative text scan;
  HTML-escaped model-context rendering with char budget
- ArtifactCaptureMiddleware: before_model hook captures lightweight
  metadata (never content) and tracks handle consumption
- ArtifactResolutionMiddleware: rewrites handles in tool-call args to
  real references before execution; unknown handles untouched
- DurableContextMiddleware: projects the registry into the hidden
  durable-context data block ([available]/[consumed] status)
- config: new tool_artifacts section (all switches default on)
- frontend: artifact badges on tool-call steps keyed by tool_call_id

Fixes #4676

* fix(agents): harden artifact registry after review (#4676)

Review findings on #4929:

- capture/consumption updates emitted in the same before_model call are
  now concatenated instead of dict-merged; dict-merging clobbered the
  shared tool_artifacts key and permanently dropped fresh captures
- handles embed a per-result ordinal so multiple references inside one
  tool result never collide (two paths in one text block previously
  collapsed into one handle)
- structured_content extraction pulls concrete refs from known keys
  (file/path/url/task_id/..., string or list-of-string values) instead
  of storing a truncated JSON preview, falls back to an untruncated
  whole-object data entry, and no longer skips file/image content blocks
- plain-string tool results (e.g. bash/write_file output) are scanned
  for sandbox paths / remote URLs too, gated by detect_refs_in_text
- wire max_entries through configure_tool_artifact_max_entries at
  assembly (clamped 10..1000) and gate detect_refs_in_text scans
- resolution requires both enabled and resolve_handles_in_args,
  matching its docs
- add real ToolCallRequest.override coverage and the spec 14.4
  integration cycle (capture -> inject -> resolve -> consumption)

* fix(agents): address second-round artifact registry review (#4676)

Follow-up findings on #4929:

- drop generic `output`/`outputs` from structured ref keys: prose under
  those keys no longer becomes a bogus file entry (falls back to the
  whole-object data entry; path-shaped refs are still caught by the
  free-text scan)
- strip closing quotes, brackets, and backticks from detected refs so
  markdown- and JSON-wrapped paths resolve to clean real references
- remove the process-wide cap setter: the reducer enforces only an
  absolute ceiling (1000) and ArtifactCaptureMiddleware applies the
  configured max_entries per agent when emitting updates, so agents
  with different configs in one process never share or flap a cap
- skip extraction for tool messages whose tool_call_id already has
  registry entries and memoize known-empty results (bounded FIFO), so
  steady-state capture cost drops to the new message tail instead of a
  full-history rescan per model call
- correct the inverted ordering claim: capture is position-independent
  in the wrap chain because it is a before_model hook reading state
  messages; fixed the assembly comment, AGENTS.md item 15, and the
  ordering test docstring

CI parity verified locally: ruff check + format --check, full offline
suite, blocking-io suite, collect-only, uv lock --check,
check_agent_guidance, frontend format/lint/typecheck/build/tests.

* fix(agents): sliding-window cap and URI gates for artifact registry (#4676)

Third-round review findings on #4929:

- max_entries is now a sliding retention window: fresh captures are
  always registered, and when the projection exceeds the configured
  cap the middleware appends a trailing {"op": "trim_to", "keep": N}
  directive that the reducer applies by evicting the oldest entries.
  The previous remaining-budget emission froze the registry at the cap,
  permanently dropping post-compaction artifacts — the exact failure
  mode #4676 targets — and silently disabled the round-2 rescan
  optimizations at the point threads grew longest
- _track_consumption memoizes settled tool-call args in a bounded FIFO
  alongside the existing empty-result memo, so historical AIMessage
  args are not regex-rescanned every model call
- file/image content-block URLs must be http(s) or path-shaped;
  data:/blob: URIs (MCP embedded resources carrying base64 payloads)
  can no longer enter thread state, tool args, or crowd out the render
  budget; protocol-relative //host forms are rejected as well
- structured-content ref values go through the same URI-shape gate as
  content blocks, closing the embedded-payload hole via known keys such
  as url/urls
- tool_artifacts.enabled now gates the whole middleware including
  consumption tracking via an early return in before_model

Docs updated to describe the sliding window, URI gates, enabled scope,
and both bounded memos.

* fix(agents): final evictions and pending-aware consumption for artifact registry (#4676)

Fourth-round review findings on #4929:

- the sliding window interacted with the seen_call_ids skip to
  resurrect evicted entries every round: once an entry was trimmed, its
  tool_call_id left the seen set while its ToolMessage stayed in
  context, so the next model call re-extracted it, re-registered it as
  newest, and evicted something else — a never-converging rotation.
  The empty-result memo is now a unified _processed_results memo that
  records every extraction regardless of outcome, making evictions
  final; steady state over a saturated thread is quiescent (pinned by
  a multi-round test asserting no updates and zero extractions)
- the consumption scan built its handle map from pre-capture state, so
  a call referencing a handle captured in the same before_model call
  missed, got settled as quiet, and its consumption mark was lost
  forever. _track_consumption now receives the pending capture entries
  and resolves against state + pending; a call settles as quiet only
  after every referenced handle resolved, so transient misses retry
  next round instead of permanently suppressing the mark

AGENTS.md entry 15 updated to describe both memos and the
eviction-is-final semantics.

* fix(artifacts): address occurrence identity, PII and URL review findings

* fix(artifacts): enforce resolved policy checks and bounded registry handling

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
李靖 2026-09-27 11:09:41 +08:00 committed by GitHub
parent a253faefdf
commit 10bd065615
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 3744 additions and 38 deletions

View File

@ -631,6 +631,8 @@ Text results retain their original representation, including large numeric IDs a
Copy actions copy only the displayed preview. This is a frontend view of data
already received by the browser, without an additional secret-redaction layer.
Tool-produced paths and URLs can be retained as short artifact handles across context compaction (`tool_artifacts` in `config.yaml`). Handles distinguish separate tool-result occurrences, even when a provider reuses call IDs. Detected file URLs preserve their query strings and fragments. When PII redaction is enabled, model-visible artifact labels follow that policy; internal references stay intact for tool argument resolution. The configured registry limit retains the newest artifacts, while checkpointed processing identities prevent evicted results from being recaptured after restart. Resolution runs before authorization and write-safety checks; unknown or expired handles return an error without executing the tool. Small unknown structured results may be retained as complete JSON up to 4096 UTF-8 bytes; empty or oversized payloads are skipped. Handles are agent-local: task arguments resolve parent handles to concrete references, and delegated reports must return concrete references rather than child-local handles. A truncated model projection reports how many handles are omitted.
DeerFlow supports configurable MCP servers and skills to extend its capabilities.
For HTTP/SSE MCP servers, OAuth token flows are supported (`client_credentials`, `refresh_token`).
Durable HTTP/SSE task status and cancellation calls select configured `user_auth` credentials using the persisted task owner, including after restart; per-request secrets are not retained for background calls. If a request-scoped credential overrides submit authentication, both credentials must authorize access to the same remote task.

View File

@ -609,6 +609,7 @@ def build_middlewares(
DurableContextMiddleware(
skills_container_path=resolved_app_config.skills.container_path,
skill_file_read_tool_names=resolved_app_config.summarization.skill_file_read_tool_names,
inject_tool_artifacts=resolved_app_config.tool_artifacts.enabled and resolved_app_config.tool_artifacts.inject_model_context,
task_continuity_enabled=getattr(getattr(resolved_app_config, "task_continuity", None), "enabled", False) is True,
pii_redaction_config=getattr(resolved_app_config, "pii_redaction", None),
)

View File

@ -75,7 +75,8 @@ strict providers reject.
force eager sandbox acquisition.
8. **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
9. **LLMErrorHandlingMiddleware** - Converts provider/model failures to recoverable assistant errors. Sync and async calls carry circuit-generation ownership; only the current owner may settle or release a half-open probe, so stale completions cannot affect a newer recovery attempt. Cancellation propagates unchanged without retry or failure accounting.
10. **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](../../../../../docs/GUARDRAILS.md).
10. **ArtifactResolutionMiddleware** - See [tool artifacts](TOOL_ARTIFACTS.md).
11. **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](../../../../../docs/GUARDRAILS.md).
Every guardrail decision path publishes a neutral
`deerflow.authz.outcome.AuthorizationOutcome` into the per-run runtime context,
@ -83,10 +84,11 @@ strict providers reject.
`__authorization_outcome` key (so `build_run_config` strips caller-supplied
forgeries). Consumers pop it; the publisher and the consumer share only that
contract module.
11. **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 `|`/`&&`/`;`, an `eval`/`source` argument) executes fetched content and is blocked; **value position** (`x=$(curl url)`, `echo $(curl url)`, an argument, a `for` word list) only captures output and passes (#4611). So `_HIGH_RISK_COMMAND_POSITION_PATTERNS` is 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_PREFIX` extends 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 keeps `x=$(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)"`): an `eval`/`source` argument, 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_SUBSTITUTION` opener. An unquoted newline splits like `;` (else `echo hi\n$(curl url)` evades the anchored rules). Heredoc bodies are data: `_split_compound_command` records 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 outside `eval`/`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.
12. **ReadBeforeWriteMiddleware** - *(optional, `read_before_write.enabled`, default on)* Outermost write gate (#3857): `read_file` stamps a content hash on its ToolMessage; `write_file` (existing file, incl. append) and `str_replace` are 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-stamp `deerflow_tool_meta` and carry `deerflow_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; `SandboxAuthorizationError` becomes an error ToolMessage. Its `wrap_model_call` swaps 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 shared `tool_call_args` helper rewrites every arg surface together and every model-bound arg rewrite must use it.
13. **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 with `deerflow_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 emit `middleware:tool_progress`, including `reset` when a later agent invocation (for example, goal continuation) clears WARNED/BLOCKED state. The state machine supplies the action and count threshold (`null` for 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](../../../../../docs/RUN_EVENT_STREAM.md).
14. **ToolReceiptMiddleware + ToolErrorHandlingMiddleware** - `ToolReceiptMiddleware` is *(optional, if `verification.receipts_enabled`, default on)*. It is the **outermost `wrap_tool_call` layer** — registered ahead of entries 10-13 — 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 in `deerflow.extensions.ordering`). Normal results still carry the `deerflow_tool_meta` status ToolErrorHandlingMiddleware stamps on the inner return path; short-circuit messages self-stamp meta or fall back to `message.status`. It stamps deterministic provenance (tool name, status, args/output hashes, byte count, timestamp) onto direct `ToolMessage` results and every matching `ToolMessage` carried in `Command.update.messages`, including delegated `task`, `present_file`, `view_image`, and `tool_search` results; 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 example `r24`–`r30`) rather than requiring `r1`, 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. `ToolErrorHandlingMiddleware` receives `AppConfig`, converts tool exceptions into error `ToolMessage`s 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.
12. **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 `|`/`&&`/`;`, an `eval`/`source` argument) executes fetched content and is blocked; **value position** (`x=$(curl url)`, `echo $(curl url)`, an argument, a `for` word list) only captures output and passes (#4611). So `_HIGH_RISK_COMMAND_POSITION_PATTERNS` is 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_PREFIX` extends 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 keeps `x=$(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)"`): an `eval`/`source` argument, 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_SUBSTITUTION` opener. An unquoted newline splits like `;` (else `echo hi\n$(curl url)` evades the anchored rules). Heredoc bodies are data: `_split_compound_command` records 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 outside `eval`/`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.
13. **ReadBeforeWriteMiddleware** - *(optional, `read_before_write.enabled`, default on)* Outermost write gate (#3857): `read_file` stamps a content hash on its ToolMessage; `write_file` (existing file, incl. append) and `str_replace` are 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-stamp `deerflow_tool_meta` and carry `deerflow_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; `SandboxAuthorizationError` becomes an error ToolMessage. Its `wrap_model_call` swaps 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 shared `tool_call_args` helper rewrites every arg surface together and every model-bound arg rewrite must use it.
14. **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 with `deerflow_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 emit `middleware:tool_progress`, including `reset` when a later agent invocation (for example, goal continuation) clears WARNED/BLOCKED state. The state machine supplies the action and count threshold (`null` for 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](../../../../../docs/RUN_EVENT_STREAM.md).
15. **ToolReceiptMiddleware + ToolErrorHandlingMiddleware** - `ToolReceiptMiddleware` is *(optional, if `verification.receipts_enabled`, default on)*. It is the **outermost `wrap_tool_call` layer** — registered ahead of authorization, audit, write and progress gates — 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 in `deerflow.extensions.ordering`). Normal results still carry the `deerflow_tool_meta` status ToolErrorHandlingMiddleware stamps on the inner return path; short-circuit messages self-stamp meta or fall back to `message.status`. It stamps deterministic provenance (tool name, status, args/output hashes, byte count, timestamp) onto direct `ToolMessage` results and every matching `ToolMessage` carried in `Command.update.messages`, including delegated `task`, `present_file`, `view_image`, and `tool_search` results; 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 example `r24`–`r30`) rather than requiring `r1`, 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. `ToolErrorHandlingMiddleware` receives `AppConfig`, converts tool exceptions into error `ToolMessage`s 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.
16. **ArtifactCaptureMiddleware** - See [tool artifacts](TOOL_ARTIFACTS.md).
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.
@ -100,32 +102,32 @@ Before changing a later authorization phase, read the [authorization RFC](../../
**Lead-only middlewares** (`build_middlewares`, appended after the base):
15. **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_TIMEZONE` names an IANA zone (invalid values fall back locally).
16. **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
17. **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.
18. **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.
19. **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.
20. **TodoListMiddleware** - *(optional, if `is_plan_mode`)* Task tracking with `write_todos`. Skips reminders on `model_length_termination` so capped turns end cleanly.
21. **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.
22. **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.
23. **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
24. **ViewImageMiddleware** - *(vision models only)* Rebuilds hidden base64 image context in `ModelRequest.messages`, never in new checkpoint state; state retains lightweight `viewed_images` metadata. `_image_injection_plan` first sweeps stale server-marked context (including payloads stranded by the old `before_model`/`after_model` lifecycle), then shares one read-eligibility predicate between sync injection and async authorization. The sweep requires both the reserved ID prefix and server-owned marker, preserving user messages and unmarked legacy payloads. Before reading images, both paths recheck `sandbox:execute`, including restored views after role changes; denial skips I/O and removes marked stale context. Host-copy reads additionally require the saved `actual_path` to match the current user/thread's canonical virtual image path, including for old checkpoints and replacement sandboxes; a missing or mismatched thread identity skips that host read. Only the async wrapper passes `authorization_checked=True` after its gate, avoiding a duplicate synchronous provider call in its worker.
25. **McpRoutingMiddleware** - Auto-promotes deferred schemas matching the latest real user message before `DeferredToolFilterMiddleware`, without executing tools. New names emit `middleware:tool_promotion` with `source=routing_hint`; repeat passes emit nothing
26. **DeferredToolPromotionAuditMiddleware** - Observes final `tool_search` `Command`s; keep it outer of `SkillToolPolicyMiddleware` so 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 original `Command`, and omits private payloads
27. **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)
28. **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
29. **SubagentLimitMiddleware** - *(optional, if `subagent_enabled`)* Truncates excess ordinary `task` tool calls to enforce both the per-response concurrency limit (`max_concurrent_subagents`, resolved against startup `subagent_runtime.max_running` and the 1-64 safety range before construction) 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. Explicit durable `batch_task` calls 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 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.
17. **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_TIMEZONE` names an IANA zone (invalid values fall back locally).
18. **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
19. **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.
20. **DurableContextMiddleware** - Before compaction, captures task dispatches/result summaries in `ThreadState.delegations` and loaded skill name/path/description references (never bodies) in `ThreadState.skill_context`; projects them 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. See [tool artifacts](TOOL_ARTIFACTS.md) for projection, retry limits and delegation scope.
21. **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.
22. **TodoListMiddleware** - *(optional, if `is_plan_mode`)* Task tracking with `write_todos`. Skips reminders on `model_length_termination` so capped turns end cleanly.
23. **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.
24. **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.
25. **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
26. **ViewImageMiddleware** - *(vision models only)* Rebuilds hidden base64 image context in `ModelRequest.messages`, never in new checkpoint state; state retains lightweight `viewed_images` metadata. `_image_injection_plan` first sweeps stale server-marked context (including payloads stranded by the old `before_model`/`after_model` lifecycle), then shares one read-eligibility predicate between sync injection and async authorization. The sweep requires both the reserved ID prefix and server-owned marker, preserving user messages and unmarked legacy payloads. Before reading images, both paths recheck `sandbox:execute`, including restored views after role changes; denial skips I/O and removes marked stale context. Host-copy reads additionally require the saved `actual_path` to match the current user/thread's canonical virtual image path, including for old checkpoints and replacement sandboxes; a missing or mismatched thread identity skips that host read. Only the async wrapper passes `authorization_checked=True` after its gate, avoiding a duplicate synchronous provider call in its worker.
27. **McpRoutingMiddleware** - Auto-promotes deferred schemas matching the latest real user message before `DeferredToolFilterMiddleware`, without executing tools. New names emit `middleware:tool_promotion` with `source=routing_hint`; repeat passes emit nothing
28. **DeferredToolPromotionAuditMiddleware** - Observes final `tool_search` `Command`s; keep it outer of `SkillToolPolicyMiddleware` so 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 original `Command`, and omits private payloads
29. **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)
30. **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
31. **SubagentLimitMiddleware** - *(optional, if `subagent_enabled`)* Truncates excess ordinary `task` tool calls to enforce both the per-response concurrency limit (`max_concurrent_subagents`, resolved against startup `subagent_runtime.max_running` and the 1-64 safety range before construction) 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. Explicit durable `batch_task` calls 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 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.
Delegations use `(run_id, tool_call_id)` for capture and retention. Later-run replies cannot close older orphans; missing run IDs match conservatively.
30. **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; stamps `loop_capped` via `consume_stop_reason` (#3875 Phase 2), symmetric to `TokenBudgetMiddleware`; persists warned-state transitions (first per call hash or per tool-frequency burst) and hard stops as `middleware:loop_detection`, attributed with `is_subagent` and the optional `agent_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 pass `RunJournal` into their isolated loop. Durable batch subagents have no parent run journal and do not persist these transitions
32. **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; stamps `loop_capped` via `consume_stop_reason` (#3875 Phase 2), symmetric to `TokenBudgetMiddleware`; persists warned-state transitions (first per call hash or per tool-frequency burst) and hard stops as `middleware:loop_detection`, attributed with `is_subagent` and the optional `agent_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 pass `RunJournal` into 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](../../../../../docs/LOOP_DETECTION.md) for
fallback identity, cleanup/LRU/reset, severity ordering, and test invariants.
31. **TokenBudgetMiddleware** - `token_budget.enabled`: shares run-ID budgets across continuations; missing/invalid IDs clear invocation state.
32. **Custom middlewares** - *(optional)* Any `custom_middlewares` passed to `build_middlewares` are injected here, before config-declared extensions and the terminal-response/safety/clarification tail
33. **Configured extension middlewares** - `extensions.middlewares` in `config.yaml` or `extensions_config.json` optionally accepts `module.path:ClassName` strings or `{class, kwargs}` objects. `deerflow.reflection.resolve_class` loads `AgentMiddleware` classes; import, class, and constructor errors fail agent creation. `kwargs` must 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.
34. **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
35. **ModelLengthFinishReasonMiddleware** - Match stamps `stop_reason=model_length_capped` and ends tool loop. Suppresses calls, appends notice even with partial text, and stamps `model_length_termination` so downstream guards stand down. Preserves content blocks.
36. **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
37. **ClarificationMiddleware** - Intercepts `ask_clarification`, writes a readable `ToolMessage.content` fallback plus a structured `ToolMessage.artifact.human_input` payload, and interrupts via `Command(goto=END)` (must be last). `after_model` drops same-turn sibling tool calls so they cannot run before the user answers; a malformed `ask_clarification` parked on `invalid_tool_calls` is the same stop signal. `disable_clarification` runs keep the siblings. Payloads are versioned — legacy `free_text`/`choice_with_other` stay `version: 1`; the v2 `form` mode (from `fields`) is `version: 2` so 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 JS `Object.prototype` member (`__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 like `type: []`, which must not raise from the membership probe — and option-less selects become `text`); 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"; `required` on one means consent semantics. The response protocol is unchanged (v1 `text`/`option`): form cards submit a text summary as `response_kind: "text"`, so journal persistence needs no new allowlist entries. `RunJournal` reconciles visible, unpersisted `ToolMessage`s for current-run lead-agent calls at the next lead-agent `on_chat_model_start` after consumption, or at successful root `on_chain_end`. This preserves any middleware short-circuit, including blocked writes, across later model errors and checkpoint compaction (#4666). `_remember_current_run_tool_calls` excludes subagents; their results stay in `subagent.step`. Human Input Card replies are `hide_from_ui` `HumanMessage`s with `additional_kwargs.human_input_response`; `RunJournal` persists only allowlisted hidden sources (currently `ask_clarification`) as `llm.human.input`.
33. **TokenBudgetMiddleware** - `token_budget.enabled`: shares run-ID budgets across continuations; missing/invalid IDs clear invocation state.
34. **Custom middlewares** - *(optional)* Any `custom_middlewares` passed to `build_middlewares` are injected here, before config-declared extensions and the terminal-response/safety/clarification tail
35. **Configured extension middlewares** - `extensions.middlewares` in `config.yaml` or `extensions_config.json` optionally accepts `module.path:ClassName` strings or `{class, kwargs}` objects. `deerflow.reflection.resolve_class` loads `AgentMiddleware` classes; import, class, and constructor errors fail agent creation. `kwargs` must 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.
36. **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
37. **ModelLengthFinishReasonMiddleware** - Match stamps `stop_reason=model_length_capped` and ends tool loop. Suppresses calls, appends notice even with partial text, and stamps `model_length_termination` so downstream guards stand down. Preserves content blocks.
38. **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
39. **ClarificationMiddleware** - Intercepts `ask_clarification`, writes a readable `ToolMessage.content` fallback plus a structured `ToolMessage.artifact.human_input` payload, and interrupts via `Command(goto=END)` (must be last). `after_model` drops same-turn sibling tool calls so they cannot run before the user answers; a malformed `ask_clarification` parked on `invalid_tool_calls` is the same stop signal. `disable_clarification` runs keep the siblings. Payloads are versioned — legacy `free_text`/`choice_with_other` stay `version: 1`; the v2 `form` mode (from `fields`) is `version: 2` so 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 JS `Object.prototype` member (`__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 like `type: []`, which must not raise from the membership probe — and option-less selects become `text`); 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"; `required` on one means consent semantics. The response protocol is unchanged (v1 `text`/`option`): form cards submit a text summary as `response_kind: "text"`, so journal persistence needs no new allowlist entries. `RunJournal` reconciles visible, unpersisted `ToolMessage`s for current-run lead-agent calls at the next lead-agent `on_chat_model_start` after consumption, or at successful root `on_chain_end`. This preserves any middleware short-circuit, including blocked writes, across later model errors and checkpoint compaction (#4666). `_remember_current_run_tool_calls` excludes subagents; their results stay in `subagent.step`. Human Input Card replies are `hide_from_ui` `HumanMessage`s with `additional_kwargs.human_input_response`; `RunJournal` persists only allowlisted hidden sources (currently `ask_clarification`) as `llm.human.input`.

View File

@ -0,0 +1,21 @@
# Tool artifact middleware
## Resolution
*(optional, if `tool_artifacts.enabled && tool_artifacts.resolve_handles_in_args`, default on)* Resolves `art_xxxxxxxx` artifact handles found in tool-call arguments to their real references (`ThreadState.tool_artifacts[].real_ref`) before the tool executes (issue #4676). Runs after the outer receipt layer and before authorization/guardrail, sandbox-audit, read-before-write and progress policies; the same resolved arguments reach policy and execution. Declarative constraints enforce this order even after extension insertion. It mutates only the request args, never the message history. Handles may appear bare, inside backticks, or nested in dict/list args; unknown or expired handles return an error ToolMessage naming the missing handles and up to ten current handles, without executing the tool. This applies even to an empty registry; ordinary concrete arguments and disabled-feature behavior remain unchanged. Both config flags must be on — disabling `tool_artifacts.enabled` stops resolution for threads that still carry registry state.
## Capture
*(optional, if `tool_artifacts.enabled`, default on)* Captures lightweight artifact references from tool results into `ThreadState.tool_artifacts` (a reducer channel, so entries survive summarization compaction). `tool_artifacts.enabled` gates the whole middleware (capture and consumption tracking); when off, `before_model` returns nothing regardless of registry state. Runs as a `before_model` hook (never wraps a `ToolMessage` in a `Command`). `ThreadState.tool_artifact_processed` stores deduplicated hashes of processed result and settled consuming-call occurrences through an additive reducer. It records empty results too. The ledger is independent of registry retention and survives checkpoint reload, graph recreation and compaction; it is not trimmed with artifacts and grows by a small identity record per processed occurrence. Never replace it with a process-local memo or infer it from currently retained handles. A missing handle gets one retry before its consuming-call occurrence settles, so permanently unknown or evicted handles are scanned at most twice, including across restarts; same-round consumption resolves against state plus pending captures. Capture and consumption updates concatenate list channels so neither is lost. Position-independent in the tool-execution wrap chain: it is a `before_model` hook reading state messages, so it sees the normalized results stored in state regardless of its wrap position (error results are skipped at extraction via `status == "error"`); it is appended after ToolErrorHandlingMiddleware for readability only. Handles are `art_` + 8 hex chars derived deterministically from (thread_id, durable ToolMessage.id, provider tool_call_id, per-result ordinal). Provider call IDs may repeat across turns; graph-assigned message IDs distinguish occurrences and remain stable after compaction. Standalone ID-less hook callers must supply distinct call indices. These short hashes are identifiers, not ownership credentials. Extraction combines all of: structured-content string values under known keys (`file`/`path`/`url`/`task_id`/...) as concrete refs (unknown or non-reference payloads fall back to complete JSON `data` only when they fit 4096 UTF-8 bytes, 1024 nodes and 32 levels; empty and oversized payloads are skipped before unbounded serialization), `file`/`image` content blocks with HTTP(S), Unix-absolute or drive-absolute references (`data:`/`blob:` URIs are rejected so embedded-resource payloads never enter state or tool args), and conservative free-text scanning of both text blocks and plain-string results such as built-in sandbox tool output (sandbox paths + remote file URLs including query/fragment suffixes, gated by `detect_refs_in_text`). The reducer enforces only an absolute ceiling (1000); the configured `tool_artifacts.max_entries` is a per-agent sliding retention window: when a capture round would exceed it, the middleware appends a trailing `{"op": "trim_to", "keep": N}` directive and the reducer evicts the oldest entries (fresh captures are always registered — nothing freezes at the cap), so agents with different configs in one process do not share a cap. Reference entries contain metadata (path/URL, name, type, MIME). The JSON compatibility fallback is an explicitly bounded exception. Concrete path/URL keys reject prose and relative paths; remote task IDs use a separate non-empty, whitespace-free, 256-character token gate. File references on all extraction paths are bounded to 4096 UTF-8 bytes. Structured traversal is bounded before fallback encoding, and valid content-block references are still captured if structured data is rejected.
## Durable projection
Artifact handles are projected with working notes and summary history in the hidden HumanMessage data block when `tool_artifacts.enabled` and `inject_model_context` are enabled. Every model-visible artifact label (display name, type, tool name, MIME type) is redacted with the configured PII policy before HTML escaping. Generated handles remain usable and internal `real_ref` values remain unchanged for resolution. Values remain untrusted data. Artifact capture precedes compaction; task continuity and PII redaction retain independent configuration. `inject_tool_artifacts` participates in the middleware release policy fingerprint.
## Delegation scope (MVP)
Registries and processed-occurrence ledgers are agent-local. `SubagentExecutor._build_initial_state` does not copy either channel from the parent, and the terminal `task` Command does not merge child registry channels back into the lead. Lead handles in `task` arguments resolve to concrete references before delegation. A subagent must return concrete references in its report; a child-local handle echoed in a report is not a valid lead handle and is rejected at resolution. No bidirectional registry transfer or durable MCP task/result integration is claimed by this MVP. The model-visible registry header states this boundary.
## Projection and async verification
Registry projection retains the newest entries and reserves space for an explicit `N more artifact handles not shown` marker within its character budget. Omitted entries remain resolvable while retained in state; omission is different from expiration. `tests/blocking_io/test_artifact_middlewares.py` drives a real async capture → projection → resolution cycle and exercises unknown-handle errors and oversized structured payload rejection under `detect_blocking_io_strict`.

View File

@ -0,0 +1,205 @@
"""Middleware that captures tool-result artifacts into ``ThreadState.tool_artifacts``.
Runs as a ``before_model`` hook (not ``wrap_tool_call``) so it never wraps a
``ToolMessage`` in a ``Command``. It scans the message tail for tool messages
whose artifacts have not been captured yet, extracts ``ArtifactEntry`` records,
and returns a state update. It also tracks which handles were consumed by later
tool calls so the model-context projection can mark them ``[consumed]``.
"""
from __future__ import annotations
import hashlib
import json
import re
from typing import Any, cast, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import AIMessage, ToolMessage
from langgraph.runtime import Runtime
from deerflow.agents.thread_state import ArtifactEntry
from deerflow.config.tool_artifact_config import ToolArtifactConfig
from deerflow.tools.artifact_registry import extract_artifacts_from_result
_HANDLE_PATTERN = r"(?:`(art_[0-9a-f]{8})`|(?<!\w)(art_[0-9a-f]{8})(?!\w))"
class ArtifactCaptureMiddleware(AgentMiddleware[AgentState]):
"""Capture tool-result artifact references into durable thread state."""
def __init__(self, config: ToolArtifactConfig | None = None) -> None:
super().__init__()
self._config = config or ToolArtifactConfig()
self._handle_re = re.compile(_HANDLE_PATTERN)
@override
def before_model(self, state: AgentState, runtime: Runtime) -> dict | None:
if not self._config.enabled:
return None
thread_id = self._thread_id(runtime)
capture_update = self._capture(state, runtime)
pending: list[ArtifactEntry] = []
if capture_update:
pending = [cast(ArtifactEntry, item) for item in capture_update.get("tool_artifacts", []) if isinstance(item, dict) and "handle" in item]
consumption_update = self._track_consumption(state, thread_id, pending)
return self._merge_updates(capture_update, consumption_update)
@override
async def abefore_model(self, state: AgentState, runtime: Runtime) -> dict | None:
if not self._config.enabled:
return None
thread_id = self._thread_id(runtime)
capture_update = self._capture(state, runtime)
pending: list[ArtifactEntry] = []
if capture_update:
pending = [cast(ArtifactEntry, item) for item in capture_update.get("tool_artifacts", []) if isinstance(item, dict) and "handle" in item]
consumption_update = self._track_consumption(state, thread_id, pending)
return self._merge_updates(capture_update, consumption_update)
@staticmethod
def _merge_updates(*updates: dict | None) -> dict | None:
"""Merge state updates without clobbering list channels.
Both ``_capture`` and ``_track_consumption`` can fire in the same
``before_model`` call; a plain ``dict.update`` would replace the shared
``tool_artifacts`` value and silently drop one side's entries. List
values under the same key are concatenated instead — the reducer is
same-handle latest-wins, so duplicates collapse safely.
"""
merged: dict[str, Any] = {}
for update in updates:
if not update:
continue
for key, value in update.items():
if key in merged and isinstance(merged[key], list) and isinstance(value, list):
merged[key] = [*merged[key], *value]
else:
merged[key] = value
return merged or None
def _thread_id(self, runtime: Runtime | None) -> str:
context = getattr(runtime, "context", None)
tid = context.get("thread_id") if isinstance(context, dict) else None
return str(tid) if tid else ""
def _capture(self, state: AgentState, runtime: Runtime | None) -> dict | None:
if not self._config.enabled:
return None
thread_id = self._thread_id(runtime)
if not thread_id:
return None
messages = state.get("messages") or []
existing = state.get("tool_artifacts") or []
existing_handles = {entry.get("handle") for entry in existing if isinstance(entry, dict)}
processed = set(state.get("tool_artifact_processed") or [])
newly_processed: list[str] = []
occurrences: dict[str, int] = {}
new_entries: list[ArtifactEntry] = []
for message in messages:
if not isinstance(message, ToolMessage):
continue
tool_call_id = message.tool_call_id or ""
call_index = occurrences.get(tool_call_id, 0)
occurrences[tool_call_id] = call_index + 1
identity = self._occurrence_key("result", thread_id, message.id, tool_call_id, call_index)
if identity in processed:
continue
entries = extract_artifacts_from_result(message, thread_id=thread_id, call_index=0 if message.id is not None else call_index, detect_refs_in_text=self._config.detect_refs_in_text)
processed.add(identity)
newly_processed.append(identity)
if not entries:
continue
for entry in entries:
if entry["handle"] in existing_handles:
continue
existing_handles.add(entry["handle"])
new_entries.append(entry)
update: dict[str, Any] = {}
if newly_processed:
update["tool_artifact_processed"] = newly_processed
if new_entries:
update["tool_artifacts"] = new_entries
if len(existing) + len(new_entries) > self._config.max_entries:
update["tool_artifacts"].append({"op": "trim_to", "keep": self._config.max_entries})
return update or None
@staticmethod
def _occurrence_key(kind: str, thread_id: str, message_id: str | None, tool_call_id: str, index: int) -> str:
# Graph message reducers assign durable IDs, including to imported
# history. The index fallback supports standalone ID-less hook callers.
seed = [thread_id, message_id, tool_call_id, index if message_id is None or kind == "call" else 0]
return kind + ":" + hashlib.sha256(json.dumps(seed).encode("utf-8")).hexdigest()
def _track_consumption(self, state: AgentState, thread_id: str = "", pending_entries: list[ArtifactEntry] | None = None) -> dict | None:
# The effective registry includes entries being captured in this very
# before_model call: an AIMessage may already reference a handle whose
# registration rides along in the same update, and settling the scan as
# "quiet" on a transient miss would lose the consumption mark forever.
registry_items: list[Any] = [*(state.get("tool_artifacts") or []), *(pending_entries or [])]
handle_map: dict[str, ArtifactEntry] = {entry["handle"]: cast(ArtifactEntry, entry) for entry in registry_items if isinstance(entry, dict)}
messages = state.get("messages") or []
consumed_updates: list[ArtifactEntry] = []
processed = set(state.get("tool_artifact_processed") or [])
newly_processed: list[str] = []
occurrences: dict[str, int] = {}
for message in messages:
if not isinstance(message, AIMessage):
continue
for tool_index, tool_call in enumerate(message.tool_calls or []):
tool_call_id = tool_call.get("id")
args = tool_call.get("args")
if not tool_call_id or not isinstance(args, dict):
continue
# Args are immutable once in state. Settle the call only when
# every referenced handle resolved against the effective
# registry; a transient miss retries next round.
fallback_index = occurrences.get(tool_call_id, 0)
occurrences[tool_call_id] = fallback_index + 1
quiet_key = self._occurrence_key("call", thread_id, message.id, tool_call_id, tool_index if message.id is not None else fallback_index)
if quiet_key in processed:
continue
had_unresolved = False
for handle in self._find_handles(args):
entry = handle_map.get(handle)
if entry is None:
had_unresolved = True
continue
consumed = list(entry.get("consumed_by") or [])
if tool_call_id in consumed:
continue
updated: dict[str, Any] = {**entry, "consumed_by": [*consumed, tool_call_id]}
consumed_updates.append(cast(ArtifactEntry, updated))
handle_map[handle] = consumed_updates[-1]
# A missing entry may arrive in the next capture round. Retry
# once, then settle permanently missing/evicted handles. Both
# attempts are checkpointed, so restarts cannot reset the bound.
retry_key = "retry:" + quiet_key
settled_key = quiet_key if not had_unresolved or retry_key in processed else retry_key
processed.add(settled_key)
newly_processed.append(settled_key)
update: dict[str, Any] = {}
if consumed_updates:
update["tool_artifacts"] = consumed_updates
if newly_processed:
update["tool_artifact_processed"] = newly_processed
return update or None
def _find_handles(self, value: Any) -> set[str]:
"""Recursively find artifact handles in tool-call args."""
handles: set[str] = set()
if isinstance(value, str):
for match in self._handle_re.finditer(value):
handles.add(match.group(1) or match.group(2))
elif isinstance(value, dict):
for item in value.values():
handles.update(self._find_handles(item))
elif isinstance(value, list):
for item in value:
handles.update(self._find_handles(item))
return handles

View File

@ -0,0 +1,119 @@
"""Middleware that resolves artifact handles in tool arguments to real references.
The model references artifacts by short handles (``art_xxxxxxxx``). Before a
tool executes, this middleware replaces those handles in the tool-call arguments
with the real reference (path, URL, task id) recorded in
``ThreadState.tool_artifacts``. Handles may appear bare, inside backticks, or
embedded in a longer string. Unknown or expired handles return a structured
error without executing the tool.
"""
from __future__ import annotations
import re
from collections.abc import Awaitable, Callable
from typing import override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import ToolMessage
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.types import Command
from deerflow.agents.middlewares.tool_result_meta import normalize_tool_result
from deerflow.config.tool_artifact_config import ToolArtifactConfig
_HANDLE_PATTERN = r"(?:`(art_[0-9a-f]{8})`|(?<!\w)(art_[0-9a-f]{8})(?!\w))"
class ArtifactResolutionMiddleware(AgentMiddleware[AgentState]):
"""Resolve artifact handles in tool arguments before execution."""
def __init__(self, config: ToolArtifactConfig | None = None) -> None:
super().__init__()
self._config = config or ToolArtifactConfig()
self._handle_re = re.compile(_HANDLE_PATTERN)
@override
def wrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
if not (self._config.enabled and self._config.resolve_handles_in_args):
return handler(request)
resolved = self._resolve_request(request)
return resolved if isinstance(resolved, ToolMessage) else handler(resolved)
@override
async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
) -> ToolMessage | Command:
if not (self._config.enabled and self._config.resolve_handles_in_args):
return await handler(request)
resolved = self._resolve_request(request)
return resolved if isinstance(resolved, ToolMessage) else await handler(resolved)
def _resolve_request(self, request: ToolCallRequest) -> ToolCallRequest | ToolMessage:
args = request.tool_call.get("args")
if not isinstance(args, dict):
return request
state = request.state or {}
artifacts = state.get("tool_artifacts") or []
handle_map: dict[str, str] = {}
for entry in artifacts:
if isinstance(entry, dict) and entry.get("handle") and isinstance(entry.get("real_ref"), str) and entry["real_ref"]:
handle_map[str(entry["handle"])] = entry["real_ref"]
unknown = self._unknown_handles(args, handle_map)
if unknown:
missing = ", ".join(sorted(unknown)[:10])
if len(unknown) > 10:
missing += f" (and {len(unknown) - 10} more)"
available = [h for h in handle_map if re.fullmatch(r"art_[0-9a-f]{8}", h)]
current = ", ".join(available[-10:]) or "none"
message = ToolMessage(
content=(
f"Error: Unknown or expired artifact handle(s): {missing}. Handles are local to this agent. Current handles (up to 10): {current}. Use a current handle or obtain a new concrete reference; do not guess a replacement."
),
name=str(request.tool_call.get("name") or "unknown"),
tool_call_id=str(request.tool_call.get("id") or "missing_tool_call_id"),
status="error",
)
return normalize_tool_result(message, tool_call_id=message.tool_call_id)
resolved_args = self._resolve_value(args, handle_map)
if resolved_args == args:
return request
return request.override(tool_call={**request.tool_call, "args": resolved_args})
def _unknown_handles(self, value, handle_map: dict[str, str]) -> set[str]:
if isinstance(value, str):
return {handle for match in self._handle_re.finditer(value) if (handle := match.group(1) or match.group(2)) not in handle_map}
if isinstance(value, dict):
value = list(value.values())
if isinstance(value, list):
return set().union(*(self._unknown_handles(item, handle_map) for item in value))
return set()
def _resolve_value(self, value, handle_map: dict[str, str]):
if isinstance(value, str):
return self._resolve_string(value, handle_map)
if isinstance(value, dict):
return {key: self._resolve_value(item, handle_map) for key, item in value.items()}
if isinstance(value, list):
return [self._resolve_value(item, handle_map) for item in value]
return value
def _resolve_string(self, text: str, handle_map: dict[str, str]) -> str:
def replace_handle(match: re.Match) -> str:
handle = match.group(1) or match.group(2)
real_ref = handle_map.get(handle)
if real_ref:
return real_ref
return match.group(0)
return self._handle_re.sub(replace_handle, text)

View File

@ -32,6 +32,7 @@ from deerflow.config.pii_redaction_config import PiiRedactionConfig
from deerflow.config.summarization_config import DEFAULT_SKILL_FILE_READ_TOOL_NAMES
from deerflow.constants import DEFAULT_SKILLS_CONTAINER_PATH
from deerflow.runtime.context_keys import CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY
from deerflow.tools.artifact_registry import render_artifact_registry
_DURABLE_CONTEXT_DATA_KEY = "durable_context_data"
_SUMMARY_RENDER_CHAR_BUDGET = 6000
@ -69,7 +70,7 @@ def _bound_text(text: str, cap: int) -> str:
return f"{text[:head]}{omitted_marker}{text[-tail:]}"
def _render_durable_context_data(summary_text: str | None, ledger: list, skills: list, task_notes: dict | None = None, task_history: dict | None = None) -> str:
def _render_durable_context_data(summary_text: str | None, ledger: list, skills: list, task_notes: dict | None = None, task_history: dict | None = None, artifacts: list | None = None) -> str:
data_parts: list[str] = []
if summary_text:
bounded_summary = _bound_text(str(summary_text), _SUMMARY_RENDER_CHAR_BUDGET)
@ -83,6 +84,9 @@ def _render_durable_context_data(summary_text: str | None, ledger: list, skills:
if skill_block:
data_parts.append(skill_block)
artifact_block = render_artifact_registry(artifacts or [])
if artifact_block:
data_parts.append(artifact_block)
if task_notes is not None:
history = normalize_task_history(task_history)
note_data = json.dumps({"notes": normalize_task_notes(task_notes), "history_status": history.get("status", "no_compaction_yet"), "omitted_records": history.get("omitted_records", 0)}, ensure_ascii=False)
@ -258,6 +262,7 @@ class DurableContextMiddleware(AgentMiddleware[AgentState]):
*,
skills_container_path: str | None = None,
skill_file_read_tool_names: Collection[str] | None = None,
inject_tool_artifacts: bool = True,
task_continuity_enabled: bool = False,
pii_redaction_config: PiiRedactionConfig | None = None,
) -> None:
@ -266,6 +271,7 @@ class DurableContextMiddleware(AgentMiddleware[AgentState]):
self._pii_redaction_config = pii_redaction_config
self._skills_root = _normalize_skills_root(skills_container_path)
self._skill_read_tool_names = frozenset(DEFAULT_SKILL_FILE_READ_TOOL_NAMES if skill_file_read_tool_names is None else skill_file_read_tool_names)
self._inject_tool_artifacts = inject_tool_artifacts
def release_policy_parameters(self) -> dict[str, object]:
"""Describe the normalized inputs that govern capture and injection."""
@ -273,6 +279,7 @@ class DurableContextMiddleware(AgentMiddleware[AgentState]):
"skills_container_path": self._skills_root,
"skill_file_read_tool_names": sorted(self._skill_read_tool_names),
"task_continuity_enabled": self._task_continuity_enabled,
"inject_tool_artifacts": self._inject_tool_artifacts,
"pii_redaction_enabled": bool(self._pii_redaction_config and self._pii_redaction_config.enabled),
}
@ -321,12 +328,23 @@ class DurableContextMiddleware(AgentMiddleware[AgentState]):
def _inject(self, request: ModelRequest) -> ModelRequest:
state = request.state or {}
artifacts = []
if self._inject_tool_artifacts:
for entry in state.get("tool_artifacts") or []:
# Redact raw labels before HTML escaping; state and real_ref
# remain intact for server-side resolution. Handles are generated.
projected = dict(entry)
for field in ("display_name", "artifact_type", "tool_name", "mime_type"):
if isinstance(projected.get(field), str):
projected[field] = redact_text(projected[field], self._pii_redaction_config)
artifacts.append(projected)
data_block = _render_durable_context_data(
redact_text(state.get("summary_text"), self._pii_redaction_config),
state.get("delegations") or [],
state.get("skill_context") or [],
(state.get("task_notes") or {}) if self._task_continuity_enabled else None,
state.get("task_history") if self._task_continuity_enabled else None,
artifacts=artifacts,
)
if not data_block:
return request

View File

@ -280,6 +280,13 @@ def _build_runtime_middlewares(
tail.append(ToolReceiptMiddleware(render_mode=receipts_render_mode))
# Resolve handles before any policy inspects arguments. Receipts enclose
# this layer too, so unknown-handle errors remain part of the ledger.
if app_config.tool_artifacts.enabled and app_config.tool_artifacts.resolve_handles_in_args:
from deerflow.agents.middlewares.artifact_resolution_middleware import ArtifactResolutionMiddleware
tail.append(ArtifactResolutionMiddleware(config=app_config.tool_artifacts))
# Authorization uses the existing GuardrailMiddleware so execution-time
# deny, audit, and fail-closed handling stay in one proven implementation.
# It is appended before an explicit guardrail provider, making authorization
@ -356,6 +363,18 @@ def _build_runtime_middlewares(
tail.append(ToolProgressMiddleware.from_config(tool_progress_config))
tail.append(ToolErrorHandlingMiddleware(app_config=app_config))
# Artifact capture is a `before_model` hook that reads state messages, so
# its position in the tool-execution wrap chain is functionally irrelevant:
# it always sees the normalized results stored in state, and error results
# (status == "error") are skipped at extraction. It is appended after
# ToolErrorHandlingMiddleware purely for readability. It captures
# lightweight metadata only, so ToolOutputBudgetMiddleware truncating the
# content does not affect it. The configured cap (max_entries) is enforced
# inside the middleware when it emits updates; no assembly-time side effect.
if app_config.tool_artifacts.enabled:
from deerflow.agents.middlewares.artifact_capture_middleware import ArtifactCaptureMiddleware
tail.append(ArtifactCaptureMiddleware(config=app_config.tool_artifacts))
middlewares = [*outer_wrappers, *thread_hooks, *tail]
@ -556,6 +575,7 @@ def build_subagent_runtime_middlewares(
DurableContextMiddleware(
skills_container_path=app_config.skills.container_path,
skill_file_read_tool_names=app_config.summarization.skill_file_read_tool_names,
inject_tool_artifacts=app_config.tool_artifacts.enabled and app_config.tool_artifacts.inject_model_context,
pii_redaction_config=getattr(app_config, "pii_redaction", None),
)
)

View File

@ -283,6 +283,72 @@ def merge_skill_context(existing: list[SkillEntry] | None, new: list[SkillEntry]
return merged
# Absolute ceiling applied by the reducer itself. The operator-configured cap
# (`tool_artifacts.max_entries`) is enforced per-agent by
# ArtifactCaptureMiddleware when it emits updates, so two agents in one process
# can carry different caps without sharing state.
_ARTIFACT_MAX_ENTRIES_CEILING = 1000
class ArtifactEntry(TypedDict):
"""A captured artifact reference from a tool result (issue #4676).
Stored in ``ThreadState.tool_artifacts`` so it survives context compaction.
The model sees only the short ``handle``; the real reference (path, URL,
task id) lives here and is resolved at tool-call time.
"""
handle: str
tool_name: str
tool_call_id: str
call_index: int
artifact_type: str
display_name: str
real_ref: str
mime_type: NotRequired[str]
created_at: str
consumed_by: NotRequired[list[str]]
def merge_tool_artifacts(existing: list[ArtifactEntry] | None, new: list[ArtifactEntry] | None) -> list[ArtifactEntry]:
"""Reducer for the tool-artifact registry channel.
- new None/empty -> preserve existing.
- append entries, replacing same handle with the latest version while
preserving first-seen order (latest wins, e.g. for consumption updates).
- a trailing ``{"op": "trim_to", "keep": N}`` directive (emitted by the
capture middleware) makes the configured cap a sliding window: the
oldest entries beyond N are evicted. Without a directive nothing is
evicted, so updates stay purely additive.
- absolute ceiling of 1000 applies regardless.
"""
if not new:
return existing or []
by_handle: dict[str, ArtifactEntry] = {}
order: list[str] = []
for entry in [*(existing or []), *new]:
if not isinstance(entry, dict) or "handle" not in entry:
continue
handle = entry["handle"]
if handle not in by_handle:
order.append(handle)
by_handle[handle] = entry
merged = [by_handle[handle] for handle in order]
for item in reversed(new):
if isinstance(item, dict) and item.get("op") == "trim_to":
keep = min(int(item.get("keep", len(merged))), _ARTIFACT_MAX_ENTRIES_CEILING)
if keep < len(merged):
merged = merged[-keep:]
break
if len(merged) > _ARTIFACT_MAX_ENTRIES_CEILING:
merged = merged[-_ARTIFACT_MAX_ENTRIES_CEILING:]
return merged
class ThreadState(AgentState):
sandbox: SandboxStateField
thread_data: NotRequired[ThreadDataState | None]
@ -295,6 +361,8 @@ class ThreadState(AgentState):
promoted: Annotated[PromotedTools | None, merge_promoted]
delegations: Annotated[list[DelegationEntry], merge_delegations]
skill_context: Annotated[list[SkillEntry], merge_skill_context]
tool_artifacts: Annotated[list[ArtifactEntry], merge_tool_artifacts]
tool_artifact_processed: Annotated[list[str], merge_artifacts]
task_notes: Annotated[dict | None, TaskNotesChannel(dict | None, merge_task_notes)]
task_history: NotRequired[dict | None]
summary_text: NotRequired[str | None]
@ -414,6 +482,8 @@ THREAD_STATE_REDUCER_FIELDS = frozenset(
"promoted",
"delegations",
"skill_context",
"tool_artifacts",
"tool_artifact_processed",
"task_notes",
}
)

View File

@ -54,6 +54,7 @@ from deerflow.config.task_continuity_config import TaskContinuityConfig
from deerflow.config.title_config import TitleConfig, load_title_config_from_dict
from deerflow.config.token_budget_config import TokenBudgetConfig
from deerflow.config.token_usage_config import TokenUsageConfig
from deerflow.config.tool_artifact_config import ToolArtifactConfig
from deerflow.config.tool_config import ToolConfig, ToolGroupConfig
from deerflow.config.tool_output_config import ToolOutputConfig
from deerflow.config.tool_progress_config import ToolProgressConfig
@ -247,6 +248,7 @@ class AppConfig(BaseModel):
skill_evolution: SkillEvolutionConfig = Field(default_factory=SkillEvolutionConfig, description="Agent-managed skill evolution configuration")
extensions: ExtensionsConfig = Field(default_factory=ExtensionsConfig, description="Extensions configuration (MCP servers and skills state)")
tool_output: ToolOutputConfig = Field(default_factory=ToolOutputConfig, description="Tool output budget protection configuration")
tool_artifacts: ToolArtifactConfig = Field(default_factory=ToolArtifactConfig, description="Tool-artifact handle registry configuration (issue #4676)")
tool_search: ToolSearchConfig = Field(default_factory=ToolSearchConfig, description="Tool search / deferred loading configuration")
title: TitleConfig = Field(default_factory=TitleConfig, description="Automatic title generation configuration")
summarization: SummarizationConfig = Field(default_factory=SummarizationConfig, description="Conversation summarization configuration")

View File

@ -0,0 +1,38 @@
"""Configuration for the persistent artifact handle registry (issue #4676)."""
from __future__ import annotations
from pydantic import BaseModel, Field
class ToolArtifactConfig(BaseModel):
"""Config section for tool-artifact capture and handle resolution.
When enabled, artifact references from tool results are captured into
``ThreadState.tool_artifacts`` so they survive context compaction. The model
references artifacts by short handles (``art_xxxxxxxx``) that are resolved to
real references at tool-call time.
"""
enabled: bool = Field(
default=True,
description="Enable tool-artifact capture and handle resolution.",
)
max_entries: int = Field(
default=100,
ge=10,
le=1000,
description="Maximum artifact entries retained per thread.",
)
detect_refs_in_text: bool = Field(
default=True,
description="Conservatively scan free text tool results for sandbox paths and remote file URLs.",
)
inject_model_context: bool = Field(
default=True,
description="Project available artifact handles into the model request as durable context.",
)
resolve_handles_in_args: bool = Field(
default=True,
description="Resolve artifact handles found in tool arguments to their real references before execution.",
)

View File

@ -76,6 +76,7 @@ def core_ordering_constraints() -> tuple[OrderingConstraint, ...]:
reported an empty sequence while iteration yielded the real constraints.
Deferring the call instead of faking the value keeps one answer.
"""
from deerflow.agents.middlewares.artifact_resolution_middleware import ArtifactResolutionMiddleware
from deerflow.agents.middlewares.read_before_write_middleware import ReadBeforeWriteMiddleware
from deerflow.agents.middlewares.sandbox_audit_middleware import SandboxAuditMiddleware
from deerflow.agents.middlewares.skill_tool_policy_middleware import SkillToolPolicyMiddleware
@ -108,10 +109,19 @@ def core_ordering_constraints() -> tuple[OrderingConstraint, ...]:
reason=(f"{short_circuiter.__name__} can return or rebuild a ToolMessage without invoking its handler; ToolReceiptMiddleware must wrap it or those results never get a receipt and the ledger silently gaps"),
)
for short_circuiter in (
ArtifactResolutionMiddleware,
GuardrailMiddleware,
SandboxAuditMiddleware,
ReadBeforeWriteMiddleware,
ToolProgressMiddleware,
)
),
*(
OrderingConstraint(
outer=ArtifactResolutionMiddleware,
inner=policy,
reason="Artifact handles must resolve before argument-sensitive authorization, audit, write and progress policies inspect the call",
)
for policy in (GuardrailMiddleware, SandboxAuditMiddleware, ReadBeforeWriteMiddleware, ToolProgressMiddleware)
),
)

View File

@ -0,0 +1,390 @@
"""Persistent artifact handle registry for tool outputs (issue #4676).
MCP tools (and other tools) return file paths, URLs, task ids, and other
references in ``ToolMessage.content``. When context compaction summarizes the
conversation, those structured references are reduced to natural-language prose
and the LLM can no longer deterministically resolve them.
This module provides a short, deterministic ``handle`` for each artifact so the
model can reference it across turns, plus extraction helpers that turn a
``ToolMessage`` into ``ArtifactEntry`` records for ``ThreadState.tool_artifacts``.
"""
from __future__ import annotations
import hashlib
import json
import re
from datetime import UTC, datetime
from typing import Any
from urllib.parse import urlsplit
from langchain_core.messages import ToolMessage
from deerflow.agents.thread_state import ArtifactEntry
_HANDLE_PREFIX = "art_"
_HANDLE_LENGTH = 8
# Virtual sandbox path prefix (used by local stdio MCP servers whose files live
# inside the mounted user-data tree).
_SANDBOX_PATH_PATTERN = re.compile(r"/mnt/user-data/\S+")
# Conservative URL-with-file-extension match for remote references.
_REMOTE_FILE_URL_PATTERN = re.compile(r"https?://[^\s\"'`<>]+\.(?:png|jpg|jpeg|gif|html|pdf|csv|json|txt|log|md|xlsx?|docx?|zip)(?:[?#][^\s\"'`<>]*)?")
# Structured-content keys whose string values are treated as concrete
# references (paths, URLs, remote task ids) rather than opaque payload.
# Deliberately excludes generic result keys such as `output`/`outputs`: their
# values are usually prose, and path-shaped refs inside them are already caught
# by the free-text scan over tool content.
_STRUCTURED_REF_KEYS = frozenset({"file", "files", "file_path", "path", "url", "urls"})
_STRUCTURED_TASK_KEYS = frozenset({"task_id", "job_id"})
# Characters stripped from detected refs: prose punctuation plus the closing
# quotes/brackets/backticks that markdown- and JSON-formatted tool output
# commonly wraps paths in. `\S+` would otherwise consume them into `real_ref`.
_REF_TRAILING_NOISE_CHARS = ".,;:)]}\"'`"
# Content-block and structured-key refs are trusted only in these shapes.
# `data:`/`blob:` URIs can carry arbitrarily large embedded payloads (MCP
# embedded resources) that must never enter thread state, tool args, or crowd
# out the render budget; other schemes are equally unresolvable downstream.
_ARTIFACT_RENDER_CHAR_BUDGET = 3000
_STRUCTURED_DATA_MAX_BYTES = 4096
_STRUCTURED_MAX_NODES = 1024
_STRUCTURED_MAX_DEPTH = 32
_WINDOWS_ABSOLUTE_PATH_RE = re.compile(r"^[A-Za-z]:[\\/]")
def _is_referenceable_url(url: str) -> bool:
"""Accept http(s) URLs and absolute paths; reject any other URI scheme
(including protocol-relative `//host` forms)."""
if not url or len(url) > _STRUCTURED_DATA_MAX_BYTES or url.startswith("//"):
return False
try:
if len(url.encode("utf-8")) > _STRUCTURED_DATA_MAX_BYTES:
return False
except UnicodeError:
return False
if url.startswith(("http://", "https://")):
try:
return bool(urlsplit(url).netloc) and not any(char.isspace() for char in url)
except ValueError:
return False
return url.startswith("/") or bool(_WINDOWS_ABSOLUTE_PATH_RE.match(url))
def _is_referenceable_task_id(value: str) -> bool:
return bool(value) and len(value) <= 256 and not value.startswith(("data:", "blob:", "//")) and not any(char.isspace() for char in value)
def generate_handle(thread_id: str, tool_call_id: str, call_index: int, ref_ordinal: int = 0, *, occurrence_id: str | None = None) -> str:
"""Return a deterministic short handle for an artifact.
Message IDs distinguish provider IDs reused across assistant turns and stay
stable through checkpoint reload and compaction. ID-less standalone callers
must supply an occurrence-specific ``call_index``. The ordinal distinguishes
references within one result. Short hashes are identifiers, not credentials.
"""
seed = f"{thread_id}:{tool_call_id}:{call_index}:{ref_ordinal}"
if occurrence_id is not None:
seed += f":{occurrence_id}"
digest = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:_HANDLE_LENGTH]
return f"{_HANDLE_PREFIX}{digest}"
def _utc_now_iso() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def _make_entry(
*,
handle: str,
tool_name: str,
tool_call_id: str,
call_index: int,
artifact_type: str,
display_name: str,
real_ref: str,
created_at: str,
mime_type: str | None = None,
) -> ArtifactEntry:
entry: ArtifactEntry = {
"handle": handle,
"tool_name": tool_name,
"tool_call_id": tool_call_id,
"call_index": call_index,
"artifact_type": artifact_type,
"display_name": display_name,
"real_ref": real_ref,
"created_at": created_at,
}
if mime_type:
entry["mime_type"] = mime_type
return entry
def _detect_refs_in_text(text: str) -> list[dict[str, str]]:
"""Conservatively detect file paths and remote file URLs in free text."""
refs: list[dict[str, str]] = []
seen: set[str] = set()
for match in _SANDBOX_PATH_PATTERN.finditer(text):
raw = match.group(0).rstrip(_REF_TRAILING_NOISE_CHARS)
if raw in seen or not _is_referenceable_url(raw):
continue
seen.add(raw)
refs.append(
{
"type": "file",
"ref": raw,
"display": raw.split("/")[-1],
}
)
for match in _REMOTE_FILE_URL_PATTERN.finditer(text):
raw = match.group(0).rstrip(_REF_TRAILING_NOISE_CHARS)
if raw in seen or not _is_referenceable_url(raw):
continue
seen.add(raw)
refs.append(
{
"type": "file",
"ref": raw,
"display": raw.split("/")[-1],
}
)
return refs
def _collect_structured_refs(value: Any, found: list[tuple[str, str]]) -> bool:
"""Collect reference keys with a bounded traversal; reject oversized shapes."""
pending = [(None, value, 0)]
visited = 0
while pending:
key, item, depth = pending.pop()
visited += 1
if visited > _STRUCTURED_MAX_NODES or depth > _STRUCTURED_MAX_DEPTH:
return False
if isinstance(item, (dict, list)) and len(item) + visited + len(pending) > _STRUCTURED_MAX_NODES:
return False
if isinstance(item, dict):
pending.extend((child_key, child, depth + 1) for child_key, child in reversed(item.items()))
elif isinstance(item, list):
pending.extend((key, child, depth + 1) for child in reversed(item))
elif isinstance(item, str) and (key in _STRUCTURED_REF_KEYS or key in _STRUCTURED_TASK_KEYS):
found.append((key, item))
return True
def _serialize_bounded_data(value: Any) -> str | None:
"""Keep complete JSON only when its shape and UTF-8 size fit the budget.
Preflight strings and collection sizes before encoding, so an enormous
unknown MCP payload cannot trigger unbounded json.dumps on the agent loop.
"""
if not value:
return None
pending = [(value, 0)]
visited = 0
estimated_bytes = 0
while pending:
item, depth = pending.pop()
visited += 1
if visited > _STRUCTURED_MAX_NODES or depth > _STRUCTURED_MAX_DEPTH:
return None
if isinstance(item, str):
if len(item) > _STRUCTURED_DATA_MAX_BYTES:
return None
try:
estimated_bytes += len(item.encode("utf-8")) + 2
except UnicodeError:
return None
elif isinstance(item, (dict, list)):
if len(item) + visited + len(pending) > _STRUCTURED_MAX_NODES:
return None
estimated_bytes += 2 + len(item)
if isinstance(item, dict):
if any(not isinstance(key, str) for key in item):
return None
pending.extend((child, depth + 1) for child in item.values())
pending.extend((key, depth + 1) for key in item)
else:
pending.extend((child, depth + 1) for child in item)
elif isinstance(item, int):
if item.bit_length() > _STRUCTURED_DATA_MAX_BYTES:
return None
elif item is not None and not isinstance(item, float):
return None
if estimated_bytes > _STRUCTURED_DATA_MAX_BYTES:
return None
try:
encoded = json.dumps(value, ensure_ascii=False)
return encoded if len(encoded.encode("utf-8")) <= _STRUCTURED_DATA_MAX_BYTES else None
except (ValueError, TypeError, UnicodeError):
return None
def _display_name_for_ref(ref: str) -> str:
return ref.replace("\\", "/").split("/")[-1] or ref
class _EntrySink:
"""Allocates sequential per-result ordinals so every extracted reference
within one tool result gets a distinct handle."""
def __init__(self, *, thread_id: str, tool_call_id: str, call_index: int, created_at: str, tool_name: str, occurrence_id: str | None):
self._occurrence_id = occurrence_id
self._thread_id = thread_id
self._tool_call_id = tool_call_id
self._call_index = call_index
self._created_at = created_at
self._tool_name = tool_name
self._next_ordinal = 0
def add(self, *, artifact_type: str, display_name: str, real_ref: str, mime_type: str | None = None) -> ArtifactEntry:
entry = _make_entry(
handle=generate_handle(self._thread_id, self._tool_call_id, self._call_index, self._next_ordinal, occurrence_id=self._occurrence_id),
tool_name=self._tool_name,
tool_call_id=self._tool_call_id,
call_index=self._call_index,
artifact_type=artifact_type,
display_name=display_name,
real_ref=real_ref,
created_at=self._created_at,
mime_type=mime_type,
)
self._next_ordinal += 1
return entry
def extract_artifacts_from_result(
result: ToolMessage,
*,
thread_id: str,
call_index: int = 0,
detect_refs_in_text: bool = True,
) -> list[ArtifactEntry]:
"""Extract artifact references from a ``ToolMessage``.
Extraction sources, all combined (not mutually exclusive):
1. ``ToolMessage.artifact["structured_content"]`` (MCP ``structuredContent``):
string values under known keys (``file``/``path``/``url``/``task_id``/...)
become concrete ``file``/``task`` entries; when no known key matches, the
whole payload becomes a complete JSON ``data`` entry only within the
4096-byte, 1024-node and 32-level limits. Empty/oversized payloads are skipped.
2. ``content`` blocks of type ``file`` / ``image`` with a URL source become
``file`` / ``image`` entries.
3. ``content`` text blocks and plain-string results are scanned
conservatively for sandbox paths and remote file URLs (gated by
``detect_refs_in_text``).
Error results produce no entries. Every reference from one result gets a
distinct handle via a sequential ordinal.
"""
if result.status == "error":
return []
now = _utc_now_iso()
tool_name = result.name or "unknown"
tool_call_id = result.tool_call_id or ""
sink = _EntrySink(thread_id=thread_id, tool_call_id=tool_call_id, call_index=call_index, created_at=now, tool_name=tool_name, occurrence_id=result.id)
entries: list[ArtifactEntry] = []
artifact = result.artifact
if artifact is not None and isinstance(artifact, dict):
structured = artifact.get("structured_content")
if structured:
found: list[tuple[str, str]] = []
if _collect_structured_refs(structured, found):
for key, value in found:
is_task = key in _STRUCTURED_TASK_KEYS
if not (_is_referenceable_task_id(value) if is_task else _is_referenceable_url(value)):
continue
entries.append(sink.add(artifact_type="task" if is_task else "file", display_name=_display_name_for_ref(value), real_ref=value))
if not entries and not any(value.startswith(("data:", "blob:")) for _, value in found):
encoded = _serialize_bounded_data(structured)
if encoded is not None:
entries.append(sink.add(artifact_type="data", display_name=f"{tool_name} structured result", real_ref=encoded))
content = result.content
if isinstance(content, str):
if detect_refs_in_text and content:
for ref in _detect_refs_in_text(content):
entries.append(sink.add(artifact_type=ref["type"], display_name=ref["display"], real_ref=ref["ref"]))
return entries
if not isinstance(content, list):
return entries
for block in content:
if not isinstance(block, dict):
continue
block_type = block.get("type", "")
if block_type in {"file", "image"}:
source = block.get("source") or {}
if not isinstance(source, dict):
continue
url = source.get("url")
if isinstance(url, str) and url and _is_referenceable_url(url):
entries.append(
sink.add(
artifact_type="file" if block_type == "file" else "image",
display_name=_display_name_for_ref(url),
real_ref=url,
mime_type=source.get("mime_type") if isinstance(source.get("mime_type"), str) else None,
)
)
continue
if block_type == "text" and detect_refs_in_text:
text = block.get("text")
if not isinstance(text, str):
continue
for ref in _detect_refs_in_text(text):
entries.append(sink.add(artifact_type=ref["type"], display_name=ref["display"], real_ref=ref["ref"]))
return entries
def render_artifact_registry(entries: list[ArtifactEntry], *, max_chars: int = _ARTIFACT_RENDER_CHAR_BUDGET) -> str:
"""Render the artifact registry as model-visible context.
Shows each handle with its type, display name, and availability. Consumed
handles are marked so the model prefers unused artifacts. The output is
escaped so untrusted tool-provided values cannot forge framework context.
"""
if not entries:
return ""
from html import escape
lines = [
"## Available artifact handles",
"These are persistent handles for tool-produced artifacts. Reference them by handle in tool arguments; they resolve automatically.",
]
lines.append("Handles are local to this agent; task subagents do not share this registry. Return concrete references in delegated reports, not local handles.")
omitted_marker = f"... {len(entries)} more artifact handles not shown"
if len("\n".join([*lines, omitted_marker])) > max_chars:
return omitted_marker[: max(0, max_chars)]
shown = 0
for entry in reversed(entries):
handle = escape(entry.get("handle", ""))
artifact_type = escape(entry.get("artifact_type", ""))
display_name = escape(entry.get("display_name", ""))
consumed = bool(entry.get("consumed_by"))
status = "consumed" if consumed else "available"
mime = entry.get("mime_type")
mime_suffix = f" ({escape(str(mime))})" if mime else ""
tool_name = escape(entry.get("tool_name", ""))
line = f"- `{handle}` -> {artifact_type}: {display_name}{mime_suffix} [{status}] (from {tool_name})"
remaining = len(entries) - shown - 1
marker = f"... {remaining} more artifact handles not shown"
candidate = [*lines, line, *([marker] if remaining else [])]
if len("\n".join(candidate)) > max_chars:
break
lines.append(line)
shown += 1
if shown < len(entries):
lines.append(f"... {len(entries) - shown} more artifact handles not shown")
return "\n".join(lines)

View File

@ -0,0 +1,85 @@
"""Exercise artifact hooks under conftest's detect_blocking_io_strict gate."""
import asyncio
import re
from types import SimpleNamespace
import pytest
from _agent_e2e_helpers import FakeToolCallingModel
from langchain.agents import create_agent
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.tools import tool
from langgraph.prebuilt.tool_node import ToolCallRequest
from deerflow.agents.middlewares.artifact_capture_middleware import ArtifactCaptureMiddleware
from deerflow.agents.middlewares.artifact_resolution_middleware import ArtifactResolutionMiddleware
from deerflow.agents.middlewares.durable_context_middleware import DurableContextMiddleware
from deerflow.agents.thread_state import ThreadState
pytestmark = pytest.mark.asyncio
class HandleFollowingModel(FakeToolCallingModel):
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
if self.i == 1:
block = next(m.content for m in messages if m.additional_kwargs.get("durable_context_data"))
self.responses[1].tool_calls[0]["args"]["path"] = re.search(r"art_[0-9a-f]{8}", block).group()
return super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
async def test_async_capture_project_and_resolve_cycle_does_not_block():
seen = []
@tool(response_format="content_and_artifact")
async def make_file() -> tuple[str, dict]:
"""Create a structured file reference."""
return "done", {"structured_content": {"file": "/mnt/user-data/outputs/report.md"}}
@tool
async def read_file(path: str) -> str:
"""Read a file."""
seen.append(path)
return "read"
model = HandleFollowingModel(
responses=[
AIMessage(content="", tool_calls=[{"name": "make_file", "args": {}, "id": "make", "type": "tool_call"}]),
AIMessage(content="", tool_calls=[{"name": "read_file", "args": {"path": "pending"}, "id": "read", "type": "tool_call"}]),
AIMessage(content="done"),
]
)
graph = await asyncio.to_thread(lambda: create_agent(model=model, tools=[make_file, read_file], middleware=[ArtifactResolutionMiddleware(), ArtifactCaptureMiddleware(), DurableContextMiddleware()], state_schema=ThreadState))
state = await graph.ainvoke({"messages": [HumanMessage(content="make then read")]}, context={"thread_id": "strict-artifact-thread"})
assert seen == ["/mnt/user-data/outputs/report.md"]
assert state["tool_artifacts"][0]["consumed_by"] == ["read"]
async def test_async_unknown_handle_returns_error_without_io_or_execution():
req = ToolCallRequest(tool_call={"name": "read_file", "args": {"path": "art_00000000"}, "id": "read", "type": "tool_call"}, tool=None, state={"tool_artifacts": []}, runtime=SimpleNamespace(context={"thread_id": "t"}))
async def never(_request):
raise AssertionError("Unknown handles must not execute tools")
reply = await ArtifactResolutionMiddleware().awrap_tool_call(req, never)
assert reply.status == "error" and "art_00000000" in reply.content
async def test_async_oversized_structured_payload_is_rejected_before_serialization(monkeypatch):
import deerflow.tools.artifact_registry as registry
def never_encode(*args, **kwargs):
raise AssertionError("Do not serialize oversized MCP payloads on the loop")
message = ToolMessage(id="huge", content="done", tool_call_id="make", artifact={"structured_content": {"opaque": "x" * 1000000}})
# Ledger identity uses json.dumps too; only intercept the actual payload.
encode = registry.json.dumps
def guarded_encode(value, *args, **kwargs):
if isinstance(value, dict) and "opaque" in value:
return never_encode()
return encode(value, *args, **kwargs)
monkeypatch.setattr(registry.json, "dumps", guarded_encode)
update = await ArtifactCaptureMiddleware().abefore_model({"messages": [message]}, SimpleNamespace(context={"thread_id": "t"}))
assert "tool_artifacts" not in update
assert update["tool_artifact_processed"]

View File

@ -17,6 +17,8 @@
"delegations",
"messages",
"skill_context",
"tool_artifact_processed",
"tool_artifacts",
"viewed_images"
]
},
@ -29,6 +31,8 @@
"messages",
"skill_context",
"thread_data",
"tool_artifact_processed",
"tool_artifacts",
"viewed_images"
]
},
@ -41,6 +45,8 @@
"messages",
"skill_context",
"thread_data",
"tool_artifact_processed",
"tool_artifacts",
"uploaded_files",
"viewed_images"
]
@ -54,6 +60,8 @@
"messages",
"skill_context",
"thread_data",
"tool_artifact_processed",
"tool_artifacts",
"uploaded_files",
"viewed_images"
]
@ -67,6 +75,8 @@
"messages",
"skill_context",
"thread_data",
"tool_artifact_processed",
"tool_artifacts",
"uploaded_files",
"viewed_images"
]
@ -81,6 +91,8 @@
"skill_context",
"thread_data",
"title",
"tool_artifact_processed",
"tool_artifacts",
"uploaded_files",
"viewed_images"
]
@ -95,6 +107,8 @@
"skill_context",
"thread_data",
"title",
"tool_artifact_processed",
"tool_artifacts",
"uploaded_files",
"viewed_images"
]
@ -110,6 +124,8 @@
"skill_context",
"thread_data",
"title",
"tool_artifact_processed",
"tool_artifacts",
"uploaded_files",
"viewed_images"
]
@ -125,6 +141,8 @@
"skill_context",
"thread_data",
"title",
"tool_artifact_processed",
"tool_artifacts",
"uploaded_files",
"viewed_images"
]
@ -140,6 +158,8 @@
"skill_context",
"thread_data",
"title",
"tool_artifact_processed",
"tool_artifacts",
"uploaded_files",
"viewed_images"
]
@ -155,6 +175,8 @@
"skill_context",
"thread_data",
"title",
"tool_artifact_processed",
"tool_artifacts",
"uploaded_files",
"viewed_images"
]
@ -170,6 +192,8 @@
"skill_context",
"thread_data",
"title",
"tool_artifact_processed",
"tool_artifacts",
"uploaded_files",
"viewed_images"
]
@ -185,6 +209,42 @@
"skill_context",
"thread_data",
"title",
"tool_artifact_processed",
"tool_artifacts",
"uploaded_files",
"viewed_images"
]
},
{
"event": "values",
"keys": [
"artifacts",
"background_tasks",
"delegations",
"messages",
"sandbox",
"skill_context",
"thread_data",
"title",
"tool_artifact_processed",
"tool_artifacts",
"uploaded_files",
"viewed_images"
]
},
{
"event": "values",
"keys": [
"artifacts",
"background_tasks",
"delegations",
"messages",
"sandbox",
"skill_context",
"thread_data",
"title",
"tool_artifact_processed",
"tool_artifacts",
"uploaded_files",
"viewed_images"
]

View File

@ -0,0 +1,392 @@
from types import SimpleNamespace
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from deerflow.agents.middlewares.artifact_capture_middleware import ArtifactCaptureMiddleware
from deerflow.agents.thread_state import merge_artifacts, merge_tool_artifacts
from deerflow.config.tool_artifact_config import ToolArtifactConfig
from deerflow.tools.artifact_registry import generate_handle
def merge_and_apply(existing: list | None, update: list | None) -> list:
"""Apply an emitted update the way the LangGraph reducer would."""
return merge_tool_artifacts(existing or [], update or [])
def apply_update(state, update):
if update:
state["tool_artifacts"] = merge_tool_artifacts(state.get("tool_artifacts"), update.get("tool_artifacts"))
state["tool_artifact_processed"] = merge_artifacts(state.get("tool_artifact_processed"), update.get("tool_artifact_processed"))
def _runtime(thread_id: str = "thread_1"):
return SimpleNamespace(context={"thread_id": thread_id})
def _tool_message(content: str, tool_call_id: str = "call_1", *, artifact: dict | None = None) -> ToolMessage:
return ToolMessage(content=content, tool_call_id=tool_call_id, artifact=artifact)
class TestCapture:
def test_captures_structured_artifact(self):
middleware = ArtifactCaptureMiddleware()
msg = _tool_message(
"wrote file",
artifact={"structured_content": {"file": "/mnt/user-data/outputs/report.md", "mime_type": "text/markdown"}},
)
out = middleware.before_model({"messages": [msg]}, _runtime())
assert out is not None
(entry,) = out["tool_artifacts"]
assert entry["artifact_type"] == "file"
assert entry["real_ref"] == "/mnt/user-data/outputs/report.md"
assert entry["handle"].startswith("art_")
def test_handle_deterministic_per_tool_call(self):
"""Handles depend only on (thread_id, tool_call_id, ordinal), not extraction timing."""
msg = _tool_message(
"wrote file",
artifact={"structured_content": {"file": "/mnt/user-data/outputs/report.md"}},
)
first = ArtifactCaptureMiddleware().before_model({"messages": [msg]}, _runtime("thread_1"))
second = ArtifactCaptureMiddleware().before_model({"messages": [msg]}, _runtime("thread_1"))
assert first["tool_artifacts"][0]["handle"] == second["tool_artifacts"][0]["handle"]
def test_structured_fallback_stored_as_real_ref(self):
middleware = ArtifactCaptureMiddleware()
structured = {"custom_payload": {"deep": {"value": "x" * 600}}}
msg = _tool_message("done", tool_call_id="call_fb", artifact={"structured_content": structured})
out = middleware.before_model({"messages": [msg]}, _runtime())
assert out is not None
(entry,) = out["tool_artifacts"]
assert entry["artifact_type"] == "data"
import json as _json
assert entry["real_ref"] == _json.dumps(structured, ensure_ascii=False)
def test_does_not_capture_twice(self):
middleware = ArtifactCaptureMiddleware()
msg = _tool_message(
"wrote file",
artifact={"structured_content": {"file": "/mnt/user-data/outputs/report.md"}},
)
state = {"messages": [msg], "tool_artifacts": []}
first = middleware.before_model(state, _runtime())
apply_update(state, first)
second = middleware.before_model(state, _runtime())
assert second is None
def test_disabled_config_skips_capture(self):
middleware = ArtifactCaptureMiddleware(config=ToolArtifactConfig(enabled=False))
msg = _tool_message(
"wrote file",
artifact={"structured_content": {"file": "/mnt/user-data/outputs/report.md"}},
)
assert middleware.before_model({"messages": [msg]}, _runtime()) is None
def test_tracks_consumption_of_handle_in_tool_args(self):
middleware = ArtifactCaptureMiddleware()
handle = generate_handle("thread_1", "call_1", 0)
entry = {
"handle": handle,
"artifact_type": "file",
"real_ref": "/mnt/user-data/outputs/report.md",
"display_name": "report.md",
"tool_name": "write_file",
"mime_type": None,
"consumed_by": [],
}
state = {
"tool_artifacts": [entry],
"messages": [
AIMessage(
content="",
tool_calls=[{"name": "read_file", "args": {"path": f"{handle}"}, "id": "call_2", "type": "tool_call"}],
)
],
}
out = middleware.before_model(state, _runtime())
assert out is not None
assert out["tool_artifacts"][0]["consumed_by"] == ["call_2"]
def test_no_update_when_no_handle_referenced(self):
middleware = ArtifactCaptureMiddleware()
entry = {
"handle": generate_handle("thread_1", "call_1", 0),
"artifact_type": "file",
"real_ref": "/mnt/user-data/outputs/report.md",
"display_name": "report.md",
"tool_name": "write_file",
"mime_type": None,
"consumed_by": [],
}
state = {
"tool_artifacts": [entry],
"messages": [HumanMessage(content="hello")],
}
assert middleware.before_model(state, _runtime()) is None
def test_capture_slides_window_at_configured_cap(self):
"""At the configured cap, fresh captures evict the oldest entries instead of being dropped."""
middleware = ArtifactCaptureMiddleware(config=ToolArtifactConfig(max_entries=20))
existing = [
{
"handle": generate_handle("thread_1", f"call_old_{i}", 0),
"artifact_type": "file",
"real_ref": f"/mnt/user-data/outputs/old_{i}.md",
"display_name": f"old_{i}.md",
"tool_name": "write_file",
"consumed_by": [],
}
for i in range(20)
]
messages = [
ToolMessage(
content="wrote file",
tool_call_id=f"call_new_{i}",
artifact={"structured_content": {"file": f"/mnt/user-data/outputs/new_{i}.md"}},
)
for i in range(2)
]
out = middleware.before_model({"messages": messages, "tool_artifacts": existing}, _runtime())
assert out is not None
update = out["tool_artifacts"]
assert update[-1].get("op") == "trim_to" and update[-1]["keep"] == 20, "trim directive must ride along"
fresh_handles = {entry["handle"] for entry in update[:2]}
assert fresh_handles == {generate_handle("thread_1", f"call_new_{i}", 0) for i in range(2)}
from deerflow.agents.thread_state import merge_tool_artifacts
merged = merge_tool_artifacts(existing, update)
assert len(merged) == 20
merged_handles = {entry["handle"] for entry in merged}
assert all(handle in merged_handles for handle in fresh_handles), "fresh artifacts must be registered"
assert generate_handle("thread_1", "call_old_0", 0) not in merged_handles, "oldest must be evicted"
assert generate_handle("thread_1", "call_old_19", 0) in merged_handles, "recent entries must survive"
def test_disabled_flag_disables_whole_middleware_including_consumption(self):
middleware = ArtifactCaptureMiddleware(config=ToolArtifactConfig(enabled=False))
entry = {
"handle": generate_handle("thread_1", "call_1", 0),
"artifact_type": "file",
"real_ref": "/mnt/user-data/outputs/report.md",
"display_name": "report.md",
"tool_name": "write_file",
"consumed_by": [],
}
state = {
"tool_artifacts": [entry],
"messages": [
AIMessage(
content="",
tool_calls=[{"name": "read_file", "args": {"path": entry["handle"]}, "id": "call_2", "type": "tool_call"}],
)
],
}
assert middleware.before_model(state, _runtime()) is None
def test_consumption_scan_memoized_across_rounds(self, monkeypatch):
"""Historical AIMessage args must not be regex-rescanned once settled."""
middleware = ArtifactCaptureMiddleware()
entry = {
"handle": generate_handle("thread_1", "call_1", 0),
"artifact_type": "file",
"real_ref": "/mnt/user-data/outputs/report.md",
"display_name": "report.md",
"tool_name": "write_file",
"consumed_by": [],
}
ai_message = AIMessage(
content="",
tool_calls=[{"name": "read_file", "args": {"path": entry["handle"]}, "id": "call_2", "type": "tool_call"}],
)
scan_calls: list[str] = []
original = ArtifactCaptureMiddleware._find_handles
def counting_find_handles(self, value):
scan_calls.append("scan")
return original(self, value)
monkeypatch.setattr(ArtifactCaptureMiddleware, "_find_handles", counting_find_handles)
first = middleware.before_model({"messages": [ai_message], "tool_artifacts": [entry]}, _runtime())
assert first is not None and first["tool_artifacts"][0]["consumed_by"] == ["call_2"]
scans_after_first = len(scan_calls)
settled_state = {"messages": [ai_message]}
apply_update(settled_state, first)
second = middleware.before_model(settled_state, _runtime())
assert second is None or "tool_artifacts" not in (second or {})
assert len(scan_calls) == scans_after_first, "settled tool calls must be skipped without rescanning"
def test_capture_skips_already_seen_and_empty_results(self, monkeypatch):
"""Steady-state cost must drop to the new message tail, not full history."""
from deerflow.agents.middlewares import artifact_capture_middleware
calls: list[str] = []
real_extract = artifact_capture_middleware.extract_artifacts_from_result
def spy(result, **kwargs):
calls.append(result.tool_call_id)
return real_extract(result, **kwargs)
monkeypatch.setattr(artifact_capture_middleware, "extract_artifacts_from_result", spy)
middleware = ArtifactCaptureMiddleware()
captured_msg = _tool_message(
"wrote file",
tool_call_id="call_cap",
artifact={"structured_content": {"file": "/mnt/user-data/outputs/report.md"}},
)
empty_msg = _tool_message("no refs here at all", tool_call_id="call_empty")
first = middleware.before_model({"messages": [captured_msg, empty_msg]}, _runtime())
assert first is not None
assert len(calls) == 2
calls.clear()
state = {"messages": [captured_msg, empty_msg]}
apply_update(state, first)
second = middleware.before_model(state, _runtime())
assert second is None or "tool_artifacts" not in (second or {})
assert calls == [], "already-captured and known-empty results must be skipped without extraction"
def test_evicted_results_are_not_resurrected(self, monkeypatch):
"""A sliding-window eviction must be final: no per-round re-registration churn."""
from deerflow.agents.middlewares import artifact_capture_middleware
calls: list[str] = []
real_extract = artifact_capture_middleware.extract_artifacts_from_result
def spy(result, **kwargs):
calls.append(result.tool_call_id)
return real_extract(result, **kwargs)
monkeypatch.setattr(artifact_capture_middleware, "extract_artifacts_from_result", spy)
middleware = ArtifactCaptureMiddleware(config=ToolArtifactConfig(max_entries=10))
old_msgs = [_tool_message("wrote file", tool_call_id=f"call_old_{i}", artifact={"structured_content": {"file": f"/x/old_{i}.md"}}) for i in range(10)]
state = {"messages": list(old_msgs)}
rt = _runtime("t")
# Saturate the registry.
out = middleware.before_model(state, rt)
apply_update(state, out)
# A fresh capture arrives; the oldest entry is evicted but its message remains in context.
fresh_msg = _tool_message("wrote file", tool_call_id="call_fresh", artifact={"structured_content": {"file": "/x/fresh.md"}})
state["messages"] = [*old_msgs, fresh_msg]
calls.clear()
out = middleware.before_model(state, rt)
apply_update(state, out)
# Steady state: rounds over the same context must be quiescent.
for _ in range(3):
calls.clear()
steady = middleware.before_model(state, rt)
assert steady is None or "tool_artifacts" not in (steady or {}), "evicted entries must not resurrect"
assert calls == [], f"no re-extraction expected in steady state, got {calls}"
handles_now = {entry["handle"] for entry in state["tool_artifacts"]}
assert len(handles_now) == 10
assert generate_handle("t", "call_fresh", 0) in handles_now
def test_consumption_resolves_handle_captured_in_same_round(self):
"""An AIMessage may reference a handle whose entry is captured in this very before_model."""
middleware = ArtifactCaptureMiddleware()
handle = generate_handle("t", "call_make", 0)
msgs = [
ToolMessage(content="made", tool_call_id="call_make", artifact={"structured_content": {"file": "/x/report.md"}}),
AIMessage(content="", tool_calls=[{"name": "read_file", "args": {"path": handle}, "id": "call_read", "type": "tool_call"}]),
]
state = {"messages": msgs}
out = middleware.before_model(state, _runtime("t"))
assert out is not None
by_handle = {entry["handle"]: entry for entry in out["tool_artifacts"] if "handle" in entry}
assert handle in by_handle, "capture must fire"
assert by_handle[handle]["consumed_by"] == ["call_read"], "same-round consumption must resolve"
def test_quiet_memo_retries_when_handle_unresolved(self):
"""A scan that misses the registry must not permanently settle the tool call."""
middleware = ArtifactCaptureMiddleware()
handle = generate_handle("t", "call_late", 0)
ai_message = AIMessage(
content="",
tool_calls=[{"name": "read_file", "args": {"path": handle}, "id": "call_read", "type": "tool_call"}],
)
def make_entry(h: str, name: str) -> dict:
return {"handle": h, "artifact_type": "file", "real_ref": f"/x/{name}", "display_name": name, "tool_name": "write_file", "consumed_by": []}
unrelated = make_entry(generate_handle("t", "call_other", 0), "other.md")
late = make_entry(handle, "late.md")
# Round 1: scan runs (registry non-empty), handle unresolved -> no update,
# and crucially the call must NOT be settled as quiet.
first = middleware.before_model({"messages": [ai_message], "tool_artifacts": [unrelated]}, _runtime("t"))
assert first is None or "tool_artifacts" not in (first or {})
# Round 2: the entry exists now -> consumption must be recorded.
second = middleware.before_model({"messages": [ai_message], "tool_artifacts": [unrelated, late]}, _runtime("t"))
assert second is not None
assert second["tool_artifacts"][0]["consumed_by"] == ["call_read"], "unresolved scan must retry, not settle"
# Round 3: settled -> quiescent.
third_state = {"messages": [ai_message], "tool_artifacts": [unrelated, late]}
apply_update(third_state, second)
third = middleware.before_model(third_state, _runtime("t"))
assert third is None or "tool_artifacts" not in (third or {})
def test_capture_and_consumption_updates_concatenate(self):
"""Both a fresh capture and a consumption update in one before_model call must survive.
Regression: dict-merging the two updates clobbered the shared
``tool_artifacts`` key, permanently losing the new capture whenever the
next model response was terminal.
"""
middleware = ArtifactCaptureMiddleware()
existing_handle = generate_handle("thread_1", "call_old", 0)
existing = {
"handle": existing_handle,
"artifact_type": "file",
"real_ref": "/mnt/user-data/outputs/old.md",
"display_name": "old.md",
"tool_name": "write_file",
"mime_type": None,
"consumed_by": [],
}
state = {
"tool_artifacts": [existing],
"messages": [
AIMessage(
content="",
tool_calls=[{"name": "read_file", "args": {"path": existing_handle}, "id": "call_new", "type": "tool_call"}],
),
ToolMessage(
content="wrote file",
tool_call_id="call_fresh",
artifact={"structured_content": {"file": "/mnt/user-data/outputs/fresh.md"}},
),
],
}
out = middleware.before_model(state, _runtime())
assert out is not None
handles = {entry["handle"] for entry in out["tool_artifacts"]}
assert generate_handle("thread_1", "call_fresh", 0) in handles, "fresh capture was dropped"
consumed = next(entry for entry in out["tool_artifacts"] if entry["handle"] == existing_handle)
assert consumed["consumed_by"] == ["call_new"], "consumption update was dropped"

View File

@ -0,0 +1,124 @@
"""Integration coverage for the artifact handle registry cycle (issue #4676).
Covers spec §14.4: capture from a tool result -> handle injected into model
context -> model references the handle in a later tool call -> resolution
rewrites it to the real reference at the tool-call boundary.
"""
import re
from typing import Any
from _agent_e2e_helpers import FakeToolCallingModel
from langchain.agents import create_agent
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from deerflow.agents.middlewares.artifact_capture_middleware import ArtifactCaptureMiddleware
from deerflow.agents.middlewares.artifact_resolution_middleware import ArtifactResolutionMiddleware
from deerflow.agents.middlewares.durable_context_middleware import DurableContextMiddleware
from deerflow.agents.thread_state import ThreadState
from deerflow.tools.artifact_registry import generate_handle
THREAD_ID = "artifact-cycle-thread"
REAL_REF = "/mnt/user-data/outputs/report.md"
FRESH_HANDLE = generate_handle(THREAD_ID, "call_make", 0)
seen_read_args: list[dict] = []
class RecordingToolCallingModel(FakeToolCallingModel):
"""FakeToolCallingModel that records the messages sent to each model call."""
def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
object.__setattr__(self, "received", [])
def _generate(self, messages, stop=None, run_manager=None, **kwargs): # noqa: ANN001, ANN003
self.received.append(list(messages))
if self.i == 1:
data = next(m.content for m in messages if m.additional_kwargs.get("durable_context_data"))
handle = re.search(r"art_[0-9a-f]{8}", data).group()
self.responses[1].tool_calls[0]["args"]["path"] = handle
return super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
@tool("make_file", parse_docstring=True)
def make_file(name: str) -> str:
"""Create a report file.
Args:
name: file name to create.
"""
return f"Saved {REAL_REF} successfully"
@tool("read_file", parse_docstring=True)
def spy_read_file(path: str) -> str:
"""Read a file.
Args:
path: path of the file to read.
"""
seen_read_args.append({"path": path})
return f"contents of {path}"
def _cycle_model() -> RecordingToolCallingModel:
return RecordingToolCallingModel(
responses=[
AIMessage(
content="",
tool_calls=[{"name": "make_file", "args": {"name": "report.md"}, "id": "call_make", "type": "tool_call"}],
),
AIMessage(
content="",
tool_calls=[{"name": "read_file", "args": {"path": FRESH_HANDLE}, "id": "call_read", "type": "tool_call"}],
),
AIMessage(content="done"),
]
)
def _build_agent(model: FakeToolCallingModel):
return create_agent(
model=model,
tools=[make_file, spy_read_file],
middleware=[
DurableContextMiddleware(),
ArtifactCaptureMiddleware(),
ArtifactResolutionMiddleware(),
],
state_schema=ThreadState,
checkpointer=InMemorySaver(),
)
def test_full_capture_inject_resolve_cycle():
seen_read_args.clear()
agent = _build_agent(_cycle_model())
config = {"configurable": {"thread_id": THREAD_ID}}
result = agent.invoke({"messages": [HumanMessage(content="make then read")]}, config, context={"thread_id": THREAD_ID})
assert seen_read_args == [{"path": REAL_REF}], f"handle was not resolved at the tool boundary: {seen_read_args}"
entries = {entry["handle"]: entry for entry in result["tool_artifacts"]}
made = next(e for e in entries.values() if e["tool_call_id"] == "call_make")
assert made["real_ref"] == REAL_REF
assert made["consumed_by"] == ["call_read"]
def test_handle_projected_into_model_context():
seen_read_args.clear()
model = _cycle_model()
agent = _build_agent(model)
config = {"configurable": {"thread_id": THREAD_ID}}
result = agent.invoke({"messages": [HumanMessage(content="make then read")]}, config, context={"thread_id": THREAD_ID})
# Round 3 is the request after both capture and consumption happened.
round3 = model.received[-1]
durable_blocks = [message.content for message in round3 if getattr(message, "additional_kwargs", {}).get("durable_context_data")]
handle = next(e["handle"] for e in result["tool_artifacts"] if e["tool_call_id"] == "call_make")
assert any(handle in content for content in durable_blocks), "captured handle never reached the model-facing durable context block"

View File

@ -0,0 +1,434 @@
"""Tests for the persistent artifact handle registry (issue #4676)."""
import json
from langchain_core.messages import ToolMessage
from deerflow.agents.thread_state import merge_tool_artifacts
from deerflow.tools.artifact_registry import (
_detect_refs_in_text,
extract_artifacts_from_result,
generate_handle,
)
def _entry(handle: str, i: int) -> dict:
return {
"handle": handle,
"tool_name": "t",
"tool_call_id": f"call-{i}",
"call_index": 0,
"artifact_type": "file",
"display_name": f"{i}.txt",
"real_ref": f"/tmp/{i}.txt",
"created_at": "2026-08-19T00:00:00Z",
}
def test_generate_handle_deterministic():
assert generate_handle("thread-1", "call-1", 0) == generate_handle("thread-1", "call-1", 0)
def test_generate_handle_unique():
assert generate_handle("thread-1", "call-1", 0) != generate_handle("thread-1", "call-1", 1)
assert generate_handle("thread-1", "call-1", 0) != generate_handle("thread-1", "call-2", 0)
assert generate_handle("thread-1", "call-1", 0) != generate_handle("thread-2", "call-1", 0)
def test_generate_handle_format():
handle = generate_handle("thread-1", "call-1", 0)
assert handle.startswith("art_")
assert len(handle) == len("art_") + 8
def test_extract_from_file_block():
result = ToolMessage(
content=[
{
"type": "file",
"source": {"type": "url", "url": "/mnt/user-data/outputs/report.html", "mime_type": "text/html"},
}
],
tool_call_id="call-1",
name="mcp_server_analyze",
)
entries = extract_artifacts_from_result(result, thread_id="thread-1")
assert len(entries) == 1
entry = entries[0]
assert entry["artifact_type"] == "file"
assert entry["real_ref"] == "/mnt/user-data/outputs/report.html"
assert entry["display_name"] == "report.html"
assert entry["tool_name"] == "mcp_server_analyze"
assert entry["tool_call_id"] == "call-1"
assert entry["mime_type"] == "text/html"
def test_extract_from_image_block():
result = ToolMessage(
content=[
{
"type": "image",
"source": {"type": "url", "url": "/mnt/user-data/outputs/chart.png", "mime_type": "image/png"},
}
],
tool_call_id="call-2",
name="mcp_chart_gen",
)
entries = extract_artifacts_from_result(result, thread_id="thread-1")
assert len(entries) == 1
assert entries[0]["artifact_type"] == "image"
assert entries[0]["real_ref"] == "/mnt/user-data/outputs/chart.png"
def test_file_block_rejects_data_and_blob_urls():
"""Embedded-resource URIs must never enter the registry or tool args."""
result = ToolMessage(
content=[
{"type": "file", "source": {"type": "url", "url": "data:text/plain;base64,QUFB" + "A" * 500}},
{"type": "image", "source": {"type": "url", "url": "blob:https://example.com/uuid"}},
{"type": "file", "source": {"type": "url", "url": "//cdn.example.com/pixel.gif"}},
{"type": "file", "source": {"type": "url", "url": "/mnt/user-data/outputs/real.txt"}},
],
tool_call_id="call-2b",
name="mcp_embedded",
)
entries = extract_artifacts_from_result(result, thread_id="thread-1")
refs = {entry["real_ref"] for entry in entries}
assert refs == {"/mnt/user-data/outputs/real.txt"}
def test_structured_url_key_rejects_non_http_uris():
"""Structured refs get the same URI-shape gate as content blocks."""
result = ToolMessage(
content=[{"type": "text", "text": "done"}],
tool_call_id="call-4g",
name="mcp_embedded",
artifact={
"structured_content": {
"url": "data:text/html;base64,PGh0bWw+" + "Q" * 300,
"file": "/mnt/user-data/outputs/keep.md",
"task_id": "job-11",
}
},
)
entries = extract_artifacts_from_result(result, thread_id="thread-1")
refs = {entry["real_ref"] for entry in entries}
assert refs == {"/mnt/user-data/outputs/keep.md", "job-11"}
types = {entry["real_ref"]: entry["artifact_type"] for entry in entries}
assert types["job-11"] == "task"
def test_extract_from_text_with_path():
result = ToolMessage(
content=[{"type": "text", "text": "Report saved to /mnt/user-data/outputs/report.html"}],
tool_call_id="call-3",
name="mcp_server_analyze",
)
entries = extract_artifacts_from_result(result, thread_id="thread-1")
assert len(entries) == 1
assert entries[0]["artifact_type"] == "file"
assert entries[0]["real_ref"] == "/mnt/user-data/outputs/report.html"
def test_extract_from_structured_content():
result = ToolMessage(
content=[{"type": "text", "text": "task submitted"}],
tool_call_id="call-4",
name="mcp_task_submit",
artifact={"structured_content": {"task_id": "remote-task-42", "status": "working"}},
)
entries = extract_artifacts_from_result(result, thread_id="thread-1")
assert len(entries) == 1
assert entries[0]["artifact_type"] == "task"
assert entries[0]["real_ref"] == "remote-task-42"
assert entries[0]["display_name"] == "remote-task-42"
def test_extract_from_structured_file_key_is_concrete_ref():
result = ToolMessage(
content=[{"type": "text", "text": "saved"}],
tool_call_id="call-4b",
name="mcp_writer",
artifact={"structured_content": {"file": "/mnt/user-data/outputs/report.md", "mime_type": "text/markdown"}},
)
entries = extract_artifacts_from_result(result, thread_id="thread-1")
assert len(entries) == 1
assert entries[0]["artifact_type"] == "file"
assert entries[0]["real_ref"] == "/mnt/user-data/outputs/report.md"
def test_extract_from_structured_list_valued_keys():
result = ToolMessage(
content=[{"type": "text", "text": "saved"}],
tool_call_id="call-4e",
name="mcp_batch_writer",
artifact={"structured_content": {"files": ["/mnt/user-data/outputs/a.txt", "/mnt/user-data/outputs/b.txt"], "task_ids": ["x"]}},
)
entries = extract_artifacts_from_result(result, thread_id="thread-1")
refs = {entry["real_ref"] for entry in entries}
assert refs == {"/mnt/user-data/outputs/a.txt", "/mnt/user-data/outputs/b.txt"}
handles = {entry["handle"] for entry in entries}
assert len(handles) == 2
def test_structured_generic_output_key_not_treated_as_ref():
"""Prose under generic result keys must not become a bogus file entry."""
result = ToolMessage(
content=[{"type": "text", "text": "done"}],
tool_call_id="call-4f",
name="mcp_analyst",
artifact={"structured_content": {"output": "Analysis complete; revenue up 12% quarter over quarter."}},
)
entries = extract_artifacts_from_result(result, thread_id="thread-1")
assert len(entries) == 1
assert entries[0]["artifact_type"] == "data"
assert "Analysis complete" in entries[0]["real_ref"]
def test_structured_fallback_whole_object_not_truncated():
structured = {"custom_payload": {"nested": ["x" * 200] * 10}}
result = ToolMessage(
content=[{"type": "text", "text": "done"}],
tool_call_id="call-4c",
name="mcp_exotic",
artifact={"structured_content": structured},
)
entries = extract_artifacts_from_result(result, thread_id="thread-1")
assert len(entries) == 1
assert entries[0]["artifact_type"] == "data"
assert entries[0]["real_ref"] == json.dumps(structured, ensure_ascii=False)
def test_structured_content_does_not_skip_file_blocks():
result = ToolMessage(
content=[
{
"type": "file",
"source": {"type": "url", "url": "/mnt/user-data/outputs/chart.png", "mime_type": "image/png"},
}
],
tool_call_id="call-4d",
name="mcp_mixed",
artifact={"structured_content": {"task_id": "job-7"}},
)
entries = extract_artifacts_from_result(result, thread_id="thread-1")
refs = {entry["real_ref"] for entry in entries}
assert "job-7" in refs
assert "/mnt/user-data/outputs/chart.png" in refs
def test_two_refs_in_one_text_block_get_distinct_handles():
result = ToolMessage(
content=[{"type": "text", "text": "Saved /mnt/user-data/outputs/a.txt and /mnt/user-data/outputs/b.csv"}],
tool_call_id="call-3b",
name="mcp_server_analyze",
)
entries = extract_artifacts_from_result(result, thread_id="thread-1")
assert len(entries) == 2
handles = {entry["handle"] for entry in entries}
assert len(handles) == 2
assert {entry["real_ref"] for entry in entries} == {"/mnt/user-data/outputs/a.txt", "/mnt/user-data/outputs/b.csv"}
def test_string_content_with_path_captured():
result = ToolMessage(
content="Wrote /mnt/user-data/outputs/notes.md successfully",
tool_call_id="call-6b",
name="write_file",
)
entries = extract_artifacts_from_result(result, thread_id="thread-1")
assert len(entries) == 1
assert entries[0]["real_ref"] == "/mnt/user-data/outputs/notes.md"
assert entries[0]["artifact_type"] == "file"
def test_detect_refs_in_text_flag_disables_text_scans():
result = ToolMessage(
content=[{"type": "text", "text": "Report saved to /mnt/user-data/outputs/report.html"}],
tool_call_id="call-3c",
name="mcp_server_analyze",
)
entries = extract_artifacts_from_result(result, thread_id="thread-1", detect_refs_in_text=False)
assert entries == []
def test_no_extraction_from_error_result():
result = ToolMessage(
content="Error: Tool failed",
tool_call_id="call-5",
name="mcp_server_analyze",
status="error",
)
entries = extract_artifacts_from_result(result, thread_id="thread-1")
assert entries == []
def test_no_extraction_from_plain_text_without_refs():
result = ToolMessage(content="All done", tool_call_id="call-6", name="mcp_server_analyze")
entries = extract_artifacts_from_result(result, thread_id="thread-1")
assert entries == []
def test_detect_refs_in_text_paths():
refs = _detect_refs_in_text("See /mnt/user-data/outputs/a.txt and /mnt/user-data/outputs/b.csv here.")
paths = [r["ref"] for r in refs]
assert "/mnt/user-data/outputs/a.txt" in paths
assert "/mnt/user-data/outputs/b.csv" in paths
def test_detect_refs_in_text_urls():
refs = _detect_refs_in_text("Download https://example.com/files/report.pdf now.")
assert len(refs) == 1
assert refs[0]["ref"] == "https://example.com/files/report.pdf"
def test_detect_refs_in_text_noise():
refs = _detect_refs_in_text("No references here, just text.")
assert refs == []
def test_detect_refs_in_text_strips_quotes_backticks_brackets():
text = 'Saved to `/mnt/user-data/outputs/report.md` and {"path": "/mnt/user-data/a.txt"} plus [/mnt/user-data/c.csv]'
values = [ref["ref"] for ref in _detect_refs_in_text(text)]
assert "/mnt/user-data/outputs/report.md" in values
assert "/mnt/user-data/a.txt" in values
assert "/mnt/user-data/c.csv" in values
def test_string_content_with_quoted_path_captured_cleanly():
result = ToolMessage(
content='Wrote "/mnt/user-data/outputs/notes.md" successfully',
tool_call_id="call-6c",
name="write_file",
)
entries = extract_artifacts_from_result(result, thread_id="thread-1")
assert len(entries) == 1
assert entries[0]["real_ref"] == "/mnt/user-data/outputs/notes.md"
def test_merge_tool_artifacts_append():
existing = [
{
"handle": "art_00000001",
"tool_name": "t1",
"tool_call_id": "call-1",
"call_index": 0,
"artifact_type": "file",
"display_name": "a.txt",
"real_ref": "/tmp/a.txt",
"created_at": "2026-08-19T00:00:00Z",
}
]
new = [
{
"handle": "art_00000002",
"tool_name": "t2",
"tool_call_id": "call-2",
"call_index": 0,
"artifact_type": "file",
"display_name": "b.txt",
"real_ref": "/tmp/b.txt",
"created_at": "2026-08-19T00:00:01Z",
}
]
merged = merge_tool_artifacts(existing, new)
assert len(merged) == 2
assert merged[0]["handle"] == "art_00000001"
assert merged[1]["handle"] == "art_00000002"
def test_merge_tool_artifacts_dedup_same_handle_latest_wins():
existing = [
{
"handle": "art_00000001",
"tool_name": "t1",
"tool_call_id": "call-1",
"call_index": 0,
"artifact_type": "file",
"display_name": "a.txt",
"real_ref": "/tmp/a.txt",
"created_at": "2026-08-19T00:00:00Z",
}
]
new = [
{
"handle": "art_00000001",
"tool_name": "t1",
"tool_call_id": "call-1",
"call_index": 0,
"artifact_type": "file",
"display_name": "a.txt",
"real_ref": "/tmp/a-new.txt",
"consumed_by": ["call-9"],
"created_at": "2026-08-19T00:00:01Z",
}
]
merged = merge_tool_artifacts(existing, new)
assert len(merged) == 1
assert merged[0]["real_ref"] == "/tmp/a-new.txt"
assert merged[0]["consumed_by"] == ["call-9"]
def test_merge_tool_artifacts_empty_new_preserves_existing():
existing = [
{
"handle": "art_00000001",
"tool_name": "t1",
"tool_call_id": "call-1",
"call_index": 0,
"artifact_type": "file",
"display_name": "a.txt",
"real_ref": "/tmp/a.txt",
"created_at": "2026-08-19T00:00:00Z",
}
]
merged = merge_tool_artifacts(existing, None)
assert merged == existing
merged2 = merge_tool_artifacts(existing, [])
assert merged2 == existing
def test_merge_tool_artifacts_trim_directive_slides_window():
"""A trailing trim directive makes the configured cap a sliding window."""
existing = [_entry(f"art_{i:08x}", i) for i in range(20)]
fresh = [_entry(f"art_new{i:02x}", 100 + i) for i in range(2)]
update = [*fresh, {"op": "trim_to", "keep": 20}]
merged = merge_tool_artifacts(existing, update)
assert len(merged) == 20
assert merged[0]["handle"] == "art_00000002", "oldest two must be evicted"
handles = {entry["handle"] for entry in merged}
assert {f"art_new{i:02x}" for i in range(2)} <= handles, "fresh entries must survive"
assert "art_00000000" not in handles and "art_00000001" not in handles
def test_merge_tool_artifacts_no_trim_without_directive():
existing = [_entry(f"art_{i:08x}", i) for i in range(5)]
fresh = [_entry(f"art_new{i:02x}", 50 + i) for i in range(2)]
merged = merge_tool_artifacts(existing, fresh)
assert len(merged) == 7
def test_merge_tool_artifacts_trim_clamped_to_ceiling():
entries = [_entry(f"art_{i:08x}", i) for i in range(1200)]
update = [*entries, {"op": "trim_to", "keep": 5000}]
merged = merge_tool_artifacts(None, update)
assert len(merged) == 1000
assert merged[0]["handle"] == f"art_{200:08x}"
def test_merge_tool_artifacts_cap():
"""Reducer enforces the absolute ceiling only; configured caps live in the middleware."""
entries = [_entry(f"art_{i:08x}", i) for i in range(1500)]
merged = merge_tool_artifacts(None, entries)
assert len(merged) == 1000
assert merged[0]["handle"] == f"art_{500:08x}"
assert merged[-1]["handle"] == f"art_{1499:08x}"
def test_merge_tool_artifacts_consumption_update_does_not_grow_count():
entries = [_entry(f"art_{i:08x}", i) for i in range(1000)]
consumed = {**entries[0], "consumed_by": ["call-x"]}
merged = merge_tool_artifacts(entries, [consumed])
assert len(merged) == 1000

View File

@ -0,0 +1,194 @@
from langchain_core.messages import ToolMessage
from langgraph.prebuilt.tool_node import ToolCallRequest
from deerflow.agents.middlewares.artifact_resolution_middleware import ArtifactResolutionMiddleware
from deerflow.config.tool_artifact_config import ToolArtifactConfig
from deerflow.tools.artifact_registry import generate_handle
class _FakeToolCallRequest:
def __init__(self, tool_call: dict, state: dict) -> None:
self.tool_call = tool_call
self.state = state
def override(self, **overrides):
return _FakeToolCallRequest(
overrides.get("tool_call", self.tool_call),
overrides.get("state", self.state),
)
def _request(tool_call: dict, artifacts: list[dict]):
return _FakeToolCallRequest(tool_call, {"tool_artifacts": artifacts})
def _artifact(thread_id: str, call_index: int, real_ref: str) -> dict:
return {
"handle": generate_handle(thread_id, f"call_{call_index}", call_index),
"artifact_type": "file",
"real_ref": real_ref,
"display_name": real_ref.rsplit("/", 1)[-1],
"tool_name": "write_file",
"mime_type": None,
"consumed_by": [],
}
class TestResolution:
def test_resolves_bare_handle_in_args(self):
middleware = ArtifactResolutionMiddleware()
handle = generate_handle("thread_1", "call_0", 0)
request = _request(
{"name": "read_file", "args": {"path": handle}, "id": "call_2", "type": "tool_call"},
[_artifact("thread_1", 0, "/mnt/user-data/outputs/report.md")],
)
seen = {}
def handler(req):
seen["args"] = req.tool_call["args"]
return ToolMessage(content="ok", tool_call_id="call_2")
result = middleware.wrap_tool_call(request, handler)
assert seen["args"] == {"path": "/mnt/user-data/outputs/report.md"}
assert result.content == "ok"
def test_resolves_backticked_handle_in_args(self):
middleware = ArtifactResolutionMiddleware()
handle = generate_handle("thread_1", "call_0", 0)
request = _request(
{"name": "bash", "args": {"command": f"cat `{handle}`"}, "id": "call_2", "type": "tool_call"},
[_artifact("thread_1", 0, "/mnt/user-data/outputs/report.md")],
)
seen = {}
def handler(req):
seen["args"] = req.tool_call["args"]
return ToolMessage(content="ok", tool_call_id="call_2")
middleware.wrap_tool_call(request, handler)
assert seen["args"] == {"command": "cat /mnt/user-data/outputs/report.md"}
def test_resolves_nested_handle(self):
middleware = ArtifactResolutionMiddleware()
handle = generate_handle("thread_1", "call_0", 0)
request = _request(
{
"name": "bash",
"args": {"command": "echo hi", "cwd": None, "files": [{"path": handle, "mode": "r"}]},
"id": "call_2",
"type": "tool_call",
},
[_artifact("thread_1", 0, "/mnt/user-data/outputs/report.md")],
)
seen = {}
def handler(req):
seen["args"] = req.tool_call["args"]
return ToolMessage(content="ok", tool_call_id="call_2")
middleware.wrap_tool_call(request, handler)
assert seen["args"]["files"][0]["path"] == "/mnt/user-data/outputs/report.md"
def test_unknown_handle_returns_error(self):
middleware = ArtifactResolutionMiddleware()
request = _request(
{"name": "read_file", "args": {"path": "art_00000000"}, "id": "call_2", "type": "tool_call"},
[_artifact("thread_1", 0, "/mnt/user-data/outputs/report.md")],
)
def handler(req):
raise AssertionError("Unknown handle must not reach the tool")
result = middleware.wrap_tool_call(request, handler)
assert result.status == "error"
assert "art_00000000" in result.content
assert request.tool_call["args"] == {"path": "art_00000000"}
def test_empty_registry_rejects_handles_but_preserves_concrete_args(self):
middleware = ArtifactResolutionMiddleware()
request = _request({"name": "read_file", "args": {"path": "art_00000000"}, "id": "call_2", "type": "tool_call"}, [])
called = []
def handler(req):
called.append(req)
return ToolMessage(content="ok", tool_call_id="call_2")
result = middleware.wrap_tool_call(request, handler)
assert not called and result.status == "error"
concrete = _request({"name": "read_file", "args": {"path": "/tmp/a.txt"}, "id": "call_2"}, [])
assert middleware.wrap_tool_call(concrete, handler).content == "ok"
assert called == [concrete]
def test_disabled_config_skips_resolution(self):
middleware = ArtifactResolutionMiddleware(config=ToolArtifactConfig(resolve_handles_in_args=False))
request = _request(
{"name": "read_file", "args": {"path": "art_00000000"}, "id": "call_2", "type": "tool_call"},
[_artifact("thread_1", 0, "/mnt/user-data/outputs/report.md")],
)
called = []
def handler(req):
called.append(req)
return ToolMessage(content="ok", tool_call_id="call_2")
middleware.wrap_tool_call(request, handler)
assert called[0] is request
def test_feature_disabled_flag_blocks_resolution_even_when_args_flag_on(self):
middleware = ArtifactResolutionMiddleware(config=ToolArtifactConfig(enabled=False, resolve_handles_in_args=True))
handle = generate_handle("thread_1", "call_0", 0)
request = _request(
{"name": "read_file", "args": {"path": handle}, "id": "call_2", "type": "tool_call"},
[_artifact("thread_1", 0, "/mnt/user-data/outputs/report.md")],
)
seen = {}
def handler(req):
seen["args"] = req.tool_call["args"]
return ToolMessage(content="ok", tool_call_id="call_2")
middleware.wrap_tool_call(request, handler)
assert seen["args"] == {"path": handle}
class TestRealToolCallRequest:
"""Exercise the real ``ToolCallRequest.override()`` dataclass path."""
def _real_request(self, args: dict) -> ToolCallRequest:
return ToolCallRequest(
tool_call={"name": "read_file", "args": args, "id": "call_real", "type": "tool_call"},
tool=None,
state={"tool_artifacts": [_artifact("thread_1", 0, "/mnt/user-data/outputs/report.md")]},
runtime=None,
)
def test_override_produces_resolved_request(self):
middleware = ArtifactResolutionMiddleware()
handle = generate_handle("thread_1", "call_0", 0)
request = self._real_request({"path": handle})
resolved = middleware._resolve_request(request)
assert resolved is not request
assert resolved.tool_call["args"] == {"path": "/mnt/user-data/outputs/report.md"}
assert request.tool_call["args"] == {"path": handle}, "original request must stay untouched"
def test_end_to_end_through_wrap_tool_call(self):
middleware = ArtifactResolutionMiddleware()
handle = generate_handle("thread_1", "call_0", 0)
request = self._real_request({"path": f"`{handle}`"})
seen = {}
def handler(req):
seen["args"] = req.tool_call["args"]
return ToolMessage(content="ok", tool_call_id="call_real")
result = middleware.wrap_tool_call(request, handler)
assert seen["args"] == {"path": "/mnt/user-data/outputs/report.md"}
assert result.content == "ok"

View File

@ -0,0 +1,175 @@
"""Regression cases from PR #4929's occurrence, PII and URL reviews."""
from types import SimpleNamespace
import pytest
from _agent_e2e_helpers import FakeToolCallingModel
from langchain.agents import create_agent
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langgraph.checkpoint.memory import InMemorySaver
from deerflow.agents.middlewares.artifact_capture_middleware import ArtifactCaptureMiddleware
from deerflow.agents.middlewares.durable_context_middleware import DurableContextMiddleware
from deerflow.agents.thread_state import ThreadState, get_thread_state_schema, merge_tool_artifacts
from deerflow.config.pii_redaction_config import PiiRedactionConfig
from deerflow.config.tool_artifact_config import ToolArtifactConfig
from deerflow.tools.artifact_registry import extract_artifacts_from_result
def result(message_id, ref, call_id="reused"):
return ToolMessage(id=message_id, content="done", tool_call_id=call_id, name="mcp_tool", artifact={"structured_content": {"file": ref}})
def runtime():
return SimpleNamespace(context={"thread_id": "review-thread"})
def apply(state, update):
if update:
state["tool_artifacts"] = merge_tool_artifacts(state.get("tool_artifacts"), update.get("tool_artifacts"))
state["tool_artifact_processed"] = list(dict.fromkeys([*state.get("tool_artifact_processed", []), *update.get("tool_artifact_processed", [])]))
def test_reused_provider_id_creates_distinct_artifacts_across_turns():
middleware = ArtifactCaptureMiddleware()
state = {"messages": [result("result-first", "/x/first.pdf")]}
apply(state, middleware.before_model(state, runtime()))
first = state["tool_artifacts"][0]
state["messages"].append(result("result-second", "/x/second.pdf"))
apply(state, middleware.before_model(state, runtime()))
assert [e["real_ref"] for e in state["tool_artifacts"]] == ["/x/first.pdf", "/x/second.pdf"]
assert len({e["handle"] for e in state["tool_artifacts"]}) == 2
# Removing the earlier turn cannot change the surviving occurrence's handle.
second = state["tool_artifacts"][1]
fresh = ArtifactCaptureMiddleware().before_model({"messages": [state["messages"][-1]]}, runtime())
assert fresh["tool_artifacts"][0]["handle"] == second["handle"] != first["handle"]
def test_reused_consuming_call_id_does_not_settle_a_later_occurrence():
middleware = ArtifactCaptureMiddleware()
state = {"messages": [result("result-first", "/x/first.pdf")]}
apply(state, middleware.before_model(state, runtime()))
state["messages"].append(AIMessage(id="quiet-turn", content="", tool_calls=[{"name": "read_file", "args": {"path": "/x/unrelated"}, "id": "read", "type": "tool_call"}]))
apply(state, middleware.before_model(state, runtime()))
handle = state["tool_artifacts"][0]["handle"]
state["messages"].append(AIMessage(id="consuming-turn", content="", tool_calls=[{"name": "read_file", "args": {"path": handle}, "id": "read", "type": "tool_call"}]))
apply(state, middleware.before_model(state, runtime()))
assert state["tool_artifacts"][0]["consumed_by"] == ["read"]
@pytest.mark.parametrize("mode", ["full", "delta"])
def test_eviction_remains_final_after_checkpoint_and_middleware_recreation(mode):
saver = InMemorySaver()
model = FakeToolCallingModel(responses=[AIMessage(content="done")])
def agent():
return create_agent(model=model, tools=[], middleware=[ArtifactCaptureMiddleware(ToolArtifactConfig(max_entries=10))], state_schema=get_thread_state_schema(mode), checkpointer=saver)
config = {"configurable": {"thread_id": "review-thread"}}
messages = [result(f"result-{i}", f"/x/{i}.pdf", f"call-{i}") for i in range(15)]
graph = agent()
first = graph.invoke({"messages": [HumanMessage(content="start"), *messages]}, config, context={"thread_id": "review-thread"})
assert len(first["tool_artifacts"]) == 10
retained = first["tool_artifacts"]
# Mark a retained entry as consumed, then reload through a different graph instance.
graph.update_state(config, {"tool_artifacts": [{**retained[-1], "consumed_by": ["read"]}]})
for _ in range(3):
restored = agent().invoke({"messages": [HumanMessage(content="continue")]}, config, context={"thread_id": "review-thread"})
assert [e["handle"] for e in restored["tool_artifacts"]] == [e["handle"] for e in retained]
assert restored["tool_artifacts"][-1]["consumed_by"] == ["read"]
@pytest.mark.parametrize("suffix", ["?sig=abc&expires=123", "#page=2", "?sig=abc#page=2"])
@pytest.mark.parametrize("wrapper", ["`{}`", '"{}"', "[{}]", "({})"])
def test_detected_file_url_preserves_suffix(suffix, wrapper):
ref = "https://host/report.pdf" + suffix
entries = extract_artifacts_from_result(ToolMessage(content="Saved " + wrapper.format(ref), tool_call_id="url"), thread_id="t")
assert [e["real_ref"] for e in entries] == [ref]
@pytest.mark.parametrize("enabled", [True, False])
def test_structured_artifact_labels_follow_configured_pii_redaction(enabled):
email = "alice@example.com"
key = "sk-" + "a" * 24
ref = f"https://host/report-{email}.pdf?token={key}"
model = FakeToolCallingModel(responses=[AIMessage(content="done")])
captured = []
original = model._generate
# Record the actual model request, after capture and durable projection.
def record(messages, **kwargs):
captured.extend(messages)
return original(messages, **kwargs)
object.__setattr__(model, "_generate", record)
config = PiiRedactionConfig(enabled=enabled, token_secret="synthetic-test-secret")
graph = create_agent(model=model, tools=[], middleware=[ArtifactCaptureMiddleware(), DurableContextMiddleware(pii_redaction_config=config)], state_schema=ThreadState)
out = graph.invoke({"messages": [HumanMessage(content="continue"), result("private-result", ref)]}, context={"thread_id": "review-thread"})
blocks = [m.content for m in captured if m.additional_kwargs.get("durable_context_data")]
assert blocks
rendered = "\n".join(blocks)
assert (email in rendered) is not enabled
assert (key in rendered) is not enabled
assert out["tool_artifacts"][0]["real_ref"] == ref
assert out["tool_artifacts"][0]["handle"] in rendered
def test_reused_provider_ids_in_real_checkpointed_agent_resolve_both_occurrences():
from langchain_core.tools import tool
from langgraph.prebuilt.tool_node import ToolCallRequest
from deerflow.agents.middlewares.artifact_resolution_middleware import ArtifactResolutionMiddleware
@tool
def make_file(name: str) -> str:
"""Make a file and return its path."""
return f"Saved /mnt/user-data/outputs/{name}"
saver = InMemorySaver()
model = FakeToolCallingModel(
responses=[
AIMessage(content="", tool_calls=[{"name": "make_file", "args": {"name": "first.pdf"}, "id": "reused", "type": "tool_call"}]),
AIMessage(content="done"),
AIMessage(content="", tool_calls=[{"name": "make_file", "args": {"name": "second.pdf"}, "id": "reused", "type": "tool_call"}]),
AIMessage(content="done"),
]
)
graph = create_agent(model=model, tools=[make_file], middleware=[ArtifactCaptureMiddleware()], state_schema=ThreadState, checkpointer=saver)
config = {"configurable": {"thread_id": "review-thread"}}
graph.invoke({"messages": [HumanMessage(content="first")]}, config, context={"thread_id": "review-thread"})
state = graph.invoke({"messages": [HumanMessage(content="second")]}, config, context={"thread_id": "review-thread"})
entries = state["tool_artifacts"]
assert [e["real_ref"] for e in entries] == ["/mnt/user-data/outputs/first.pdf", "/mnt/user-data/outputs/second.pdf"]
assert entries[0]["handle"] != entries[1]["handle"]
resolver = ArtifactResolutionMiddleware()
for entry in entries:
request = ToolCallRequest(tool_call={"name": "make_file", "args": {"name": entry["handle"]}, "id": "read", "type": "tool_call"}, tool=make_file, state=state, runtime=runtime())
resolved = resolver.wrap_tool_call(request, lambda r: ToolMessage(content=r.tool_call["args"]["name"], tool_call_id="read"))
assert resolved.content == entry["real_ref"]
@pytest.mark.parametrize("field", ["display_name", "artifact_type", "tool_name", "mime_type"])
def test_all_model_visible_artifact_labels_are_redacted_without_mutating_state(field):
from langchain.agents.middleware.types import ModelRequest
secret = "alice@example.com sk-" + "a" * 24
entry = {
"handle": "art_1234abcd",
"tool_name": "mcp_tool",
"tool_call_id": "make",
"call_index": 0,
"artifact_type": "file",
"display_name": "report.pdf",
"real_ref": "https://host/private.pdf",
"created_at": "now",
field: secret,
}
state = {"tool_artifacts": [entry], "messages": []}
model = FakeToolCallingModel(responses=[AIMessage(content="done")])
request = ModelRequest(model=model, messages=[], state=state, runtime=runtime(), tools=[])
middleware = DurableContextMiddleware(pii_redaction_config=PiiRedactionConfig(enabled=True, token_secret="synthetic-test-secret"))
projected = middleware._inject(request)
data = next(m.content for m in projected.messages if m.additional_kwargs.get("durable_context_data"))
assert "alice@example.com" not in data and "sk-" + "a" * 24 not in data
assert entry[field] == secret and entry["real_ref"] == "https://host/private.pdf"
assert entry["handle"] in data

View File

@ -0,0 +1,223 @@
"""Safety and bounded-context regressions from PR #4929's remaining reviews."""
import hashlib
import json
from types import SimpleNamespace
import pytest
from _agent_e2e_helpers import FakeToolCallingModel
from langchain.agents import create_agent
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.tools import tool
from langgraph.prebuilt.tool_node import ToolCallRequest
from deerflow.agents.middlewares.artifact_capture_middleware import ArtifactCaptureMiddleware
from deerflow.agents.middlewares.artifact_resolution_middleware import ArtifactResolutionMiddleware
from deerflow.agents.middlewares.read_before_write_middleware import ReadBeforeWriteMiddleware
from deerflow.agents.middlewares.sandbox_audit_middleware import SandboxAuditMiddleware
from deerflow.agents.middlewares.tool_error_handling_middleware import ToolErrorHandlingMiddleware, build_lead_runtime_middlewares
from deerflow.agents.middlewares.tool_receipt_middleware import ToolReceiptMiddleware
from deerflow.agents.thread_state import ThreadState, merge_artifacts, merge_tool_artifacts
from deerflow.config.app_config import AppConfig
from deerflow.config.guardrails_config import GuardrailProviderConfig, GuardrailsConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.guardrails.middleware import GuardrailMiddleware
from deerflow.guardrails.provider import GuardrailDecision
from deerflow.tools.artifact_registry import extract_artifacts_from_result, render_artifact_registry
def entry(ref="/mnt/user-data/outputs/report.md", handle="art_1234abcd"):
return {"handle": handle, "tool_name": "mcp_tool", "tool_call_id": "make", "call_index": 0, "artifact_type": "file", "display_name": "report.md", "real_ref": ref, "created_at": "now"}
def request(args, entries=()):
return ToolCallRequest(tool_call={"name": "read_file", "args": args, "id": "read", "type": "tool_call"}, tool=None, state={"tool_artifacts": list(entries)}, runtime=SimpleNamespace(context={"thread_id": "t"}))
def safety_chain(config=None):
middlewares = build_lead_runtime_middlewares(app_config=config or AppConfig(sandbox=SandboxConfig(use="test")))
return [m for m in middlewares if isinstance(m, (ArtifactResolutionMiddleware, ReadBeforeWriteMiddleware, SandboxAuditMiddleware, GuardrailMiddleware, ToolReceiptMiddleware, ToolErrorHandlingMiddleware))]
@pytest.mark.parametrize("prior_read", [None, "stale", "current"])
def test_real_agent_applies_write_gate_to_resolved_path(prior_read):
artifact = entry()
path = artifact["real_ref"]
files = {path: "current"}
writes = []
@tool
def write_file(path: str, content: str) -> str:
"""Write a file."""
writes.append(path)
files[path] = content
return "ok"
middlewares = safety_chain()
gate = next(m for m in middlewares if isinstance(m, ReadBeforeWriteMiddleware))
def read_current(_runtime, path):
if path not in files:
raise FileNotFoundError(path)
return files[path]
gate._content_reader = read_current
messages = [HumanMessage(content="write")]
if prior_read:
messages.append(ToolMessage(content=prior_read, tool_call_id="prior-read", additional_kwargs={"deerflow_read_mark": {"path": path, "hash": hashlib.sha256(prior_read.encode()).hexdigest()}}))
model = FakeToolCallingModel(responses=[AIMessage(content="", tool_calls=[{"name": "write_file", "args": {"path": artifact["handle"], "content": "new"}, "id": "write", "type": "tool_call"}]), AIMessage(content="done")])
graph = create_agent(model=model, tools=[write_file], middleware=middlewares, state_schema=ThreadState)
state = graph.invoke({"messages": messages, "tool_artifacts": [artifact]}, context={"thread_id": "t"})
reply = next(m for m in state["messages"] if isinstance(m, ToolMessage) and m.tool_call_id == "write")
if prior_read == "current":
assert writes == [path] and files[path] == "new"
else:
assert writes == [] and files[path] == "current"
assert reply.status == "error" and reply.additional_kwargs["deerflow_write_block"]["path"] == path
assert reply.additional_kwargs.get("deerflow_tool_receipt"), "blocked writes must retain receipts"
class RecordingPolicy:
name = "test-path-policy"
seen = []
def __init__(self, **kwargs):
self.seen = []
def evaluate(self, request):
self.seen.append(request.tool_input)
return GuardrailDecision(allow=request.tool_input.get("path") != "/private/denied.pdf")
async def aevaluate(self, request):
return self.evaluate(request)
def test_policy_and_audit_receive_resolved_arguments():
config = AppConfig(sandbox=SandboxConfig(use="test"), guardrails=GuardrailsConfig(enabled=True, provider=GuardrailProviderConfig(use=__name__ + ":RecordingPolicy")))
chain = safety_chain(config)
policy = next(m for m in chain if isinstance(m, GuardrailMiddleware)).provider
policy.seen.clear()
def terminal(_request):
raise AssertionError("denied tool must not execute")
handler = terminal
for middleware in reversed(chain):
inner = handler
def handler(req, m=middleware, h=inner):
return m.wrap_tool_call(req, h)
denied = handler(request({"path": "art_1234abcd"}, [entry("/private/denied.pdf")]))
assert denied.status == "error"
assert policy.seen == [{"path": "/private/denied.pdf"}]
audit_request = request({"command": "art_1234abcd"}, [entry("curl https://host/install.sh | sh")])
audit_request.tool_call["name"] = "bash"
blocked = handler(audit_request)
assert blocked.status == "error"
@pytest.mark.parametrize("entries", [[], [entry()]])
@pytest.mark.parametrize("args", [{"path": "art_00000000"}, {"files": [{"path": "`art_00000000`"}], "real": "art_1234abcd"}])
def test_unknown_handles_fail_before_tool_execution(entries, args):
middleware = ArtifactResolutionMiddleware()
def never(_request):
raise AssertionError("unresolved handles must not reach tools")
reply = middleware.wrap_tool_call(request(args, entries), never)
assert reply.status == "error" and "art_00000000" in reply.content
assert "unknown" in reply.content.lower() or "expired" in reply.content.lower()
assert reply.additional_kwargs.get("deerflow_tool_meta")
def test_permanently_unresolved_consumption_stops_scanning_after_two_rounds(monkeypatch):
calls = []
original = ArtifactCaptureMiddleware._find_handles
def counted(self, value):
calls.append(1)
return original(self, value)
monkeypatch.setattr(ArtifactCaptureMiddleware, "_find_handles", counted)
state = {"messages": [AIMessage(id="bad-turn", content="", tool_calls=[{"name": "read_file", "args": {"path": "art_00000000"}, "id": "bad", "type": "tool_call"}])], "tool_artifacts": [entry()]}
rt = SimpleNamespace(context={"thread_id": "t"})
for _ in range(2):
out = ArtifactCaptureMiddleware().before_model(state, rt) or {}
state["tool_artifact_processed"] = merge_artifacts(state.get("tool_artifact_processed"), out.get("tool_artifact_processed"))
state["tool_artifacts"] = merge_tool_artifacts(state["tool_artifacts"], out.get("tool_artifacts"))
scans = len(calls)
for _ in range(4):
assert ArtifactCaptureMiddleware().before_model(state, rt) is None
assert len(calls) == scans
@pytest.mark.parametrize("key,value", [("path", "not found"), ("url", "see the docs"), ("path", "outputs/report.md")])
def test_non_reference_structured_values_fall_back_to_bounded_data(key, value):
structured = {key: value}
entries = extract_artifacts_from_result(ToolMessage(content="done", tool_call_id="make", artifact={"structured_content": structured}), thread_id="t")
assert len(entries) == 1 and entries[0]["artifact_type"] == "data"
assert json.loads(entries[0]["real_ref"]) == structured
@pytest.mark.parametrize("ref", ["/tmp/report.pdf", "https://host/report.pdf?sig=abc", r"C:\outputs\report.pdf"])
def test_reference_shape_accepts_only_absolute_or_http_refs(ref):
entries = extract_artifacts_from_result(ToolMessage(content="done", tool_call_id="make", artifact={"structured_content": {"path": ref}}), thread_id="t")
assert entries[0]["artifact_type"] == "file" and entries[0]["real_ref"] == ref
@pytest.mark.parametrize("structured", [{}, [], {"custom": "x" * 1000000}, {"custom": "😀" * 2000}, {"custom": [None] * 100000}])
def test_empty_or_oversized_structured_payload_is_not_checkpointed(structured, monkeypatch):
import deerflow.tools.artifact_registry as registry
def never_serialize(*args, **kwargs):
raise AssertionError("oversized or empty payload must be rejected before serialization")
monkeypatch.setattr(registry.json, "dumps", never_serialize)
result = ToolMessage(content="done", tool_call_id="make", artifact={"structured_content": structured})
assert extract_artifacts_from_result(result, thread_id="t") == []
def test_structured_fallback_keeps_complete_json_within_utf8_budget():
result = ToolMessage(content="done", tool_call_id="make", artifact={"structured_content": {"custom": "😀" * 900}})
(captured,) = extract_artifacts_from_result(result, thread_id="t")
assert len(captured["real_ref"].encode("utf-8")) <= 4096
assert json.loads(captured["real_ref"]) == result.artifact["structured_content"]
def test_render_shows_omitted_count_without_exceeding_budget():
entries = [entry(handle=f"art_{i:08x}") for i in range(20)]
rendered = render_artifact_registry(entries, max_chars=600)
shown = sum(e["handle"] in rendered for e in entries)
assert 0 < shown < len(entries)
assert f"{len(entries) - shown} more artifact handles not shown" in rendered
assert len(rendered) <= 600
assert "local to this agent" in rendered
@pytest.mark.parametrize("budget", [0, 10, 200, 310, 320, 340, 350, 600])
def test_render_never_exceeds_small_budgets(budget):
rendered = render_artifact_registry([entry()], max_chars=budget)
assert len(rendered) <= budget
def test_oversized_structured_content_does_not_drop_valid_file_block():
result = ToolMessage(content=[{"type": "file", "source": {"url": "/tmp/report.pdf"}}], tool_call_id="make", artifact={"structured_content": {"custom": "x" * 1000000}})
entries = extract_artifacts_from_result(result, thread_id="t")
assert [e["real_ref"] for e in entries] == ["/tmp/report.pdf"]
@pytest.mark.parametrize("value", ["https://[invalid/report.pdf", "https://", "data:application/pdf;base64,AAAA", "blob:https://host/id"])
def test_bad_structured_urls_do_not_become_file_handles(value):
result = ToolMessage(content="done", tool_call_id="make", artifact={"structured_content": {"url": value}})
entries = extract_artifacts_from_result(result, thread_id="t")
assert all(e["artifact_type"] != "file" for e in entries)
def test_excessive_structured_depth_and_escaping_are_rejected():
nested = {"leaf": "value"}
for _ in range(40):
nested = {"nested": nested}
for value in (nested, {"custom": "\n" * 3000}):
result = ToolMessage(content="done", tool_call_id="make", artifact={"structured_content": value})
assert extract_artifacts_from_result(result, thread_id="t") == []

View File

@ -1382,3 +1382,78 @@ class TestSummaryRecordWindowSplit:
]
assert middleware.before_model({"messages": msgs}, None) is None
class TestArtifactRegistryProjection:
def _artifact_entry(self, handle: str = "art_a1b2c3d4") -> dict:
return {
"handle": handle,
"tool_name": "mcp_server_analyze",
"tool_call_id": "call_1",
"call_index": 0,
"artifact_type": "file",
"display_name": "report.html",
"real_ref": "/mnt/user-data/outputs/report.html",
"mime_type": "text/html",
"created_at": "2026-08-19T00:00:00Z",
"consumed_by": [],
}
def test_artifact_handles_injected_into_durable_data_block(self):
model = RecordingFakeModel(responses=[AIMessage(content="ok")])
agent = create_agent(
model=model,
tools=[fake_task],
middleware=[DurableContextMiddleware()],
state_schema=ThreadState,
)
agent.invoke(
{
"messages": [HumanMessage(content="continue")],
"tool_artifacts": [self._artifact_entry()],
}
)
data = [message for message in model.received[-1] if isinstance(message, HumanMessage) and message.additional_kwargs.get("durable_context_data")]
assert data, "durable context data message not injected"
assert "art_a1b2c3d4" in data[0].content
assert "report.html" in data[0].content
def test_artifact_projection_disabled_by_flag(self):
model = RecordingFakeModel(responses=[AIMessage(content="ok")])
agent = create_agent(
model=model,
tools=[fake_task],
middleware=[DurableContextMiddleware(inject_tool_artifacts=False)],
state_schema=ThreadState,
)
agent.invoke(
{
"messages": [HumanMessage(content="continue")],
"tool_artifacts": [self._artifact_entry()],
}
)
data = [message for message in model.received[-1] if isinstance(message, HumanMessage) and message.additional_kwargs.get("durable_context_data")]
assert all("art_a1b2c3d4" not in message.content for message in data)
def test_artifact_projection_coexists_with_task_notes_and_summary():
model = RecordingFakeModel(responses=[AIMessage(content="ok")])
middleware = DurableContextMiddleware(task_continuity_enabled=True)
agent = create_agent(model=model, tools=[], middleware=[middleware], state_schema=ThreadState)
agent.invoke(
{
"messages": [HumanMessage(content="continue")],
"summary_text": "Previously generated a report.",
"task_notes": {"next": {"content": "Import the report", "source_ids": [], "authority": "model_report"}},
"tool_artifacts": [TestArtifactRegistryProjection()._artifact_entry()],
}
)
blocks = [message.content for message in model.received[-1] if message.additional_kwargs.get("durable_context_data")]
assert len(blocks) == 1
assert "art_a1b2c3d4" in blocks[0]
assert "Import the report" in blocks[0]
assert "Previously generated a report." in blocks[0]

View File

@ -97,10 +97,22 @@ def test_core_constraints_are_declared():
from deerflow.agents.middlewares.tool_promotion_audit_middleware import DeferredToolPromotionAuditMiddleware
from deerflow.extensions.ordering import core_ordering_constraints
core_ordering_constraints.cache_clear() # Prior wiring tests temporarily replace middleware modules.
pairs = {(c.outer, c.inner) for c in core_ordering_constraints()}
assert (ToolProgressMiddleware, ToolErrorHandlingMiddleware) in pairs
assert (DeferredToolPromotionAuditMiddleware, SkillToolPolicyMiddleware) in pairs
from deerflow.agents.middlewares.artifact_resolution_middleware import ArtifactResolutionMiddleware
from deerflow.agents.middlewares.read_before_write_middleware import ReadBeforeWriteMiddleware
from deerflow.agents.middlewares.sandbox_audit_middleware import SandboxAuditMiddleware
from deerflow.agents.middlewares.tool_receipt_middleware import ToolReceiptMiddleware
from deerflow.guardrails.middleware import GuardrailMiddleware
for policy in (GuardrailMiddleware, SandboxAuditMiddleware, ReadBeforeWriteMiddleware, ToolProgressMiddleware):
assert (ArtifactResolutionMiddleware, policy) in pairs
assert (ToolReceiptMiddleware, ArtifactResolutionMiddleware) in pairs
core_ordering_constraints.cache_clear()
def test_core_constraints_are_a_plain_tuple():
"""Deferred resolution must not be paid for with an object that reports one

View File

@ -328,7 +328,7 @@ def test_disabled_continuity_retention_does_not_change_assembly_identity():
assert _middleware_fingerprint(omitted) == _middleware_fingerprint(disabled)
@pytest.mark.parametrize("kwargs", [{"task_continuity_enabled": True}, {"skills_container_path": "/other-skills"}, {"skill_file_read_tool_names": ["custom_read"]}])
@pytest.mark.parametrize("kwargs", [{"inject_tool_artifacts": False}, {"task_continuity_enabled": True}, {"skills_container_path": "/other-skills"}, {"skill_file_read_tool_names": ["custom_read"]}])
def test_durable_context_behavior_changes_assembly_identity(kwargs):
from deerflow.agents.middlewares.durable_context_middleware import DurableContextMiddleware

View File

@ -616,6 +616,20 @@ class TestAgentConstruction:
# HumanMessage should be the task
assert messages[1].content == "Do the task"
@pytest.mark.anyio
async def test_task_child_does_not_inherit_agent_local_artifact_registry(self, classes, base_config, monkeypatch):
"""The documented MVP delegation boundary starts with a fresh registry."""
monkeypatch.setattr(
sys.modules["deerflow.skills.storage"],
"get_or_new_user_skill_storage",
lambda user_id, *, app_config=None: SimpleNamespace(load_skills=lambda *, enabled_only: []),
)
executor = classes["SubagentExecutor"](config=base_config, tools=[], thread_id="parent-thread")
state, _, _ = await executor._build_initial_state("Process /mnt/user-data/outputs/report.md and return concrete references")
assert "tool_artifacts" not in state
assert "tool_artifact_processed" not in state
assert "/mnt/user-data/outputs/report.md" in state["messages"][-1].content
@pytest.mark.anyio
async def test_build_initial_state_no_skills_only_system_prompt(
self,

View File

@ -22,6 +22,7 @@ from deerflow.agents.thread_state import (
merge_sandbox,
merge_skill_context,
merge_todos,
merge_tool_artifacts,
merge_viewed_images,
)
from deerflow.subagents.status_contract import SUBAGENT_STATUS_VALUES
@ -354,6 +355,11 @@ class TestThreadStateAnnotations:
hints = get_type_hints(ThreadState, include_extras=True)
assert merge_skill_context in hints["skill_context"].__metadata__
def test_tool_artifacts_field_is_wired_to_merge_tool_artifacts(self):
"""ThreadState.tool_artifacts must merge artifact records by handle."""
hints = get_type_hints(ThreadState, include_extras=True)
assert merge_tool_artifacts in hints["tool_artifacts"].__metadata__
def test_public_reducer_field_names_match_thread_state_contract(self):
assert THREAD_STATE_REDUCER_FIELDS == {
"messages",
@ -365,5 +371,7 @@ class TestThreadStateAnnotations:
"promoted",
"delegations",
"skill_context",
"tool_artifacts",
"tool_artifact_processed",
"task_notes",
}

View File

@ -217,6 +217,8 @@ def test_build_subagent_runtime_middlewares_threads_app_config_to_llm_middleware
# + 1 SubagentDateContextMiddleware
# + 1 SystemMessageCoalescingMiddleware + 1 ToolReceiptMiddleware
# (all enabled by default).
# + 1 ArtifactResolutionMiddleware + 1 ArtifactCaptureMiddleware
# (tool_artifacts enabled by default, #4676).
from deerflow.agents.middlewares.durable_context_middleware import DurableContextMiddleware
from deerflow.agents.middlewares.dynamic_context_middleware import SubagentDateContextMiddleware
from deerflow.agents.middlewares.knowledge_scope_middleware import KnowledgeScopeMiddleware
@ -228,7 +230,7 @@ def test_build_subagent_runtime_middlewares_threads_app_config_to_llm_middleware
from deerflow.agents.middlewares.tool_output_budget_middleware import ToolOutputBudgetMiddleware
from deerflow.agents.middlewares.tool_receipt_middleware import ToolReceiptMiddleware
assert len(middlewares) == 20
assert len(middlewares) == 22
assert isinstance(middlewares[0], FakeMiddleware) # InputSanitizationMiddleware stub
assert isinstance(middlewares[1], KnowledgeScopeMiddleware)
assert isinstance(middlewares[2], ToolOutputBudgetMiddleware)
@ -302,6 +304,75 @@ def test_tool_progress_middleware_is_outer_relative_to_error_handling(monkeypatc
assert progress_idx < error_idx, f"ToolProgressMiddleware (index {progress_idx}) must be outer (lower index) than ToolErrorHandlingMiddleware (index {error_idx}); order: {[type(m).__name__ for m in middlewares]}"
def test_artifact_middlewares_ordering_in_runtime_chain(monkeypatch: pytest.MonkeyPatch):
# Resolution precedes authorization, auditing and write/progress gates;
# all policy layers inspect the same concrete arguments as the tool.
# ArtifactCaptureMiddleware is position-independent in the wrap chain — it
# is a `before_model` hook reading state messages, not a tool wrapper — so
# the assertion below only pins its assembly slot, not a behavioral
# dependency. — issue #4676.
from deerflow.agents.middlewares.artifact_capture_middleware import ArtifactCaptureMiddleware
from deerflow.agents.middlewares.artifact_resolution_middleware import ArtifactResolutionMiddleware
from deerflow.config.tool_artifact_config import ToolArtifactConfig
from deerflow.config.tool_progress_config import ToolProgressConfig
app_config = AppConfig(
models=[
ModelConfig(
name="test-model",
display_name="test-model",
description=None,
use="langchain_openai:ChatOpenAI",
model="test-model",
)
],
sandbox=SandboxConfig(use="test"),
guardrails=GuardrailsConfig(enabled=False),
circuit_breaker=CircuitBreakerConfig(failure_threshold=7, recovery_timeout_sec=11),
tool_progress=ToolProgressConfig(enabled=True),
tool_artifacts=ToolArtifactConfig(enabled=True, resolve_handles_in_args=True),
)
_stub_runtime_middleware_imports(monkeypatch)
middlewares = build_subagent_runtime_middlewares(app_config=app_config, lazy_init=False)
names = [type(m).__name__ for m in middlewares]
resolution_idx = next(i for i, m in enumerate(middlewares) if isinstance(m, ArtifactResolutionMiddleware))
capture_idx = next(i for i, m in enumerate(middlewares) if isinstance(m, ArtifactCaptureMiddleware))
error_idx = next(i for i, m in enumerate(middlewares) if isinstance(m, ToolErrorHandlingMiddleware))
assert resolution_idx < error_idx < capture_idx, f"expected resolution < error < capture, got: {names}"
for name in ("FakeSandboxAuditMiddleware", "ReadBeforeWriteMiddleware", "ToolProgressMiddleware"):
gate_idx = next(i for i, m in enumerate(middlewares) if type(m).__name__ == name)
assert resolution_idx < gate_idx, names
def test_artifact_capture_absent_when_disabled(monkeypatch: pytest.MonkeyPatch):
from deerflow.agents.middlewares.artifact_capture_middleware import ArtifactCaptureMiddleware
from deerflow.config.tool_artifact_config import ToolArtifactConfig
app_config = AppConfig(
models=[
ModelConfig(
name="test-model",
display_name="test-model",
description=None,
use="langchain_openai:ChatOpenAI",
model="test-model",
)
],
sandbox=SandboxConfig(use="test"),
guardrails=GuardrailsConfig(enabled=False),
circuit_breaker=CircuitBreakerConfig(failure_threshold=7, recovery_timeout_sec=11),
tool_artifacts=ToolArtifactConfig(enabled=False),
)
_stub_runtime_middleware_imports(monkeypatch)
middlewares = build_subagent_runtime_middlewares(app_config=app_config, lazy_init=False)
assert not any(isinstance(m, ArtifactCaptureMiddleware) for m in middlewares)
def test_middleware_ordering_guard_moved_to_declarative_constraints(monkeypatch: pytest.MonkeyPatch):
"""_build_runtime_middlewares no longer hand-validates ordering; the invariant is now
declared in deerflow.extensions.ordering (core_ordering_constraints / assert_ordering) and

View File

@ -1355,6 +1355,27 @@ tool_output:
# wrote without a read. 0 keeps none.
keep_recent_writes: 1
# ============================================================================
# Tool Artifact Handle Registry Configuration (issue #4676)
# ============================================================================
# Captures lightweight artifact references (paths, URLs, task ids) from tool
# results into durable per-thread state (`ThreadState.tool_artifacts`) so they
# survive context compaction. The model references artifacts by short handles
# (`art_xxxxxxxx`) that are resolved to real references before tool execution.
# Handles are listed in the model context and rendered as badges in the UI.
tool_artifacts:
enabled: true
# Maximum artifact entries retained per thread (a sliding window: the
# oldest entries are evicted when the cap is exceeded).
max_entries: 100
# Conservatively scan free-text tool results for sandbox paths / remote file URLs.
detect_refs_in_text: true
# Project available artifact handles into the model request as durable context.
inject_model_context: true
# Resolve artifact handles found in tool arguments to real references before execution.
resolve_handles_in_args: true
# ============================================================================
# Suggestions Configuration
# ============================================================================

View File

@ -0,0 +1,674 @@
# Spec: MCP 工具产物持久化句柄注册表(Artifact Handle Registry)
**关联 Issue**: [#4676](https://github.com/bytedance/deer-flow/issues/4676) — [feat] 为 MCP 协议工具产物提供通用句柄以支持可持久化引用
**范围**: 后端 harness + 中间件 + state schema + 前端渲染
**依赖**: 无(独立实现,可与 #4652 MCP Tasks 扩展协议并存)
**状态**: 草案(待评审)
---
## 1. 问题描述
MCP 工具在 `ToolMessage.content` 中返回文件路径、task_id、资源 URI 等结构化引用。当 `SummarizationMiddleware` 压缩上下文时,这些引用被 LLM 摘要为自然语言(存储在 `summary_text`),原始结构化引用丢失,导致后续工具无法确定性地消费上游产物。
**具体场景**:
```
轮次1: 用户 → "分析这个数据集"
轮次2: Agent 调用 mcp_server_analyze(dataset.csv) → 返回 {file_path: "/mnt/user-data/outputs/report.html", summary: "..."}
轮次3-N: 多轮对话,触发上下文压缩
→ ToolMessage 被 RemoveMessage 删除,摘要写"生成了 report.html"
轮次N+1: 用户 → "把那个报告发给 QA 工具"
→ Agent 幻觉路径,或重新问用户
```
### 现有机制的断点(代码级)
1. **`ToolMessage.artifact` 只存 `structuredContent`** — `backend/packages/harness/deerflow/mcp/tools.py:429-432` 中,仅 MCP 的 `structuredContent` 字段被保存为 artifact;普通文本返回的文件路径、`ResourceLink` 的 URL、`ImageContent` 的 base64 数据都不进入 artifact,只存在于 `content` 列表中。
2. **上下文压缩不保留 tool_call_id 链** — `backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py:603-613` 中,压缩后 `messages` 被 `RemoveMessage(id=REMOVE_ALL_MESSAGES)` 清空重建,旧的 `ToolMessage`(含 `tool_call_id`)被删除,结构化映射丢失。
3. **`DurableContextMiddleware` 只捕获 `task` 委托** — `backend/packages/harness/deerflow/agents/middlewares/durable_context_middleware.py:225-235` 中,`extract_delegations()` 只匹配 `tool_name == "task"` 的调用;普通 MCP 工具的产物不在捕获范围内。
## 2. 目标
1. **捕获** — 从 MCP 工具结果(及任何产生产物的工具)中提取产物引用,存入持久化注册表,存续于压缩之后。
2. **注入** — 将产物元数据注入模型上下文,使 LLM 能通过短句柄引用产物。
3. **解析** — 在工具调用时,将参数中的短句柄解析为真实引用,再传给下游 MCP 工具。
4. **展示** — 在前端工具调用步骤 UI 中展示产物引用。
5. **共存** — 叠加在现有机制之上(`ToolOutputBudgetMiddleware`、`DurableContextMiddleware`、`ThreadState.artifacts`),不破坏它们。
## 3. 非目标
- 后台轮询 / 任务生命周期管理(由 #4652 MCP Tasks 覆盖)。
- 产物文件存储 / 提供(现有 artifact API 处理)。
- 跨线程产物共享(所有权限定为 thread 级)。
- 前端产物编辑(现有 artifact panel 处理)。
---
## 4. 数据模型
### 4.1 `ArtifactEntry`(新增 TypedDict,放 `thread_state.py`)
```python
class ArtifactEntry(TypedDict):
"""从工具结果中捕获的产物引用。"""
handle: str # 短稳定引用,如 "art_3a7f2b"(8位hex)
tool_name: str # 产出该产物的工具名
tool_call_id: str # 对应的 AIMessage.tool_calls[].id
call_index: int # 批次内 0-based 序号
artifact_type: str # "file" | "image" | "resource" | "task_id" | "data"
display_name: str # 人类可读:文件名、描述、或截断内容预览
real_ref: str # 真实引用:文件路径、URL、task_id 等
mime_type: NotRequired[str] # MIME 类型(如已知)
created_at: str # ISO 8601 时间戳
consumed_by: NotRequired[list[str]] # 消费过该句柄的 tool_call_id 列表
```
### 4.2 `ThreadState` 新增字段
```python
class ThreadState(AgentState):
# ... 现有字段 ...
tool_artifacts: Annotated[list[ArtifactEntry], merge_tool_artifacts]
```
### 4.3 Reducer:`merge_tool_artifacts`
```python
_ARTIFACT_MAX_ENTRIES = 100
def merge_tool_artifacts(
existing: list[ArtifactEntry] | None,
new: list[ArtifactEntry] | None,
) -> list[ArtifactEntry]:
"""追加新产物;按 handle 去重;保留最近 N 条。"""
if not new:
return existing or []
by_handle: dict[str, ArtifactEntry] = {}
order: list[str] = []
for entry in [*(existing or []), *new]:
h = entry["handle"]
if h not in by_handle:
order.append(h)
by_handle[h] = entry # 最新的覆盖旧的
merged = [by_handle[h] for h in order]
if len(merged) > _ARTIFACT_MAX_ENTRIES:
merged = merged[-_ARTIFACT_MAX_ENTRIES:]
return merged
```
### 4.4 注册到 `THREAD_STATE_REDUCER_FIELDS`
```python
THREAD_STATE_REDUCER_FIELDS = frozenset({
"messages", "sandbox", "artifacts", "todos", "goal",
"viewed_images", "promoted", "delegations", "skill_context",
"tool_artifacts", # ← 新增
})
```
---
## 5. 句柄生成
格式:`art_` + 8位 hex,由 `(thread_id, tool_call_id, call_index)` 确定性派生:
```python
# 新模块: backend/packages/harness/deerflow/tools/artifact_registry.py
import hashlib
_HANDLE_PREFIX = "art_"
_HANDLE_LENGTH = 8
def generate_handle(thread_id: str, tool_call_id: str, call_index: int) -> str:
"""确定性短句柄。相同输入 → 相同句柄。"""
seed = f"{thread_id}:{tool_call_id}:{call_index}"
digest = hashlib.sha256(seed.encode()).hexdigest()[:_HANDLE_LENGTH]
return f"{_HANDLE_PREFIX}{digest}"
```
**碰撞安全性**:`tool_call_id` 在一个 AIMessage 内唯一,`call_index` 区分同一批次的多个调用。不同线程不会共享 state,因此无碰撞风险。
---
## 6. 从工具结果中提取产物
### 6.1 提取逻辑(`artifact_registry.py`)
```python
def extract_artifacts_from_result(
result: ToolMessage,
*,
thread_id: str,
call_index: int = 0,
) -> list[ArtifactEntry]:
"""从 ToolMessage 的 content 和 artifact 字段中提取产物引用。"""
entries = []
now = _utc_now_iso()
tool_name = result.name or "unknown"
tool_call_id = result.tool_call_id or ""
# 路径1:ToolMessage.artifact(MCP structuredContent)
if result.artifact and isinstance(result.artifact, dict):
sc = result.artifact.get("structured_content")
if sc is not None:
entries.append(_make_entry(
handle=generate_handle(thread_id, tool_call_id, call_index),
tool_name=tool_name, tool_call_id=tool_call_id,
call_index=call_index, artifact_type="data",
display_name=f"{tool_name} 的结构化结果",
real_ref=json.dumps(sc)[:500], created_at=now,
))
return entries
# 路径2:Content blocks(file、image、resource)
content = result.content
if isinstance(content, str):
return entries
if not isinstance(content, list):
return entries
for i, block in enumerate(content):
if not isinstance(block, dict):
continue
block_type = block.get("type", "")
if block_type == "file":
source = block.get("source", {})
url = source.get("url") or ""
if url:
entries.append(_make_entry(
handle=generate_handle(thread_id, tool_call_id, call_index + i),
tool_name=tool_name, tool_call_id=tool_call_id,
call_index=call_index + i, artifact_type="file",
display_name=url.split("/")[-1] or url,
real_ref=url, mime_type=source.get("mime_type"),
created_at=now,
))
elif block_type == "image":
source = block.get("source", {})
url = source.get("url")
if url:
entries.append(_make_entry(
handle=generate_handle(thread_id, tool_call_id, call_index + i),
tool_name=tool_name, tool_call_id=tool_call_id,
call_index=call_index + i, artifact_type="image",
display_name=f"{tool_name} 的图片",
real_ref=url, mime_type=source.get("mime_type"),
created_at=now,
))
elif block_type == "text":
# 保守策略:检测文本中的文件路径和 URL
text = block.get("text", "")
for ref in _detect_refs_in_text(text):
entries.append(_make_entry(
handle=generate_handle(thread_id, tool_call_id, call_index + i),
tool_name=tool_name, tool_call_id=tool_call_id,
call_index=call_index + i,
artifact_type=ref["type"],
display_name=ref["display"],
real_ref=ref["ref"],
created_at=now,
))
return entries
def _detect_refs_in_text(text: str) -> list[dict]:
"""从文本中检测文件路径和 URL。"""
refs = []
# 匹配 /mnt/user-data/... 路径(sandbox 虚拟路径)
for match in re.finditer(r'/mnt/user-data/\S+', text):
path = match.group(0).rstrip('.,;:)')
refs.append({
"type": "file",
"ref": path,
"display": path.split("/")[-1],
})
# 匹配指向文件的 https URL
for match in re.finditer(r'https?://\S+\.(?:png|jpg|jpeg|html|pdf|csv|json|txt|log)', text):
url = match.group(0).rstrip('.,;:)')
refs.append({
"type": "file",
"ref": url,
"display": url.split("/")[-1],
})
return refs
```
### 6.2 为什么不在 `_convert_call_tool_result()` 中直接做?
转换函数是纯函数(MCP 结果 → LangChain 内容块)。产物提取是**横切关注点**,适用于所有工具(不仅是 MCP)。独立为注册表模块的好处:
- 内置工具返回文件路径也能被捕获。
- 中间件可通过配置开关,不触碰 MCP 代码。
- 提取逻辑可独立测试。
---
## 7. 中间件:`ArtifactCaptureMiddleware`
### 7.1 在中间件链中的位置
```
ToolErrorHandlingMiddleware (包装异常,盖章 deerflow_tool_meta)
↓
ArtifactCaptureMiddleware ← 新增(从结果中捕获产物)
↓
ToolOutputBudgetMiddleware (截断/外部化超大输出)
↓
ToolResultSanitizationMiddleware (中和注入标签)
```
**位置理由**:必须在 `ToolErrorHandlingMiddleware` 之后(结果已成型),在 `ToolOutputBudgetMiddleware` 之前(捕获完整内容,不被截断)。产物注册表只存轻量元数据(句柄 + 真实引用),不存内容本体,截断不影响它。
### 7.2 实现方式:`before_model` hook
**关键决策**:不在 `wrap_tool_call` 中用 `Command(update=...)` 包装结果(会替换 ToolMessage)。改用 `before_model` hook,与 `DurableContextMiddleware._capture_delegations()` 完全一致的模式:
```python
# 新模块: backend/packages/harness/deerflow/agents/middlewares/artifact_capture_middleware.py
class ArtifactCaptureMiddleware(AgentMiddleware[AgentState]):
"""从工具结果中捕获产物引用到 ThreadState.tool_artifacts。
运行在 before_model hook(不是 wrap_tool_call),
扫描尚未捕获的最新 ToolMessage,提取产物,返回 state update。
"""
def before_model(self, state: AgentState, runtime: Runtime) -> dict | None:
messages = state.get("messages", [])
existing = state.get("tool_artifacts") or []
existing_call_ids = {e["tool_call_id"] for e in existing}
thread_id = runtime.context.get("thread_id", "")
if not thread_id:
return None
new_entries = []
for message in messages:
if not isinstance(message, ToolMessage):
continue
if message.tool_call_id in existing_call_ids:
continue
if message.status == "error":
continue
entries = extract_artifacts_from_result(
message,
thread_id=thread_id,
call_index=0,
)
new_entries.extend(entries)
if new_entries:
return {"tool_artifacts": new_entries}
return None
async def abefore_model(self, state: AgentState, runtime: Runtime) -> dict | None:
return self.before_model(state, runtime)
```
---
## 8. 模型上下文注入
### 8.1 注入点:`DurableContextMiddleware`
扩展现有的 `DurableContextMiddleware`,将 `tool_artifacts` 投影到每个模型请求的隐藏 `HumanMessage` 数据块中:
```python
# 在 durable_context_middleware.py 中,扩展 _build_durable_context_block()
def _build_artifact_section(self, state: AgentState) -> str | None:
artifacts = state.get("tool_artifacts") or []
if not artifacts:
return None
lines = ["## 可用产物引用"]
lines.append("以下是工具产出的持久化句柄。在工具参数中使用它们来引用产物。")
lines.append("")
for entry in artifacts:
consumed = entry.get("consumed_by", [])
status = "[已消费]" if consumed else "[可用]"
mime = f" ({entry['mime_type']})" if entry.get("mime_type") else ""
lines.append(f"- `{entry['handle']}` → {entry['artifact_type']}: {entry['display_name']}{mime} {status}")
lines.append(f" 来源: {entry['tool_name']} (call {entry['tool_call_id'][:12]})")
return "\n".join(lines)
```
### 8.2 LLM 看到的内容(注入后)
```
## 可用产物引用
以下是工具产出的持久化句柄。在工具参数中使用它们来引用产物。
- `art_3a7f2b1c` → file: report.html (text/html) [可用]
来源: mcp_server_analyze (call call_a1b2c3d4e5f6)
- `art_8e4d9f2a` → image: chart.png (image/png) [可用]
来源: mcp_chart_gen (call call_f6e5d4c3b2a1)
```
### 8.3 上下文压缩后的行为
| 数据 | 压缩后保留? | 机制 |
|---|---|---|
| `messages`(ToolMessages) | 否 — 被 `RemoveMessage(REMOVE_ALL_MESSAGES)` 删除 | 摘要文本 |
| `ThreadState.tool_artifacts` | **是** — channel state,不是 messages | `merge_tool_artifacts` reducer |
| `ThreadState.delegations` | 是 — 同一模式 | `merge_delegations` reducer |
| `ThreadState.skill_context` | 是 — 同一模式 | `merge_skill_context` reducer |
---
## 9. 工具参数句柄解析
### 9.1 新中间件:`ArtifactResolutionMiddleware`
在工具执行**之前**,将参数中的句柄解析为真实引用:
```python
# 新模块: backend/packages/harness/deerflow/agents/middlewares/artifact_resolution_middleware.py
class ArtifactResolutionMiddleware(AgentMiddleware[AgentState]):
"""将工具参数中的产物句柄解析为真实引用。"""
_HANDLE_PATTERN = re.compile(
r'`(art_[0-9a-f]{8})`|(?<!\w)(art_[0-9a-f]{8})(?!\w)'
)
def wrap_tool_call(self, request, handler):
args = request.tool_call.get("args", {})
if not isinstance(args, dict):
return handler(request)
state = request.state
artifacts = state.get("tool_artifacts") or []
handle_map = {a["handle"]: a for a in artifacts}
resolved_args = self._resolve_args(args, handle_map)
if resolved_args != args:
request = request._replace(
tool_call={**request.tool_call, "args": resolved_args}
)
return handler(request)
async def awrap_tool_call(self, request, handler):
args = request.tool_call.get("args", {})
if not isinstance(args, dict):
return await handler(request)
state = request.state
artifacts = state.get("tool_artifacts") or []
handle_map = {a["handle"]: a for a in artifacts}
resolved_args = self._resolve_args(args, handle_map)
if resolved_args != args:
request = request._replace(
tool_call={**request.tool_call, "args": resolved_args}
)
return await handler(request)
def _resolve_args(self, args: dict, handle_map: dict) -> dict:
"""递归解析字符串值中的产物句柄。"""
resolved = {}
for key, value in args.items():
if isinstance(value, str):
resolved[key] = self._resolve_string(value, handle_map)
elif isinstance(value, dict):
resolved[key] = self._resolve_args(value, handle_map)
elif isinstance(value, list):
resolved[key] = [
self._resolve_string(v, handle_map) if isinstance(v, str)
else self._resolve_args(v, handle_map) if isinstance(v, dict)
else v
for v in value
]
else:
resolved[key] = value
return resolved
def _resolve_string(self, text: str, handle_map: dict) -> str:
"""将产物句柄替换为真实引用。"""
def replace_handle(match):
handle = match.group(1) or match.group(2)
entry = handle_map.get(handle)
if entry:
return entry["real_ref"]
return match.group(0)
return self._HANDLE_PATTERN.sub(replace_handle, text)
```
### 9.2 消费追踪
在 `ArtifactCaptureMiddleware.before_model` 中,同时扫描 `AIMessage.tool_calls` 的参数,记录哪些句柄被消费了:
```python
def _track_consumption(self, state, runtime):
messages = state.get("messages", [])
existing = state.get("tool_artifacts") or []
if not existing:
return None
handle_map = {a["handle"]: a for a in existing}
consumed_updates = []
for message in messages:
if not isinstance(message, AIMessage):
continue
for tool_call in (message.tool_calls or []):
args = tool_call.get("args", {})
if not isinstance(args, dict):
continue
found_handles = _find_handles_in_values(args)
for handle in found_handles:
if handle in handle_map:
entry = handle_map[handle]
if tool_call["id"] not in entry.get("consumed_by", []):
consumed_updates.append({
**entry,
"consumed_by": [*entry.get("consumed_by", []), tool_call["id"]],
})
if consumed_updates:
return {"tool_artifacts": consumed_updates}
return None
```
---
## 10. 配置
```yaml
# config.yaml
tool_artifacts:
enabled: true # 默认开启
max_entries: 100 # 每线程最大产物数
detect_refs_in_text: true # 保守文本扫描(检测路径/URL)
inject_model_context: true # 将句柄投影到 LLM 上下文
```
```python
# deerflow/config/tool_artifact_config.py
class ToolArtifactConfig(BaseModel):
enabled: bool = Field(default=True)
max_entries: int = Field(default=100, ge=10, le=1000)
detect_refs_in_text: bool = Field(default=True)
inject_model_context: bool = Field(default=True)
```
---
## 11. API 接口
### 11.1 无新增端点
产物注册表是 state channel-only。现有端点已暴露 `ThreadState`:
- `GET /api/threads/{id}` → 返回值中包含 `tool_artifacts`
- `GET /api/langgraph/threads/{id}/state` → 同上
### 11.2 流式传输
`tool_artifacts` 在 `values` stream 模式下自动包含(它是带 reducer 的 `ThreadState` 字段)。
---
## 12. 前端变更
### 12.1 类型扩展
```typescript
// frontend/src/core/threads/types.ts
export interface ArtifactEntry {
handle: string;
tool_name: string;
tool_call_id: string;
artifact_type: "file" | "image" | "resource" | "task_id" | "data";
display_name: string;
real_ref: string;
mime_type?: string;
created_at: string;
consumed_by?: string[];
}
export interface AgentThreadState extends Record<string, unknown> {
// ... 现有字段 ...
tool_artifacts?: ArtifactEntry[];
}
```
### 12.2 工具调用步骤渲染
在 `message-group.tsx` 的 `convertToSteps()` 中,为产出产物的工具调用附加产物数据:
```typescript
// 构建 step 后,检查该工具调用是否产出了产物
const artifacts = state.tool_artifacts ?? [];
const producedArtifacts = artifacts.filter(
a => a.tool_call_id === tool_call.id
);
if (producedArtifacts.length > 0) {
step.artifacts = producedArtifacts;
}
```
### 12.3 产物徽章 UI
```tsx
// 在 ToolCall 组件中,工具名展示之后
{step.artifacts && step.artifacts.length > 0 && (
<div className="artifact-badges">
{step.artifacts.map(a => (
<span key={a.handle} className="artifact-badge" title={a.real_ref}>
{a.artifact_type === "file" ? <FileIcon /> : <DataIcon />}
{a.display_name}
<code>{a.handle}</code>
</span>
))}
</div>
)}
```
---
## 13. 文件清单
| 新增/修改 | 路径 | 用途 |
|---|---|---|
| **新增** | `backend/packages/harness/deerflow/tools/artifact_registry.py` | 句柄生成、提取、文本检测 |
| **新增** | `backend/packages/harness/deerflow/config/tool_artifact_config.py` | 配置 schema |
| **新增** | `backend/packages/harness/deerflow/agents/middlewares/artifact_capture_middleware.py` | 捕获 + 消费追踪 |
| **新增** | `backend/packages/harness/deerflow/agents/middlewares/artifact_resolution_middleware.py` | 工具参数句柄解析 |
| **修改** | `backend/packages/harness/deerflow/agents/thread_state.py` | 新增 `ArtifactEntry`、`tool_artifacts` 字段、reducer |
| **修改** | `backend/packages/harness/deerflow/agents/middlewares/durable_context_middleware.py` | 注入产物句柄到模型上下文 |
| **修改** | `backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py` | 追加中间件到链 |
| **修改** | `backend/packages/harness/deerflow/agents/lead_agent/agent.py` | 接入配置 + 中间件 |
| **修改** | `backend/packages/harness/deerflow/config/app_config.py` | 注册 `tool_artifacts` 配置 |
| **修改** | `frontend/src/core/threads/types.ts` | 新增 `ArtifactEntry` 类型 |
| **修改** | `frontend/src/components/workspace/messages/message-group.tsx` | 渲染产物徽章 |
| **新增** | `backend/tests/test_artifact_registry.py` | 单元测试 |
| **新增** | `backend/tests/test_artifact_capture_middleware.py` | 中间件测试 |
| **新增** | `backend/tests/test_artifact_resolution_middleware.py` | 解析测试 |
---
## 14. 测试计划
### 14.1 单元测试(`test_artifact_registry.py`)
| 用例 | 验证点 |
|---|---|
| `test_generate_handle_deterministic` | 相同输入 → 相同句柄 |
| `test_generate_handle_unique` | 不同输入 → 不同句柄 |
| `test_extract_from_file_block` | ResourceLink → ArtifactEntry type="file" |
| `test_extract_from_image_block` | ImageContent → ArtifactEntry type="image" |
| `test_extract_from_text_with_path` | 文本含 `/mnt/user-data/...` → ArtifactEntry |
| `test_extract_from_structured_content` | structuredContent → ArtifactEntry type="data" |
| `test_no_extraction_from_error_result` | 错误 ToolMessage → 空列表 |
| `test_no_extraction_from_plain_text` | 纯字符串 content → 空列表 |
| `test_merge_tool_artifacts_dedup` | 相同 handle → 最新覆盖 |
| `test_merge_tool_artifacts_cap` | 超过上限 → 保留最近 |
| `test_handle_pattern_detection` | 正则匹配 `art_3a7f2b1c` |
### 14.2 中间件测试(`test_artifact_capture_middleware.py`)
| 用例 | 验证点 |
|---|---|
| `test_capture_from_mcp_file_result` | MCP 文件 → state update 含产物 |
| `test_capture_from_mcp_image_result` | MCP 图片 → state update |
| `test_skip_error_result` | 错误 → 不捕获 |
| `test_dedup_existing` | 已捕获的 handle → 不重复捕获 |
| `test_track_consumption` | 工具参数含句柄 → consumed_by 更新 |
### 14.3 解析测试(`test_artifact_resolution_middleware.py`)
| 用例 | 验证点 |
|---|---|
| `test_resolve_single_handle` | `"use file art_3a7f2b1c"` → 真实路径 |
| `test_resolve_in_nested_args` | `{"path": "art_3a7f2b1c"}` → `{"path": "/mnt/user-data/..."}` |
| `test_resolve_in_list_args` | `["art_3a7f2b1c", "other"]` → `["真实路径", "other"]` |
| `test_no_resolve_unknown_handle` | 未知句柄 → 不变 |
| `test_no_resolve_non_string` | 非字符串值 → 不变 |
### 14.4 集成测试
| 用例 | 验证点 |
|---|---|
| `test_artifact_survives_summarization` | 创建产物 → 触发压缩 → `tool_artifacts` 仍存在 |
| `test_artifact_visible_in_model_context` | 隐藏 HumanMessage 含产物区段 |
| `test_full_cycle_mcp_to_resolution` | MCP 返回文件 → 捕获 → 模型看到句柄 → 模型用句柄调工具 → 解析 → 正确路径 |
---
## 15. 迁移与向后兼容
- `tool_artifacts` 字段使用 `Annotated[list[ArtifactEntry], merge_tool_artifacts]` — 现有线程默认空列表。
- 无需数据库迁移(checkpoint schema 通过 TypedDict 自描述)。
- 配置默认 `enabled: true` — 仅需 opt-out。
- 前端优雅处理缺失的 `tool_artifacts`(可选字段,`?? []`)。
## 16. 待确认问题
**MVP 委派边界(已明确)**:句柄注册表和处理记录仅在当前 agent 内有效。`task` 子代理初始状态不继承父级 `tool_artifacts` / `tool_artifact_processed`,终态报告也不合并子代理的注册表。父级工具边界会先把 `task` 参数内的有效父级句柄解析为具体引用;子代理报告必须返回具体路径 / URL,而不能返回仅在子代理内有效的句柄。父级对未知或已过期句柄给出明确错误并拒绝执行,不再把字面句柄传给下游工具。跨委派注册表传递及持久 MCP task/result 集成是后续工作,本 MVP 不声称实现这些能力。
1. **句柄呈现形式**:LLM 看到的句柄用反引号包裹(`` `art_3a7f2b1c` ``)还是裸文本?反引号有助于正则检测,但部分模型会将其视为代码。
2. **多调用批次序号**:`call_index` 假设顺序执行。同一 AIMessage 中的并行工具调用,顺序取决于 LangGraph 的 dispatch 合约,需确认。
3. **句柄过期策略**:超过上限时自动淘汰,还是支持显式"关闭/释放"语义?MVP 仅做上限淘汰。
---
## 17. 实施拆解
**预估工作量**:后端 ~800 行 + 前端 ~100 行 + 测试 ~600 行。可拆为 3 个 PR:
1. **PR 1(基础设施)**:State schema + 注册表 + 捕获中间件 + 单元测试
2. **PR 2(解析与注入)**:解析中间件 + 模型上下文注入 + 中间件测试
3. **PR 3(前端)**:前端渲染 + 集成测试

View File

@ -152,7 +152,7 @@ Array previews coalesce consecutive generated markers only at the end into one o
- **Thread routes** — construct Web UI chat paths through `core/threads/utils.ts::pathOfThread()`, which percent-encodes both custom agent names and thread IDs before inserting them into route segments
- **LangGraph client** is a singleton obtained via `getAPIClient()` in `core/api/`
- **Run creation retries** are disabled only for the initial `POST /runs/stream` in `core/api/api-client.ts`, because an ambiguous failure can otherwise create duplicate runs. Automatic SSE recovery keeps the SDK's normal HTTP retry budget for `GET` requests to the server-provided `Location`, including transient 5xx responses, and forwards `Last-Event-ID`. Reads and explicit SSE joins keep their normal retry behavior. Regression tests cover both a failed creation POST and POST success → stream interruption → GET 504 → GET success.
- **Run stream options** are sanitized by `core/api/stream-mode.ts`: the Gateway-supported set is `values`, `messages-tuple`, `updates`, `debug`, `tasks`, `checkpoints`, and `custom`; any request containing an unsupported mode throws before HTTP instead of being partially forwarded or silently defaulting to `values`. `streamResumable` is retained by thread hooks only for SDK-side reconnect bookkeeping but stripped before the HTTP request because the Gateway does not accept that request option; actual replay uses the SSE `Last-Event-ID` cursor. The main chat client's initial and rejoined streams are forced to incremental `messages-tuple`, `updates`, and `custom` modes so SDK lazy tracking cannot add repeated full-state `values` snapshots; explicitly requested non-snapshot modes such as `debug`, `tasks`, and `checkpoints` are preserved. `values` remains supported outside this chat wrapper for explicit state inspection and by the replay-gap durable-state reload below. The backend file-tool chunk batcher is keyed to `messages-tuple`, not `values`, so omitting snapshots must not regress `write_file` / `str_replace` streaming into one SSE frame per model token. `core/threads/stream-state.ts` folds the user-visible non-message fields from `updates` with the matching DeerFlow reducer semantics and rejects irrelevant frames before calling the SDK mutator, while the SDK's `messages-tuple` manager remains the sole owner of live message chunk assembly and deduplication. Keep this boundary aligned with the backend request schema; `messages` and `events` are not supported and must not be forwarded.
- **Run stream options** are sanitized by `core/api/stream-mode.ts`: the Gateway-supported set is `values`, `messages-tuple`, `updates`, `debug`, `tasks`, `checkpoints`, and `custom`; any request containing an unsupported mode throws before HTTP instead of being partially forwarded or silently defaulting to `values`. `streamResumable` is retained by thread hooks only for SDK-side reconnect bookkeeping but stripped before the HTTP request because the Gateway does not accept that request option; actual replay uses the SSE `Last-Event-ID` cursor. The main chat client's initial and rejoined streams are forced to incremental `messages-tuple`, `updates`, and `custom` modes so SDK lazy tracking cannot add repeated full-state `values` snapshots; explicitly requested non-snapshot modes such as `debug`, `tasks`, and `checkpoints` are preserved. `values` remains supported outside this chat wrapper for explicit state inspection and by the replay-gap durable-state reload below. The backend file-tool chunk batcher is keyed to `messages-tuple`, not `values`, so omitting snapshots must not regress `write_file` / `str_replace` streaming into one SSE frame per model token. `core/threads/stream-state.ts` folds the user-visible non-message fields from `updates` with the matching DeerFlow reducer semantics, including `tool_artifacts` capture/consumption updates (same-handle replacement preserves order, retention directives trim the oldest entries, and the absolute ceiling is 1000) and rejects irrelevant frames before calling the SDK mutator, while the SDK's `messages-tuple` manager remains the sole owner of live message chunk assembly and deduplication. Keep this boundary aligned with the backend request schema; `messages` and `events` are not supported and must not be forwarded.
- **Active-run recovery** in `core/threads/hooks.ts` discovers pending/running runs when a reopened chat has no tab-local reconnect pointer. Remember SDK-completed run IDs for the mounted hook, including submitted and natively reconnected runs: stale or delayed runs reads must not rejoin a completed run and interrupt follow-up suggestions. Keep recovery retries bounded and continue allowing a different active run to reconnect.
- **SSE replay gaps** are handled in `core/api/api-client.ts`, which wraps both initial and joined run streams because the upstream SDK ignores unknown event names. An id-less backend `gap` control frame clears stale reconnect metadata, emits an internal `stream_replay_gap` custom event, reloads durable thread values, and resumes after the server-provided retained tail when one exists (or rejoins without a cursor if the buffer is empty), with up to five recovery rejoins after the original stream (six total stream calls on an all-gap exhaustion path). The wrapper remains a lazy async iterable because the SDK consumes it with `for await`. `core/threads/hooks.ts` clears optimistic/transient/subtask state, invalidates durable history caches, and shows the localized recovery warning; never let a gap fall through as a normal stream finish or cancel the still-running backend run.
- **Streaming Markdown rendering** is owned by `core/streamdown`: Streamdown's `animated` / `isAnimating` API handles incremental word animation, while the shared `streamdownRenderingPlugins` config registers the named code-highlighting and Mermaid plugins required by Streamdown 2.5. Keep wrappers and derived configs wired to that shared object; do not reintroduce a rehype plugin that wraps every word, because reparsing a growing block remounts old words and replays their animation.

View File

@ -3,6 +3,8 @@ import {
BookOpenTextIcon,
ChevronUp,
CoinsIcon,
FileIcon,
FilesIcon,
FolderOpenIcon,
GlobeIcon,
LightbulbIcon,
@ -38,6 +40,7 @@ import {
extractReasoningContentFromMessage,
extractTextFromMessage,
} from "@/core/messages/utils";
import type { ArtifactEntry } from "@/core/threads/types";
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env";
import { cn } from "@/lib/utils";
@ -59,6 +62,7 @@ interface MessageGroupProps {
tokenDebugSteps?: TokenDebugStep[];
showTokenDebugSummaries?: boolean;
threadId?: string;
toolArtifacts?: ArtifactEntry[];
}
function MessageGroupComponent({
@ -69,6 +73,7 @@ function MessageGroupComponent({
tokenDebugSteps = [],
showTokenDebugSummaries = false,
threadId,
toolArtifacts,
}: MessageGroupProps) {
const { t } = useI18n();
const [showAbove, setShowAbove] = useState(
@ -77,7 +82,10 @@ function MessageGroupComponent({
const [showLastThinking, setShowLastThinking] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const allSteps = useMemo(() => convertToSteps(messages), [messages]);
const allSteps = useMemo(
() => convertToSteps(messages, toolArtifacts),
[messages, toolArtifacts],
);
// Keep the original messages and tool associations intact. Only the display
// of clarification context moves outside the execution disclosure (#5503).
const clarificationTextSteps = useMemo(
@ -500,6 +508,7 @@ function areMessageGroupPropsEqual(
Boolean(next.showTokenDebugSummaries) &&
previous.threadId === next.threadId &&
sameReferences(previous.messages, next.messages) &&
sameReferences(previous.toolArtifacts, next.toolArtifacts) &&
sameReferences(previous.tokenDebugSteps, next.tokenDebugSteps)
);
}
@ -635,6 +644,7 @@ function ToolCall({
showDetails = false,
resultMessage,
browserView,
artifacts,
threadId,
}: {
id?: string;
@ -649,6 +659,7 @@ function ToolCall({
showDetails?: boolean;
resultMessage?: Extract<Message, { type: "tool" }>;
browserView?: BrowserViewMeta;
artifacts?: ArtifactEntry[];
threadId?: string;
}) {
const { t } = useI18n();
@ -996,6 +1007,7 @@ function ToolCall({
label={resolveLabel(description ?? t.toolCalls.useTool(name))}
icon={WrenchIcon}
>
{renderArtifactBadges(artifacts)}
{showDetails && (
<ToolCallDetails
name={name}
@ -1009,6 +1021,31 @@ function ToolCall({
}
}
function renderArtifactBadges(artifacts?: ArtifactEntry[]) {
if (!artifacts || artifacts.length === 0) {
return null;
}
return (
<div className="artifact-badges mt-1 flex flex-wrap gap-1">
{artifacts.map((a) => (
<span
key={a.handle}
className="border-border bg-muted text-muted-foreground inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-xs"
title={a.real_ref}
>
{a.artifact_type === "file" ? (
<FileIcon className="size-3" />
) : (
<FilesIcon className="size-3" />
)}
{a.display_name}
<code>{a.handle}</code>
</span>
))}
</div>
);
}
interface GenericCoTStep<T extends string = string> {
id?: string;
messageId?: string;
@ -1025,6 +1062,7 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
result?: string;
resultMessage?: Extract<Message, { type: "tool" }>;
browserView?: BrowserViewMeta;
artifacts?: ArtifactEntry[];
}
interface CoTAssistantTextStep extends GenericCoTStep<"assistantText"> {
@ -1076,10 +1114,21 @@ function indexToolCallData(messages: Message[]) {
return { browserViews, toolCallResults, resultMessages };
}
function convertToSteps(messages: Message[]): CoTStep[] {
function convertToSteps(
messages: Message[],
toolArtifacts?: ArtifactEntry[],
): CoTStep[] {
const steps: CoTStep[] = [];
const { browserViews, toolCallResults, resultMessages } =
indexToolCallData(messages);
const artifactsByToolCallId = new Map<string, ArtifactEntry[]>();
for (const entry of toolArtifacts ?? []) {
const key = entry.tool_call_id;
artifactsByToolCallId.set(key, [
...(artifactsByToolCallId.get(key) ?? []),
entry,
]);
}
for (const [messageIndex, message] of messages.entries()) {
if (message.type === "ai") {
// Reasoning precedes the answer text it produced, so it is pushed first:
@ -1134,6 +1183,10 @@ function convertToSteps(messages: Message[]): CoTStep[] {
}
}
step.browserView = browserViews.get(toolCallId);
const artifacts = artifactsByToolCallId.get(toolCallId);
if (artifacts) {
step.artifacts = artifacts;
}
}
steps.push(step);
}

View File

@ -1388,6 +1388,7 @@ export function MessageList({
message,
])}
showTokenDebugSummaries={showTokenDebugSummaries}
toolArtifacts={thread.values?.tool_artifacts}
/>,
);
} else if (message.id) {
@ -1441,6 +1442,7 @@ export function MessageList({
group.messages,
)}
showTokenDebugSummaries={showTokenDebugSummaries}
toolArtifacts={thread.values?.tool_artifacts}
/>
{renderTokenUsage({
messages: group.messages,

View File

@ -1,4 +1,4 @@
import type { AgentThreadState, GoalState } from "./types";
import type { AgentThreadState, ArtifactEntry, GoalState } from "./types";
type ThreadStatePatch = Partial<AgentThreadState>;
@ -16,7 +16,13 @@ function isGoalState(value: unknown): value is GoalState {
);
}
const RENDERED_THREAD_STATE_KEYS = ["title", "artifacts", "todos", "goal"];
const RENDERED_THREAD_STATE_KEYS = [
"title",
"artifacts",
"tool_artifacts",
"todos",
"goal",
];
export function hasRenderedThreadStateUpdate(data: unknown): boolean {
if (!isRecord(data)) return false;
@ -41,6 +47,49 @@ function mergeArtifacts(
return [...new Set([...(existing ?? []), ...incoming])];
}
function isArtifactEntry(value: unknown): value is ArtifactEntry {
return (
isRecord(value) &&
[
"handle",
"tool_name",
"tool_call_id",
"artifact_type",
"display_name",
"real_ref",
].every((key) => typeof value[key] === "string") &&
Number.isInteger(value.call_index)
);
}
function mergeToolArtifacts(
existing: ArtifactEntry[] | undefined,
incoming: unknown,
): ArtifactEntry[] | undefined {
if (!Array.isArray(incoming)) return undefined;
// Same-handle updates replace metadata without moving first-seen order.
// Capture can append a retention directive after its entries.
const byHandle = new Map<string, ArtifactEntry>();
for (const item of [...(existing ?? []), ...incoming]) {
if (isArtifactEntry(item)) byHandle.set(item.handle, item);
}
let keep = 1000;
for (let index = incoming.length - 1; index >= 0; index--) {
const item: unknown = incoming[index];
if (isRecord(item) && item.op === "trim_to") {
if (
typeof item.keep === "number" &&
Number.isInteger(item.keep) &&
item.keep > 0
) {
keep = Math.min(item.keep, keep);
}
break;
}
}
return [...byHandle.values()].slice(-keep);
}
/**
* Fold a LangGraph `updates` frame into the state fields rendered by the chat
* UI. Updates are grouped by node name and carry reducer inputs, not complete
@ -61,6 +110,7 @@ export function reduceThreadStateUpdates(
const patch: ThreadStatePatch = {};
let artifacts = previous.artifacts;
let toolArtifacts = previous.tool_artifacts;
let hasPatch = false;
for (const update of Object.values(data)) {
@ -82,6 +132,15 @@ export function reduceThreadStateUpdates(
}
}
if (Object.hasOwn(update, "tool_artifacts")) {
const merged = mergeToolArtifacts(toolArtifacts, update.tool_artifacts);
if (merged !== undefined) {
toolArtifacts = merged;
patch.tool_artifacts = merged;
hasPatch = true;
}
}
// DeerFlow's merge_todos treats null as "this node did not touch todos"
// and an empty list as an explicit clear.
if (

View File

@ -31,12 +31,26 @@ export interface GoalState {
};
}
export interface ArtifactEntry {
handle: string;
tool_name: string;
tool_call_id: string;
call_index: number;
artifact_type: string;
display_name: string;
real_ref: string;
mime_type?: string | null;
created_at?: string;
consumed_by?: string[];
}
export interface AgentThreadState extends Record<string, unknown> {
title: string;
messages: Message[];
artifacts?: string[];
todos?: Todo[];
goal?: GoalState | null;
tool_artifacts?: ArtifactEntry[];
}
export interface AgentThreadContext extends Record<string, unknown> {

View File

@ -12,6 +12,7 @@ import {
import { MessageGroup } from "@/components/workspace/messages/message-group";
import { I18nContext } from "@/core/i18n/context";
import { enUS } from "@/core/i18n/locales/en-US";
import type { ArtifactEntry } from "@/core/threads/types";
rs.mock("@/components/workspace/artifacts", () => ({
useArtifacts: () => ({ setOpen: () => undefined, select: () => undefined }),
@ -41,12 +42,20 @@ function tool(
status,
};
}
function group(messages: Message[], debug = true) {
function group(
messages: Message[],
debug = true,
toolArtifacts?: ArtifactEntry[],
) {
return (
<I18nContext.Provider
value={{ locale: "en-US", setLocale: () => undefined, t: enUS }}
>
<MessageGroup messages={messages} showTokenDebugSummaries={debug} />
<MessageGroup
messages={messages}
showTokenDebugSummaries={debug}
toolArtifacts={toolArtifacts}
/>
</I18nContext.Provider>
);
}
@ -57,6 +66,30 @@ function expand() {
}
describe("generic tool details", () => {
it("updates artifact badges with unchanged messages while retaining Debug details", () => {
const messages = [call, tool("report ready")];
const { rerender } = render(group(messages));
expect(screen.queryByText("art_12345678")).toBeNull();
const artifact: ArtifactEntry = {
handle: "art_12345678",
tool_name: "mcp_lookup",
tool_call_id: "call-1",
call_index: 0,
artifact_type: "file",
display_name: "report.csv",
real_ref: "/mnt/user-data/outputs/report.csv",
};
rerender(group(messages, true, [artifact]));
expect(screen.getByText("art_12345678")).toBeTruthy();
expect(screen.getByText("report.csv")).toBeTruthy();
expand();
expect(
screen.getByRole("region", { name: "Result" }).textContent,
).toContain("report ready");
rerender(group(messages, true, []));
expect(screen.queryByText("art_12345678")).toBeNull();
});
it("distinguishes multiple calls even when they use the same tool", () => {
render(
group([

View File

@ -96,6 +96,17 @@ test("updates rendered thread state without receiving a values frame", async ()
max_continuations: 8,
no_progress_count: 0,
} as GoalState;
const toolArtifacts = [
{
handle: "art_12345678",
tool_name: "make_report",
tool_call_id: "call-1",
call_index: 0,
artifact_type: "file",
display_name: "report.csv",
real_ref: "/mnt/user-data/outputs/report.csv",
},
];
const todos = [{ content: "Draft", status: "in_progress" as const }];
act(() => {
@ -114,6 +125,7 @@ test("updates rendered thread state without receiving a values frame", async ()
{
agent: {
artifacts: ["report.md"],
tool_artifacts: toolArtifacts,
goal,
// The same message also arrives through messages-tuple. The update
// path must not apply it a second time.
@ -137,6 +149,7 @@ test("updates rendered thread state without receiving a values frame", async ()
expect(result.current.thread.values).toMatchObject({
artifacts: ["old.md", "report.md"],
tool_artifacts: toolArtifacts,
goal,
title: "After",
todos,

View File

@ -0,0 +1,72 @@
import { expect, test } from "@rstest/core";
import {
hasRenderedThreadStateUpdate,
reduceThreadStateUpdates,
} from "@/core/threads/stream-state";
import type { AgentThreadState, ArtifactEntry } from "@/core/threads/types";
function entry(handle: string): ArtifactEntry {
return {
handle,
tool_name: "make_report",
tool_call_id: `call_${handle}`,
call_index: 0,
artifact_type: "file",
display_name: `${handle}.csv`,
real_ref: `/mnt/user-data/outputs/${handle}.csv`,
consumed_by: [],
};
}
test("folds artifact capture, consumption and retention without values frames", () => {
const first = entry("art_11111111");
const second = entry("art_22222222");
const third = entry("art_33333333");
const previous: AgentThreadState = {
title: "Reports",
messages: [],
tool_artifacts: [first],
};
const consumed = { ...first, consumed_by: ["call_read"] };
const data = {
"ArtifactCaptureMiddleware.before_model": {
tool_artifacts: [second, consumed],
},
next_capture: {
tool_artifacts: [third, { op: "trim_to", keep: 2 }],
},
};
expect(hasRenderedThreadStateUpdate(data)).toBe(true);
expect(reduceThreadStateUpdates(previous, data)).toEqual({
tool_artifacts: [second, third],
});
expect(previous.tool_artifacts).toEqual([first]);
expect(
reduceThreadStateUpdates(previous, {
capture: { tool_artifacts: [second, consumed] },
}),
).toEqual({ tool_artifacts: [consumed, second] });
});
test("preserves the registry on empty updates and enforces its absolute ceiling", () => {
const first = entry("art_11111111");
const previous: AgentThreadState = {
title: "Reports",
messages: [],
tool_artifacts: [first],
};
expect(
reduceThreadStateUpdates(previous, {
capture: { tool_artifacts: [] },
}),
).toEqual({ tool_artifacts: [first] });
const many = Array.from({ length: 1001 }, (_, i) =>
entry(`art_${i.toString(16).padStart(8, "0")}`),
);
expect(
reduceThreadStateUpdates(previous, {
capture: { tool_artifacts: many },
})?.tool_artifacts,
).toEqual(many.slice(-1000));
});