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

317 lines
15 KiB
Python

"""Backend↔frontend contract for structured subagent result metadata.
``task`` tool result text is model-visible display content. Runtime
consumers read the structured facts carried inside
``ToolMessage.additional_kwargs``:
- ``subagent_status``: one of ``SUBAGENT_STATUS_VALUES``.
- ``subagent_stop_reason`` (optional): when a guardrail cap ended the run
early, one of ``SUBAGENT_STOP_REASON_VALUES`` (``token_capped`` /
``turn_capped`` / ``loop_capped``). Additive (#3875 Phase 2): a capped run
that still produced a final answer stays ``status=completed`` and carries
the cap here; a capped run with no usable output is ``status=failed`` +
``stop_reason``. Old frontends ignore the unknown field.
- ``subagent_error`` (optional): the human-readable error blob the
backend recorded.
- ``subagent_result_brief`` / ``subagent_result_sha256`` (optional):
bounded completed-result metadata plus a digest of the full result.
- ``subagent_model_name`` (optional): effective DeerFlow model identifier used
by this delegated run.
- ``subagent_token_usage`` (optional): final cumulative ``input_tokens`` /
``output_tokens`` / ``total_tokens`` snapshot when the provider reported it.
- ``subagent_tool_receipts`` (optional): the child's harvested tool receipts
(RFC #4651 PR2), transported in full; present on terminal statuses when
the run produced stamped receipts.
- ``subagent_receipt_verdict`` (optional, ``completed`` only): the
parent-side citation-check verdict — advisory execution evidence; the
``citation_resolved`` vocabulary never claims task acceptance.
The shared fixture at ``contracts/subagent_status_contract.json`` pins
the enum values across Python and TypeScript.
"""
from __future__ import annotations
import hashlib
import re
from collections.abc import Mapping
from typing import Any, Literal, NotRequired, TypedDict
from deerflow.agents.middlewares.receipt_verification import ReceiptVerdict, validate_receipt_verdict
from deerflow.agents.middlewares.tool_receipt import is_valid_receipt
SUBAGENT_STATUS_KEY = "subagent_status"
SUBAGENT_STOP_REASON_KEY = "subagent_stop_reason"
SUBAGENT_ERROR_KEY = "subagent_error"
SUBAGENT_RESULT_BRIEF_KEY = "subagent_result_brief"
SUBAGENT_RESULT_SHA256_KEY = "subagent_result_sha256"
SUBAGENT_MODEL_NAME_KEY = "subagent_model_name"
SUBAGENT_TOKEN_USAGE_KEY = "subagent_token_usage"
SUBAGENT_TOOL_RECEIPTS_KEY = "subagent_tool_receipts"
SUBAGENT_RECEIPT_VERDICT_KEY = "subagent_receipt_verdict"
SUBAGENT_METADATA_TEXT_MAX_CHARS = 2000
#: The producer always emits ``hashlib.sha256(...).hexdigest()`` — 64
#: lowercase hex chars. Readers enforce the same shape so a corrupted
#: relay value cannot masquerade as a digest.
_SHA256_HEX_RE = re.compile(r"[0-9a-f]{64}")
SubagentStatusValue = Literal[
"completed",
"failed",
"cancelled",
"timed_out",
"polling_timed_out",
]
#: Enumeration of every value ``subagent_status`` may take. Mirrors the
#: ``valid_status_values`` array in the shared fixture; the contract test
#: pins them against each other. Capped runs do NOT get their own status
#: value (#3875 Phase 2): a cap that still produced output is ``completed``
#: and a cap with no output is ``failed``, with the reason carried on the
#: additive ``subagent_stop_reason`` field so old consumers keep working.
SUBAGENT_STATUS_VALUES: tuple[SubagentStatusValue, ...] = (
"completed",
"failed",
"cancelled",
"timed_out",
"polling_timed_out",
)
#: Why a guardrail cap ended a run early. Carried on the additive
#: ``subagent_stop_reason`` field, never as a status enum value.
SubagentStopReasonValue = Literal["token_capped", "turn_capped", "loop_capped"]
SUBAGENT_STOP_REASON_VALUES: tuple[SubagentStopReasonValue, ...] = (
"token_capped",
"turn_capped",
"loop_capped",
)
#: Human-readable label folded into the model-visible result text when a cap
#: fired, e.g. ``Task Succeeded (capped: token budget). Result: ...``.
_STOP_REASON_LABELS: dict[SubagentStopReasonValue, str] = {
"token_capped": "token budget",
"turn_capped": "turn budget",
"loop_capped": "repeated tool-call loop",
}
#: Statuses that carry a recoverable result in ``subagent_result_brief`` /
#: ``subagent_result_sha256``. Only ``completed`` — and a capped run that
#: produced usable partial work surfaces as ``completed`` (+ ``stop_reason``),
#: so its work survives on the wire the same way a clean success does. Other
#: non-completed statuses carry only ``subagent_error``.
_RESULT_BEARING_STATUSES: frozenset[SubagentStatusValue] = frozenset({"completed"})
#: Read-side normalization for status values that previously appeared in
#: checkpointed thread history but are no longer produced. ``max_turns_reached``
#: was emitted by Phase 1 (#3949) and lives in persisted
#: ``ToolMessage.additional_kwargs``; #3980 removed it from the producer and the
#: contract fixture, but the reader still maps it to its Phase 2 cap equivalent
#: so historical data resolves terminally (with the cap on ``stop_reason``)
#: instead of stranding as ``in_progress`` in the delegation ledger. The frontend
#: ``subtask-result.ts`` keeps a parallel deprecated alias for the same reason.
_LEGACY_STATUS_NORMALIZATION: dict[str, SubagentStopReasonValue] = {
"max_turns_reached": "turn_capped",
}
class StructuredSubagentResult(TypedDict):
status: SubagentStatusValue
stop_reason: NotRequired[SubagentStopReasonValue]
result_brief: NotRequired[str]
result_sha256: NotRequired[str]
error: NotRequired[str]
tool_receipts: NotRequired[list[dict[str, Any]]]
receipt_verdict: NotRequired[ReceiptVerdict]
def _bound_metadata_text(text: str, cap: int = SUBAGENT_METADATA_TEXT_MAX_CHARS) -> str:
cleaned = text.strip()
if len(cleaned) <= cap:
return cleaned
marker = "\n...\n"
if cap <= len(marker):
return cleaned[:cap]
head = cap * 2 // 3
tail = cap - head - len(marker)
if tail <= 0:
return cleaned[:cap]
return f"{cleaned[:head]}{marker}{cleaned[-tail:]}"
def make_subagent_additional_kwargs(
status: SubagentStatusValue,
*,
result: str | None = None,
error: str | None = None,
stop_reason: SubagentStopReasonValue | None = None,
model_name: str | None = None,
token_usage: Mapping[str, object] | None = None,
tool_receipts: list[dict[str, Any]] | None = None,
receipt_verdict: Mapping[str, object] | None = None,
) -> dict[str, object]:
"""Build the ``additional_kwargs`` payload the middleware stamps.
Drops the error field when blank so the JSON wire format never carries
a misleading empty ``subagent_error: ""``. ``stop_reason`` is stamped
only when a guardrail cap ended the run (see :data:`SUBAGENT_STOP_REASON_VALUES`).
Raises:
ValueError: when ``status`` is not in :data:`SUBAGENT_STATUS_VALUES`,
or ``stop_reason`` is not in :data:`SUBAGENT_STOP_REASON_VALUES`.
We do not accept arbitrary strings: a typo would silently leak
through to consumers as missing metadata rather than failing
loudly at the producer boundary.
"""
if status not in SUBAGENT_STATUS_VALUES:
raise ValueError(f"invalid subagent status {status!r}; expected one of {SUBAGENT_STATUS_VALUES}")
if stop_reason is not None and stop_reason not in SUBAGENT_STOP_REASON_VALUES:
raise ValueError(f"invalid subagent stop_reason {stop_reason!r}; expected one of {SUBAGENT_STOP_REASON_VALUES}")
payload: dict[str, object] = {SUBAGENT_STATUS_KEY: status}
if status in _RESULT_BEARING_STATUSES and isinstance(result, str) and result.strip():
payload[SUBAGENT_RESULT_BRIEF_KEY] = _bound_metadata_text(result)
payload[SUBAGENT_RESULT_SHA256_KEY] = hashlib.sha256(result.encode("utf-8")).hexdigest()
# Only ``completed`` (a clean success, or a capped run whose partial work
# survived) suppresses the error blob; every other status carries it.
if status != "completed" and isinstance(error, str) and error.strip():
payload[SUBAGENT_ERROR_KEY] = _bound_metadata_text(error)
if stop_reason is not None:
payload[SUBAGENT_STOP_REASON_KEY] = stop_reason
if isinstance(model_name, str) and model_name.strip():
payload[SUBAGENT_MODEL_NAME_KEY] = model_name.strip()
normalized_usage = normalize_token_usage(token_usage)
if normalized_usage is not None:
payload[SUBAGENT_TOKEN_USAGE_KEY] = normalized_usage
if isinstance(tool_receipts, list):
cleaned_receipts = [dict(receipt) for receipt in tool_receipts if is_valid_receipt(receipt)]
if cleaned_receipts:
payload[SUBAGENT_TOOL_RECEIPTS_KEY] = cleaned_receipts
validated_verdict = validate_receipt_verdict(receipt_verdict)
if validated_verdict is not None:
payload[SUBAGENT_RECEIPT_VERDICT_KEY] = validated_verdict
return payload
def normalize_token_usage(value: Any) -> dict[str, int] | None:
"""Validate a cumulative token-usage mapping into the contract shape.
The single shared validator for both metadata surfaces — the terminal
``ToolMessage`` metadata (here) and the persisted ``subagent.step`` /
``subagent.end`` run events (``step_events.py``). Keeping one function
prevents the two from drifting (e.g. one later accepting an extra token
field the other rejects, silently dropping usage on one path). Requires
non-negative ``int`` values for all three keys — ``bool`` is rejected — and
returns ``None`` for any non-mapping or malformed input.
"""
if not isinstance(value, Mapping):
return None
normalized: dict[str, int] = {}
for key in ("input_tokens", "output_tokens", "total_tokens"):
amount = value.get(key)
if isinstance(amount, bool) or not isinstance(amount, int) or amount < 0:
return None
normalized[key] = amount
return normalized
def format_subagent_result_message(
status: SubagentStatusValue,
*,
result: str | None = None,
error: str | None = None,
stop_reason: SubagentStopReasonValue | None = None,
) -> tuple[str, str | None]:
"""Return model-visible task content plus normalized metadata error.
When ``stop_reason`` is set, a short ``(capped: ...)`` note is folded into
the text so the lead agent sees — without parsing metadata — that the run
was ended by a guardrail cap. A capped run that produced usable work is
``status=completed`` (+ the partial result); a capped run with no usable
output is ``status=failed``.
"""
result_text = "" if result is None else str(result)
error_text = str(error).strip() if isinstance(error, str) else ""
capped = _STOP_REASON_LABELS.get(stop_reason) if stop_reason is not None else None
if status == "completed":
if capped:
return f"Task Succeeded (capped: {capped}). Result: {result_text}", None
return f"Task Succeeded. Result: {result_text}", None
if status == "cancelled":
detail = error_text or "Task cancelled by user."
if detail == "Task cancelled by user.":
return detail, detail
return f"Task cancelled by user. Error: {detail}", detail
if status == "timed_out":
detail = error_text or "Task timed out."
if detail == "Task timed out.":
return detail, detail
return f"Task timed out. Error: {detail}", detail
if status == "polling_timed_out":
detail = error_text or "Task polling timed out."
return detail, detail
# ``failed`` — including a turn-capped run that produced no usable output
# (``stop_reason=turn_capped``): the cap note is folded in so the lead can
# tell a broken subagent from one that simply ran out of turn budget.
detail = error_text or "Task failed."
if capped:
if detail == "Task failed.":
return f"Task failed (capped: {capped}).", detail
return f"Task failed (capped: {capped}). Error: {detail}", detail
if detail == "Task failed.":
return detail, detail
return f"Task failed. Error: {detail}", detail
def read_subagent_result_metadata(
additional_kwargs: Mapping[str, object] | None,
) -> StructuredSubagentResult | None:
if not additional_kwargs:
return None
raw_status = additional_kwargs.get(SUBAGENT_STATUS_KEY)
# Legacy checkpointed values (#3949) are no longer produced (#3980) but
# survive in persisted history. Normalize them before the validity check so
# they resolve terminally instead of returning ``None`` (which would strand
# the delegation entry as ``in_progress``). A legacy ``max_turns_reached``
# carried a recovered partial, so a payload that still has ``result_brief``
# maps to the Phase 2 ``completed + turn_capped`` shape (partial survives on
# the wire); one with no result maps to ``failed + turn_capped``.
legacy_stop_reason = _LEGACY_STATUS_NORMALIZATION.get(raw_status) if isinstance(raw_status, str) else None
if legacy_stop_reason is not None:
raw_result_brief = additional_kwargs.get(SUBAGENT_RESULT_BRIEF_KEY)
status = "completed" if (isinstance(raw_result_brief, str) and raw_result_brief.strip()) else "failed"
elif raw_status in SUBAGENT_STATUS_VALUES:
status = raw_status
else:
return None
payload: StructuredSubagentResult = {"status": status}
raw_result = additional_kwargs.get(SUBAGENT_RESULT_BRIEF_KEY)
raw_hash = additional_kwargs.get(SUBAGENT_RESULT_SHA256_KEY)
raw_error = additional_kwargs.get(SUBAGENT_ERROR_KEY)
if status in _RESULT_BEARING_STATUSES and isinstance(raw_result, str) and raw_result.strip():
payload["result_brief"] = _bound_metadata_text(raw_result)
if isinstance(raw_hash, str) and _SHA256_HEX_RE.fullmatch(raw_hash):
payload["result_sha256"] = raw_hash
if status != "completed" and isinstance(raw_error, str) and raw_error.strip():
payload["error"] = _bound_metadata_text(raw_error)
# An explicit stop_reason on the wire wins; else the synthesized legacy reason.
raw_stop_reason = additional_kwargs.get(SUBAGENT_STOP_REASON_KEY)
if isinstance(raw_stop_reason, str) and raw_stop_reason in SUBAGENT_STOP_REASON_VALUES:
payload["stop_reason"] = raw_stop_reason
elif legacy_stop_reason is not None:
payload["stop_reason"] = legacy_stop_reason
raw_receipts = additional_kwargs.get(SUBAGENT_TOOL_RECEIPTS_KEY)
if isinstance(raw_receipts, list):
cleaned_receipts = [dict(receipt) for receipt in raw_receipts if is_valid_receipt(receipt)]
if cleaned_receipts:
payload["tool_receipts"] = cleaned_receipts
validated_verdict = validate_receipt_verdict(additional_kwargs.get(SUBAGENT_RECEIPT_VERDICT_KEY))
if validated_verdict is not None:
payload["receipt_verdict"] = validated_verdict
return payload