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

173 lines
7.0 KiB
Python

"""Parent-side verification of subagent report citations against tool receipts.
Layer 1 consumption (RFC #4651 PR2): a subagent's final report cites receipt
ids (``[rN]``, optionally anchored ``[rN tool_name]``); this module
cross-checks those citations against the execution record harvested from the
child's own message stream. Pure functions only — no IO, no LLM calls.
Vocabulary layering: the summary boolean is ``citation_resolved``, never
``satisfied``/``verified``/``passed`` — strong-positive words are reserved
for the runtime hard gate so the model never conflates advisory execution
evidence with task acceptance.
Display ids are positional over the ledger shown for one model call. The
receipt middleware stamps that exact ledger on the resulting assistant
message, and terminal harvest uses the snapshot rather than renumbering the
post-compaction tool-message tail. The verdict remains advisory, not a gate.
"""
from __future__ import annotations
import re
from typing import TypedDict
from deerflow.agents.middlewares.tool_receipt import ToolReceipt, parse_citations
VERDICT_SOURCE = "receipt_citations"
VERDICT_REQUIREMENT = "cited_ids_in_execution_record"
#: Zero-citation heuristic: a completed report making action claims with no
#: receipt citations is a weak-negative signal, not a clean bill. Verb list
#: plus path-with-extension patterns; false positives cost one UNVERIFIED
#: line, false negatives let false success through — biased toward sensitive.
_ACTION_VERB_RE = re.compile(
r"\b(wrote|written|created|saved|generated|ran|executed|uploaded|downloaded"
r"|deleted|modified|updated|installed|deployed|fetched|built|compiled"
r"|produced|exported|fixed|added|changed|removed|implemented|patched"
r"|refactored|renamed|moved|merged|committed|edited|replaced|tested"
r"|verified|cleaned|configured)\b",
re.IGNORECASE,
)
#: CJK reports have no word boundaries, so the English list never fires on
#: them; match common action verbs directly instead.
_CJK_ACTION_VERB_RE = re.compile(
r"创建|生成|写入|保存|修改|更新|删除|运行|执行|安装|部署|上传|下载"
r"|修复|添加|新增|编写|编译|构建|导出|测试|提交|移动|重命名|配置|替换|清理"
)
_FILE_PATH_RE = re.compile(r"(?:/[\w.\-]+){2,}|\b[\w.\-]+\.(?:py|md|txt|json|ya?ml|csv|html|js|ts|sh|log|pdf|png|jpe?g)\b")
#: Safety net beyond any verb list: a harvested non-empty receipt ledger means
#: the subagent demonstrably executed tools, so a paragraph-length report that
#: cites none of them is UNVERIFIED regardless of language or phrasing. Short
#: status confirmations stay a vacuous pass.
_NONTRIVIAL_REPORT_MIN_CHARS = 240
#: Anti-automation-bias: model-visible verdict text always states its boundary.
_LIMITATION = "execution evidence only, does not validate claim correctness"
class CitationFailure(TypedDict):
id: str
reason: str
class ReceiptVerdict(TypedDict):
source: str
requirement: str
citation_resolved: bool
cited: list[str]
resolved: list[str]
failed: list[CitationFailure]
unknown: list[str]
no_citation_claims: bool
def _has_action_claims(report_text: str) -> bool:
return bool(_ACTION_VERB_RE.search(report_text) or _CJK_ACTION_VERB_RE.search(report_text) or _FILE_PATH_RE.search(report_text))
def verify_receipt_citations(report_text: str, receipts: list[ToolReceipt]) -> ReceiptVerdict:
"""Cross-check every citation in the report against the harvested ledger."""
by_id = {receipt["id"]: receipt for receipt in receipts}
cited: list[str] = []
resolved: list[str] = []
failed: list[CitationFailure] = []
unknown: list[str] = []
for rid, anchor in parse_citations(report_text):
cited.append(rid)
receipt = by_id.get(rid)
if receipt is None:
unknown.append(rid)
continue
if receipt["status"] != "success":
failed.append({"id": rid, "reason": f"receipt status={receipt['status']}"})
continue
if anchor is not None and anchor != receipt["tool_name"]:
failed.append({"id": rid, "reason": f"anchor mismatch: cited as {anchor}, receipt {rid} is {receipt['tool_name']}"})
continue
resolved.append(rid)
no_citation_claims = not cited and (_has_action_claims(report_text) or bool(receipts) and len(report_text.strip()) >= _NONTRIVIAL_REPORT_MIN_CHARS)
if cited:
citation_resolved = not failed and not unknown
else:
# Claim-free report with nothing to check: vacuous pass, renders nothing.
citation_resolved = not no_citation_claims
return ReceiptVerdict(
source=VERDICT_SOURCE,
requirement=VERDICT_REQUIREMENT,
citation_resolved=citation_resolved,
cited=cited,
resolved=resolved,
failed=failed,
unknown=unknown,
no_citation_claims=no_citation_claims,
)
def validate_receipt_verdict(value: object) -> ReceiptVerdict | None:
"""Structural check for a persisted verdict (read side trusts nothing)."""
if not isinstance(value, dict):
return None
source = value.get("source")
requirement = value.get("requirement")
citation_resolved = value.get("citation_resolved")
no_citation_claims = value.get("no_citation_claims")
if not isinstance(source, str) or not isinstance(requirement, str):
return None
if not isinstance(citation_resolved, bool) or not isinstance(no_citation_claims, bool):
return None
def _str_list(key: str) -> list[str] | None:
items = value.get(key)
if not isinstance(items, list) or any(not isinstance(item, str) for item in items):
return None
return list(items)
cited = _str_list("cited")
resolved = _str_list("resolved")
unknown = _str_list("unknown")
if cited is None or resolved is None or unknown is None:
return None
raw_failed = value.get("failed")
if not isinstance(raw_failed, list):
return None
failed: list[CitationFailure] = []
for entry in raw_failed:
if not isinstance(entry, dict) or not isinstance(entry.get("id"), str) or not isinstance(entry.get("reason"), str):
return None
failed.append({"id": entry["id"], "reason": entry["reason"]})
return ReceiptVerdict(
source=source,
requirement=requirement,
citation_resolved=citation_resolved,
cited=cited,
resolved=resolved,
failed=failed,
unknown=unknown,
no_citation_claims=no_citation_claims,
)
def render_citation_verdict(verdict: ReceiptVerdict) -> str:
"""Render the verdict as the delegation-ledger citation segment."""
if verdict["no_citation_claims"]:
return "citations: UNVERIFIED — action claims without receipt citations"
if not verdict["cited"]:
return ""
parts = [f"{len(verdict['resolved'])} resolved"]
if verdict["failed"]:
parts.append(f"{len(verdict['failed'])} failed")
if verdict["unknown"]:
parts.append(f"{len(verdict['unknown'])} unknown")
return f"citations: {', '.join(parts)}{_LIMITATION}"