mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 02:56:17 +00:00
* 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.
264 lines
11 KiB
Python
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
|