mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* 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>
393 lines
17 KiB
Python
393 lines
17 KiB
Python
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"
|