deer-flow/backend/tests/test_tool_receipt_middleware.py
Zeren Wang 4e35f0d1d4
feat(harness): deterministic tool receipts with model-visible ledger (RFC #4651, layer 1) (#4659)
* 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.
2026-08-23 15:43:37 +08:00

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]