mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 16:08:41 +00:00
* feat(harness): add deterministic tool receipts with model-visible ledger Stamp an immutable per-call fact record (tool name, status, args/output hashes, byte count, timestamp) onto every tool result via a new ToolReceiptMiddleware, and inject the derived receipt ledger (r1..rN) into the model context so subagent reports can cite executed actions. - tool_receipt.py: receipt core (make/extract/render), newest-first budget eviction, ids derived from the append-only message stream - ToolReceiptMiddleware: stamps ToolMessages directly or inside Command-wrapped results; hidden ledger injection mirrors DurableContextMiddleware; sits between ToolProgress and ToolErrorHandling with a build-time ordering guard - config: new verification section (receipts on, judge off), config version 32 -> 33 with example/helm/docs updates * feat(harness): split receipt rendering from stamping; address PR review Review fixes (PR #4659): - output_sha256 now uses sort_keys=True for structured content, matching the order-invariant args fingerprint - stamping failures log at warning (silent ledger gaps would corrupt citations); tool execution remains never blocked - _insert_after_leading_system_messages extracted to shared public message_utils.insert_after_leading_system_messages; both middlewares depend on it instead of a private cross-module helper - code comments in English RFC #4651 revision-2 alignment: - receipts_render_mode config ('always' | 'delegation_only'): subagent chains always render the ledger (citations are produced there); the lead chain renders only while processing subagent results, removing the always-on token tax from ordinary turns - receipts gain bounded args_preview/output_preview (<=200 chars, tail for output) so later typed claim bindings (tests_passed) can anchor to a specific recorded execution * docs(harness): state receipt freshness caveat and vocabulary layering in module docstring * merge: upstream/main — resolve AGENTS.md split, bump config_version to 34, drop unused receipt previews - backend/AGENTS.md: take upstream's slimmed root guidance (#4799); move the ToolReceiptMiddleware chain entry into agents/middlewares/AGENTS.md and the verification.* hot-reload mention into config/AGENTS.md - config.example.yaml + helm values/README: config_version 33 -> 34 so existing v33 configs get the outdated-config prompt (review: willem-bd) - tool_receipt.py: drop args_preview/output_preview — no Layer 1 consumer reads them; re-add with the Layer 2 claim-binding consumer (review: willem-bd) * docs(harness): cover receipt id renumbering after compaction in module docstring Positional display ids are stable only while history is append-only; compaction drops ToolMessages and the survivors renumber, so Layer 2 citation verification must resolve [rN] against the ledger as of the citing turn (review: willem-bd, doc-only). * chore(config): bump config_version to 35 main reached 34 via #4780 without the verification section; publishing the new schema at the same number would silently skip the outdated-config prompt for configs synced from main in that window (review: willem-bd). * fix(skills): restore errno import dropped upstream in #4830 upstream/main adf6c422 uses errno.ENOTDIR in the drift guard but removed the import, so the PR merge ref fails lint-backend (F821). * fix(harness): harden tool receipts against forgery and turn-scope delegation_only Address willem-bd's pre-merge review on #4659: 1. Untrusted receipt metadata: the gateway now strips the server-owned deerflow_tool_receipt key from external input messages; stamping always overwrites any tool-supplied value instead of preserving it; and extract_tool_receipts validates persisted receipt shapes (required typed fields, unknown keys ignored) so malformed entries are skipped instead of crashing render or passing as runtime-stamped evidence. 2. delegation_only no longer sticks on: _should_render now scopes the subagent_status scan to the current turn (messages after the latest genuine user message), so an old completed delegation stops rendering the ledger on later ordinary turns. The genuine-user predicate moves to message_utils.is_genuine_user_message, shared with input sanitization. * fix(harness): stamp receipts outside short-circuiting tool middlewares Address willem-bd's review on #4659: ToolReceiptMiddleware was registered inside Guardrail/SandboxAudit/ReadBeforeWrite/ToolProgress, each of which can return a ToolMessage without invoking its handler — blocked calls (e.g. a read-before-write-denied write_file) never got a receipt, silently gapping the ledger on a default-enabled path. SandboxAudit additionally rebuilds medium-risk results, dropping an inner stamp. ToolReceiptMiddleware is now the outermost wrap_tool_call layer in the runtime tail. Normal results still carry deerflow_tool_meta (stamped by ToolErrorHandling on the inner return path); short-circuit messages self-stamp meta or fall back to message.status. The new invariant is declared as ordering constraints in deerflow.extensions.ordering, with composed-chain regression tests for a blocked write and a warn-rebuilt bash result.
304 lines
13 KiB
Python
304 lines
13 KiB
Python
"""Tests for ToolReceiptMiddleware (stamping + context rendering)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
from langgraph.types import Command
|
|
|
|
from deerflow.agents.middlewares.tool_receipt import TOOL_RECEIPT_KEY
|
|
from deerflow.agents.middlewares.tool_receipt_middleware import ToolReceiptMiddleware
|
|
from deerflow.agents.middlewares.tool_result_meta import TOOL_META_KEY
|
|
|
|
|
|
def _request(tool_name: str = "bash") -> SimpleNamespace:
|
|
return SimpleNamespace(tool_call={"name": tool_name, "id": f"tc-{tool_name}", "args": {"cmd": "ls"}})
|
|
|
|
|
|
def _result(request) -> ToolMessage:
|
|
return ToolMessage(
|
|
content="ok",
|
|
tool_call_id=request.tool_call["id"],
|
|
name=request.tool_call["name"],
|
|
additional_kwargs={TOOL_META_KEY: {"status": "success"}},
|
|
)
|
|
|
|
|
|
def _stamped_message() -> ToolMessage:
|
|
message = ToolMessage(content="ok", tool_call_id="tc-1", name="bash")
|
|
message.additional_kwargs = {
|
|
TOOL_META_KEY: {"status": "success"},
|
|
TOOL_RECEIPT_KEY: {
|
|
"tool_call_id": "tc-1",
|
|
"tool_name": "bash",
|
|
"status": "success",
|
|
"args_sha256": "a" * 16,
|
|
"output_sha256": "b" * 16,
|
|
"output_bytes": 2,
|
|
"created_at": "2026-08-03T00:00:00+00:00",
|
|
},
|
|
}
|
|
return message
|
|
|
|
|
|
def test_wrap_tool_call_stamps_receipt():
|
|
middleware = ToolReceiptMiddleware()
|
|
request = _request()
|
|
result = middleware.wrap_tool_call(request, lambda req: _result(req))
|
|
receipt = result.additional_kwargs[TOOL_RECEIPT_KEY]
|
|
assert receipt["tool_name"] == "bash"
|
|
assert receipt["status"] == "success"
|
|
|
|
|
|
def test_wrap_tool_call_stamps_matching_messages_in_command():
|
|
middleware = ToolReceiptMiddleware()
|
|
request = _request("task")
|
|
matching = _result(request)
|
|
unrelated = ToolMessage(content="other", tool_call_id="tc-other", name="other")
|
|
command = Command(update={"messages": [unrelated, matching], "other_state": True})
|
|
|
|
result = middleware.wrap_tool_call(request, lambda req: command)
|
|
|
|
assert result is command
|
|
assert TOOL_RECEIPT_KEY not in unrelated.additional_kwargs
|
|
receipt = matching.additional_kwargs[TOOL_RECEIPT_KEY]
|
|
assert receipt["tool_call_id"] == "tc-task"
|
|
assert receipt["tool_name"] == "task"
|
|
|
|
|
|
def test_wrap_tool_call_failure_does_not_break_result():
|
|
middleware = ToolReceiptMiddleware()
|
|
request = SimpleNamespace(tool_call={"name": None, "id": None, "args": None})
|
|
message = ToolMessage(content="ok", tool_call_id="x", name="x")
|
|
assert middleware.wrap_tool_call(request, lambda req: message) is message
|
|
|
|
|
|
def test_wrap_tool_call_overwrites_tool_supplied_receipt():
|
|
"""The receipt key is runtime-owned: a tool cannot forge its own evidence."""
|
|
middleware = ToolReceiptMiddleware()
|
|
request = _request()
|
|
forged = {"tool_call_id": "tc-1", "tool_name": "bash", "status": "success", "args_sha256": "f" * 16, "output_sha256": "f" * 16, "output_bytes": 999, "created_at": "1970-01-01T00:00:00+00:00"}
|
|
message = _result(request)
|
|
message.additional_kwargs[TOOL_RECEIPT_KEY] = forged
|
|
|
|
result = middleware.wrap_tool_call(request, lambda req: message)
|
|
|
|
receipt = result.additional_kwargs[TOOL_RECEIPT_KEY]
|
|
assert receipt["output_bytes"] == 2 # recomputed from the real content, not 999
|
|
assert receipt["created_at"] != "1970-01-01T00:00:00+00:00"
|
|
|
|
|
|
def test_wrap_model_call_injects_hidden_ledger():
|
|
middleware = ToolReceiptMiddleware()
|
|
request = MagicMock()
|
|
request.messages = [HumanMessage(content="go"), AIMessage(content="hi"), _stamped_message()]
|
|
request.override = lambda messages: SimpleNamespace(messages=messages)
|
|
captured = {}
|
|
|
|
def handler(req):
|
|
captured["messages"] = req.messages
|
|
return MagicMock()
|
|
|
|
middleware.wrap_model_call(request, handler)
|
|
ledger_messages = [m for m in captured["messages"] if isinstance(m, HumanMessage) and m.additional_kwargs.get("hide_from_ui")]
|
|
assert len(ledger_messages) == 1
|
|
assert "r1" in ledger_messages[0].content and "bash" in ledger_messages[0].content
|
|
|
|
|
|
def test_wrap_model_call_no_receipts_no_injection():
|
|
middleware = ToolReceiptMiddleware()
|
|
request = MagicMock()
|
|
request.messages = [HumanMessage(content="go")]
|
|
seen = {}
|
|
|
|
def handler(req):
|
|
seen["request"] = req
|
|
return MagicMock()
|
|
|
|
middleware.wrap_model_call(request, handler)
|
|
assert seen["request"] is request # untouched passthrough
|
|
|
|
|
|
def _delegation_only_request(messages: list) -> MagicMock:
|
|
request = MagicMock()
|
|
request.messages = messages
|
|
request.override = lambda **kwargs: SimpleNamespace(messages=kwargs["messages"])
|
|
return request
|
|
|
|
|
|
def test_delegation_only_mode_skips_plain_conversation():
|
|
middleware = ToolReceiptMiddleware(render_mode="delegation_only")
|
|
request = _delegation_only_request([HumanMessage(content="go"), _stamped_message()])
|
|
seen = {}
|
|
|
|
def handler(req):
|
|
seen["request"] = req
|
|
return MagicMock()
|
|
|
|
middleware.wrap_model_call(request, handler)
|
|
assert seen["request"] is request # no completed delegation -> no ledger
|
|
|
|
|
|
def test_delegation_only_mode_renders_when_processing_subagent_result():
|
|
middleware = ToolReceiptMiddleware(render_mode="delegation_only")
|
|
subagent_result = ToolMessage(
|
|
content="Task Succeeded. Result: done [r1]",
|
|
tool_call_id="tc-task",
|
|
name="task",
|
|
additional_kwargs={"subagent_status": "completed"},
|
|
)
|
|
request = _delegation_only_request([HumanMessage(content="go"), _stamped_message(), subagent_result])
|
|
captured = {}
|
|
|
|
def handler(req):
|
|
captured["messages"] = req.messages
|
|
return MagicMock()
|
|
|
|
middleware.wrap_model_call(request, handler)
|
|
ledger_messages = [m for m in captured["messages"] if isinstance(m, HumanMessage) and m.additional_kwargs.get("hide_from_ui")]
|
|
assert len(ledger_messages) == 1 and "r1" in ledger_messages[0].content
|
|
|
|
|
|
def test_delegation_only_mode_ignores_delegations_from_earlier_turns():
|
|
"""A completed delegation must not keep the ledger rendering once a new
|
|
genuine user turn has started — that would defeat the token-saving mode."""
|
|
middleware = ToolReceiptMiddleware(render_mode="delegation_only")
|
|
old_subagent_result = ToolMessage(
|
|
content="Task Succeeded. Result: done [r1]",
|
|
tool_call_id="tc-task",
|
|
name="task",
|
|
additional_kwargs={"subagent_status": "completed"},
|
|
)
|
|
request = _delegation_only_request([HumanMessage(content="first question"), _stamped_message(), old_subagent_result, AIMessage(content="report [r1]"), HumanMessage(content="unrelated follow-up")])
|
|
seen = {}
|
|
|
|
def handler(req):
|
|
seen["request"] = req
|
|
return MagicMock()
|
|
|
|
middleware.wrap_model_call(request, handler)
|
|
assert seen["request"] is request # old delegation is outside the current turn
|
|
|
|
|
|
def test_delegation_only_mode_scopes_past_hidden_framework_messages():
|
|
"""Hidden framework injections (reminders, the ledger itself) are not user
|
|
turns: a subagent result after them still counts as the current turn."""
|
|
middleware = ToolReceiptMiddleware(render_mode="delegation_only")
|
|
reminder = HumanMessage(content="<system_reminder>todo</system_reminder>", additional_kwargs={"hide_from_ui": True})
|
|
subagent_result = ToolMessage(
|
|
content="Task Succeeded. Result: done",
|
|
tool_call_id="tc-task",
|
|
name="task",
|
|
additional_kwargs={"subagent_status": "completed"},
|
|
)
|
|
request = _delegation_only_request([HumanMessage(content="go"), reminder, _stamped_message(), subagent_result])
|
|
captured = {}
|
|
|
|
def handler(req):
|
|
captured["messages"] = req.messages
|
|
return MagicMock()
|
|
|
|
middleware.wrap_model_call(request, handler)
|
|
ledger_messages = [m for m in captured["messages"] if isinstance(m, HumanMessage) and m.additional_kwargs.get("hide_from_ui") and "Tool receipts" in str(m.content)]
|
|
assert len(ledger_messages) == 1
|
|
|
|
|
|
def _build(app_config_dict: dict) -> list:
|
|
from deerflow.agents.middlewares.tool_error_handling_middleware import _build_runtime_middlewares
|
|
from deerflow.config.app_config import AppConfig
|
|
|
|
app_config = AppConfig.model_validate(app_config_dict)
|
|
return _build_runtime_middlewares(app_config=app_config, include_uploads=False, include_dangling_tool_call_patch=False)
|
|
|
|
|
|
def _tool_call_request(name: str, args: dict):
|
|
from langgraph.prebuilt.tool_node import ToolCallRequest
|
|
|
|
runtime = MagicMock()
|
|
runtime.context = {"thread_id": "t-test"}
|
|
return ToolCallRequest(
|
|
tool_call={"name": name, "args": args, "id": "call-1"},
|
|
tool=None,
|
|
state={"messages": []},
|
|
runtime=runtime,
|
|
)
|
|
|
|
|
|
def _compose_tool_chain(chain: list, terminal):
|
|
"""Compose wrap_tool_call handlers outer-first, mirroring the runtime stack."""
|
|
handler = terminal
|
|
for middleware in reversed(chain):
|
|
next_handler = handler
|
|
|
|
def handler(req, mw=middleware, h=next_handler):
|
|
return mw.wrap_tool_call(req, h)
|
|
|
|
return handler
|
|
|
|
|
|
def _tool_call_segment(middlewares: list, names: tuple[str, ...]) -> list:
|
|
"""Extract the named wrap_tool_call middlewares in factory order."""
|
|
return [m for m in middlewares if type(m).__name__ in names]
|
|
|
|
|
|
def test_composed_chain_stamps_receipt_on_blocked_write():
|
|
"""Read-before-write (default on) short-circuits a write with its own
|
|
ToolMessage; the receipt layer must wrap that short-circuit or the ledger
|
|
silently gaps (willem-bd review)."""
|
|
middlewares = _build({"sandbox": {"use": "test"}})
|
|
chain = _tool_call_segment(middlewares, ("ToolReceiptMiddleware", "ReadBeforeWriteMiddleware", "ToolErrorHandlingMiddleware"))
|
|
assert [type(m).__name__ for m in chain] == ["ToolReceiptMiddleware", "ReadBeforeWriteMiddleware", "ToolErrorHandlingMiddleware"]
|
|
# File exists with content v1 but was never read -> the write is blocked.
|
|
chain[1]._content_reader = lambda _runtime, _path: "v1"
|
|
terminal = MagicMock(return_value=ToolMessage(content="OK", tool_call_id="call-1", name="write_file"))
|
|
|
|
result = _compose_tool_chain(chain, terminal)(_tool_call_request("write_file", {"path": "/mnt/user-data/outputs/a.txt", "content": "v2"}))
|
|
|
|
terminal.assert_not_called()
|
|
assert result.status == "error"
|
|
receipt = (result.additional_kwargs or {}).get(TOOL_RECEIPT_KEY)
|
|
assert receipt is not None, "blocked write must still get a receipt"
|
|
assert receipt["tool_name"] == "write_file" and receipt["status"] == "error"
|
|
|
|
|
|
def test_composed_chain_stamps_receipt_on_warn_rebuilt_result():
|
|
"""SandboxAudit rebuilds the result ToolMessage when appending a medium-risk
|
|
warning, dropping additional_kwargs; a receipt layer inside it would lose
|
|
the stamp. Outer receipt re-stamps the rebuilt message."""
|
|
middlewares = _build({"sandbox": {"use": "test"}})
|
|
chain = _tool_call_segment(middlewares, ("ToolReceiptMiddleware", "SandboxAuditMiddleware", "ToolErrorHandlingMiddleware"))
|
|
assert [type(m).__name__ for m in chain] == ["ToolReceiptMiddleware", "SandboxAuditMiddleware", "ToolErrorHandlingMiddleware"]
|
|
terminal = MagicMock(return_value=ToolMessage(content="installed", tool_call_id="call-1", name="bash"))
|
|
|
|
result = _compose_tool_chain(chain, terminal)(_tool_call_request("bash", {"command": "pip install cowsay"}))
|
|
|
|
terminal.assert_called_once()
|
|
assert "medium-risk" in str(result.content) # warn note appended
|
|
receipt = (result.additional_kwargs or {}).get(TOOL_RECEIPT_KEY)
|
|
assert receipt is not None, "warn-rebuilt result must still carry a receipt"
|
|
assert receipt["tool_name"] == "bash"
|
|
|
|
|
|
def test_factory_registers_receipt_middleware_outer_of_error_handling():
|
|
middlewares = _build({"sandbox": {"use": "test"}})
|
|
names = [type(m).__name__ for m in middlewares]
|
|
assert "ToolReceiptMiddleware" in names
|
|
assert names.index("ToolReceiptMiddleware") < names.index("ToolErrorHandlingMiddleware")
|
|
|
|
|
|
def test_factory_registers_receipt_middleware_outer_of_short_circuiting_layers():
|
|
"""Receipts must wrap every middleware that can return or rebuild a
|
|
ToolMessage without invoking its handler, or the ledger silently gaps."""
|
|
middlewares = _build({"sandbox": {"use": "test"}})
|
|
names = [type(m).__name__ for m in middlewares]
|
|
receipt_index = names.index("ToolReceiptMiddleware")
|
|
for short_circuiter in ("SandboxAuditMiddleware", "ReadBeforeWriteMiddleware"):
|
|
assert receipt_index < names.index(short_circuiter), f"ToolReceiptMiddleware must be outer of {short_circuiter}"
|
|
|
|
|
|
def test_factory_omits_receipt_middleware_when_disabled():
|
|
middlewares = _build({"sandbox": {"use": "test"}, "verification": {"receipts_enabled": False}})
|
|
assert "ToolReceiptMiddleware" not in [type(m).__name__ for m in middlewares]
|