Zeren Wang 3b592c2053
feat(harness): subagent receipt citation verification (#5076)
* feat(harness): subagent receipt citation verification

- add receipt citation verification core
- harvest subagent tool receipts at terminal status
- transport subagent receipts and citation verdict via status contract
- verify subagent report citations at task write-back
- render citation verdicts in the delegation ledger

* fix(gateway): strip forged receipt verdicts from the delegations channel

normalize_input() and the checkpoint-state mutation sanitizer only
stripped server-owned metadata from message-shaped values, so an
external caller could submit a delegation entry carrying a forged
receipt_verdict that render_delegation_ledger would present as
runtime-owned execution evidence. Strip receipt_verdict from
caller-supplied delegation entries on both the run-creation and
thread-state mutation paths, with regression coverage for each.

* fix(harness): close silent-pass gaps in the zero-citation heuristic

The action-claim detector missed the most common completion verbs
(fixed/added/tested/changed/...) and had no CJK coverage at all, so
reports like 'I fixed the bug and added tests.' or '我已经创建了文件并运行了测试。'
were treated as claim-free: citation_resolved=True with no ledger
warning. Broaden the verb lists and add a language-independent safety
net: when the run harvested receipts and a nontrivial (>=240 char)
report cites none of them, flag it UNVERIFIED. Short claim-free
confirmations remain a vacuous pass.
2026-08-29 16:56:56 +08:00

264 lines
11 KiB
Python

"""Deterministic tool-call receipts: the zero-LLM verification layer.
Every tool result gets a receipt stamped into ``additional_kwargs`` by
``ToolReceiptMiddleware``. Receipts are *derived* from the message stream
(never stored separately), so rendering for the model and harvesting for the
parent agent always agree. Display ids (``r1..rN``) are positional over the
append-only message list, which keeps them stable across turns — but only
while history stays append-only (see the renumbering caveat below).
Layering contract: a tool receipt is an immutable *fact* record per tool call,
message-carried. It is distinct from the runtime-layer run delivery receipt
(``run.delivery`` event, one per run, event-store-carried) — the two layers
share only the verdict *structure* convention (``source``/``requirement`` +
details); the ``satisfied`` boolean stays exclusive to the runtime hard gate,
and advisory layers use neutral vocabulary (``citation_resolved``,
``supported``) so the model never conflates evidence with acceptance.
Freshness caveat: receipts capture execution truth (the raw tool return,
stamped before sanitization/truncation rewrites content further out the
chain). After compaction, only the sanitized ``content`` survives — so
``output_sha256`` is a *freshness stamp*, not a re-checkable fingerprint
against the persisted message.
Renumbering caveat: compaction/summarization (which long subagent runs use)
drops older ``ToolMessage``s, and since display ids are assigned positionally
in ``extract_tool_receipts``, the surviving receipts renumber — an ``[r3]``
cited before compaction can point at a different tool call (or nothing)
after. Layer 2 citation verification must therefore resolve ``[rN]``
references against the ledger as of the citing turn, not the post-compaction
ledger.
"""
from __future__ import annotations
import hashlib
import json
import re
from datetime import UTC, datetime
from typing import TypedDict
from langchain_core.messages import AIMessage, ToolMessage
from deerflow.agents.middlewares.tool_result_meta import TOOL_META_KEY
TOOL_RECEIPT_KEY = "deerflow_tool_receipt"
TOOL_RECEIPT_LEDGER_KEY = "deerflow_tool_receipt_ledger"
_HASH_LEN = 16
_RENDER_CHAR_BUDGET = 2000
#: Single source of truth for the citation wire format. Id assignment, ledger
#: validation, the model-facing prompt example, and the parent-side verifier
#: all derive from these — the format changes in exactly one place.
RECEIPT_ID_PREFIX = "r"
_MAX_RECEIPT_ID_DIGITS = 10
def receipt_id(position: int) -> str:
"""Display id for the ``position``-th receipt (1-based): ``r1``..``rN``."""
return f"{RECEIPT_ID_PREFIX}{position}"
#: ``[r2]`` bare or ``[r2 write_file]`` anchored. The optional label lets the
#: verifier sanity-check claim-evidence coherence.
CITATION_RE = re.compile(rf"\[{RECEIPT_ID_PREFIX}(\d+)(?:\s+([A-Za-z_][\w.-]*))?\]")
def format_citation(rid: str, tool_name: str | None = None) -> str:
"""Canonical model-facing citation (``[r2]`` bare, ``[r2 write_file]`` anchored)."""
return f"[{rid} {tool_name}]" if tool_name else f"[{rid}]"
def parse_citations(text: str) -> list[tuple[str, str | None]]:
"""Pull ``(id, anchor)`` pairs out of report prose, deduped first-seen."""
seen: set[tuple[str, str | None]] = set()
citations: list[tuple[str, str | None]] = []
for match in CITATION_RE.finditer(text):
digits = match.group(1)
# Model output is untrusted. Bound the decimal string before int()
# so an enormous citation id cannot trip Python's conversion limit and
# turn an otherwise successful task into a verification exception.
if len(digits) > _MAX_RECEIPT_ID_DIGITS:
continue
rid = receipt_id(int(digits))
citation = (rid, match.group(2))
if citation in seen:
continue
seen.add(citation)
citations.append(citation)
return citations
class ToolReceipt(TypedDict):
id: str # display id, assigned by extract_tool_receipts ("r1"..)
tool_call_id: str
tool_name: str
status: str # success | error | partial_success (from deerflow_tool_meta)
args_sha256: str
output_sha256: str
output_bytes: int
created_at: str
def _short_hash(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()[:_HASH_LEN]
def make_tool_receipt(tool_call: dict, message: ToolMessage) -> dict:
"""Build a receipt for one tool call/result pair (no display id yet)."""
args = tool_call.get("args")
args_bytes = json.dumps(args if isinstance(args, dict) else {}, sort_keys=True, default=str).encode("utf-8")
content = message.content if isinstance(message.content, str) else json.dumps(message.content, sort_keys=True, default=str)
meta = (message.additional_kwargs or {}).get(TOOL_META_KEY) or {}
status = str(meta.get("status") or getattr(message, "status", "success") or "success")
return {
"tool_call_id": str(tool_call.get("id") or ""),
"tool_name": str(tool_call.get("name") or ""),
"status": status,
"args_sha256": _short_hash(args_bytes),
"output_sha256": _short_hash(content.encode("utf-8")),
"output_bytes": len(content.encode("utf-8")),
"created_at": datetime.now(UTC).isoformat(),
}
def extract_tool_receipts(messages: list) -> list[ToolReceipt]:
"""Collect stamped receipts in message order, assigning display ids r1..rN.
Receipt dicts come back out of persisted checkpoints, so their shape is
validated before use: a malformed entry (missing/wrongly-typed fields, or
extra keys) is skipped rather than crashing the render path or being
treated as runtime-stamped evidence.
"""
receipts: list[ToolReceipt] = []
for message in messages:
if not isinstance(message, ToolMessage):
continue
receipt = (message.additional_kwargs or {}).get(TOOL_RECEIPT_KEY)
if not is_valid_receipt(receipt):
continue
receipts.append(
ToolReceipt(
id=receipt_id(len(receipts) + 1),
tool_call_id=receipt["tool_call_id"],
tool_name=receipt["tool_name"],
status=receipt["status"],
args_sha256=receipt["args_sha256"],
output_sha256=receipt["output_sha256"],
output_bytes=receipt["output_bytes"],
created_at=receipt["created_at"],
)
)
return receipts
def extract_citing_turn_receipts(messages: list) -> list[ToolReceipt] | None:
"""Return the ledger snapshot shown to the last citing assistant turn.
``ToolReceiptMiddleware`` stamps this runtime-owned snapshot on each model
response that received a receipt ledger. Unlike a terminal re-scan of tool
messages, these positional display ids remain the ids the response could
actually cite even if summarization later compacts and renumbers history.
"""
for message in reversed(messages):
if not isinstance(message, AIMessage):
continue
raw_ledger = (message.additional_kwargs or {}).get(TOOL_RECEIPT_LEDGER_KEY)
if raw_ledger is None:
continue
if not isinstance(raw_ledger, list):
return None
receipts: list[ToolReceipt] = []
first_position: int | None = None
for index, receipt in enumerate(raw_ledger):
if not is_valid_receipt(receipt):
return None
rid = receipt.get("id")
match = re.fullmatch(rf"{re.escape(RECEIPT_ID_PREFIX)}([1-9]\d*)", rid) if isinstance(rid, str) else None
if match is None:
return None
if first_position is None:
first_position = int(match.group(1))
if rid != receipt_id(first_position + index):
return None
receipts.append(
ToolReceipt(
id=receipt["id"],
tool_call_id=receipt["tool_call_id"],
tool_name=receipt["tool_name"],
status=receipt["status"],
args_sha256=receipt["args_sha256"],
output_sha256=receipt["output_sha256"],
output_bytes=receipt["output_bytes"],
created_at=receipt["created_at"],
)
)
return receipts
return None
_RECEIPT_STR_FIELDS = ("tool_call_id", "tool_name", "status", "args_sha256", "output_sha256", "created_at")
def is_valid_receipt(receipt: object) -> bool:
"""Structural check for a persisted receipt (types only, not provenance)."""
if not isinstance(receipt, dict):
return False
if any(not isinstance(receipt.get(field), str) for field in _RECEIPT_STR_FIELDS):
return False
output_bytes = receipt.get("output_bytes")
return isinstance(output_bytes, int) and not isinstance(output_bytes, bool)
def render_tool_receipts_with_snapshot(
receipts: list[ToolReceipt],
*,
max_chars: int = _RENDER_CHAR_BUDGET,
) -> tuple[str, list[ToolReceipt]]:
"""Render a ledger and return the exact receipt subset visible in it.
The retained receipts keep their original display ids. Callers that
persist a citing-turn ledger must use this snapshot rather than the full
input list, otherwise a citation could resolve against an entry omitted by
the model context budget.
"""
if not receipts:
return "", []
lines = [
"## Tool receipts (execution record)",
# The example is generated from format_citation/parse_citations' shared
# format so the instruction can never drift from the verifier.
f"Cite receipt ids (e.g. {format_citation(receipt_id(1), 'write_file')}) in your final report for every claim about an action you took.",
# Anti-automation-bias (design rule 4): the ledger always states its
# evidence boundary so the model never reads provenance as endorsement.
"Execution evidence only — receipts record that a call happened and its status; they do not validate claim correctness or task acceptance.",
]
receipt_lines = [f"- [{receipt['id']}] {receipt['tool_name']} status={receipt['status']} args_sha256={receipt['args_sha256']} output_sha256={receipt['output_sha256']} bytes={receipt['output_bytes']}" for receipt in receipts]
if len("\n".join([*lines, *receipt_lines])) <= max_chars:
lines.extend(receipt_lines)
retained_receipts = receipts
else:
omission = "- ... older receipts omitted (context budget)"
retained: list[str] = []
retained_count = 0
for line in reversed(receipt_lines):
candidate = [*lines, omission, line, *retained]
if len("\n".join(candidate)) > max_chars:
break
retained.insert(0, line)
retained_count += 1
lines.extend([omission, *retained])
retained_receipts = receipts[-retained_count:] if retained_count else []
rendered = "\n".join(lines)
if len(rendered) > max_chars:
rendered = rendered[: max(0, max_chars - 4)] + "\n..."
retained_receipts = []
return rendered, retained_receipts
def render_tool_receipts(receipts: list[ToolReceipt], *, max_chars: int = _RENDER_CHAR_BUDGET) -> str:
"""Render the receipt ledger as model-visible context (empty -> "")."""
rendered, _ = render_tool_receipts_with_snapshot(receipts, max_chars=max_chars)
return rendered