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

188 lines
8.1 KiB
Python

"""Stamp deterministic tool receipts and render the receipt ledger to the model.
Ordering contract (enforced by the build-time constraints in
``deerflow.extensions.ordering.core_ordering_constraints``): this is the
outermost ``wrap_tool_call`` layer — Guardrail, SandboxAudit, ReadBeforeWrite,
and ToolProgress can short-circuit or rebuild results, and an inner receipt
layer would silently gap the ledger on those. Normal results still carry a
normalized ``deerflow_tool_meta`` status when stamped (ToolErrorHandling runs
on the inner return path); short-circuit messages either self-stamp the meta
or fall back to ``message.status`` in ``make_tool_receipt``.
The ledger injection mirrors DurableContextMiddleware: derived from the
in-flight messages on every model call, appended as a hidden HumanMessage,
never written back to state.
"""
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable
from typing import override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.types import Command
from deerflow.agents.middlewares.message_utils import insert_after_leading_system_messages, is_genuine_user_message
from deerflow.agents.middlewares.tool_receipt import (
TOOL_RECEIPT_KEY,
TOOL_RECEIPT_LEDGER_KEY,
ToolReceipt,
extract_tool_receipts,
make_tool_receipt,
render_tool_receipts_with_snapshot,
)
logger = logging.getLogger(__name__)
_RECEIPT_CONTEXT_KEY = "deerflow_tool_receipt_context"
class ToolReceiptMiddleware(AgentMiddleware[AgentState]):
"""Receipt layer: zero-LLM provenance for every tool call.
render_mode: 'always' renders the ledger on every model call (subagent
chains — citations are produced there; without the ledger the subagent
cannot cite and Layer 1 goes inert). 'delegation_only' renders only when
the message stream contains a completed subagent result (lead chain —
the one place the lead needs citation context), avoiding the always-on
token tax in ordinary conversation turns.
"""
state_schema = AgentState
def __init__(self, *, render_mode: str = "always") -> None:
super().__init__()
if render_mode not in {"always", "delegation_only"}:
raise ValueError(f"Unknown render_mode: {render_mode}")
self._render_mode = render_mode
def _stamp_message(self, message: ToolMessage, request: ToolCallRequest) -> None:
try:
kwargs = dict(message.additional_kwargs or {})
# The receipt key is runtime-owned: always overwrite, never preserve
# a pre-existing value — a tool could otherwise forge its own
# "evidence" and have it rendered as runtime-stamped provenance.
kwargs[TOOL_RECEIPT_KEY] = make_tool_receipt(request.tool_call, message)
message.additional_kwargs = kwargs
except Exception:
# Never block tool execution — but a systematic stamping failure must
# be visible, or the ledger silently goes incomplete and citations lie.
logger.warning("Failed to stamp tool receipt", exc_info=True)
def _stamp(self, result: ToolMessage | Command, request: ToolCallRequest) -> ToolMessage | Command:
if isinstance(result, ToolMessage):
self._stamp_message(result, request)
return result
update = result.update
if not isinstance(update, dict):
return result
messages = update.get("messages", [])
if isinstance(messages, ToolMessage):
messages = [messages]
if not isinstance(messages, (list, tuple)):
return result
tool_call_id = str(request.tool_call.get("id") or "")
for message in messages:
if isinstance(message, ToolMessage) and str(message.tool_call_id) == tool_call_id:
self._stamp_message(message, request)
return result
@override
def wrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
return self._stamp(handler(request), request)
@override
async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
) -> ToolMessage | Command:
return self._stamp(await handler(request), request)
def _should_render(self, request: ModelRequest) -> bool:
if self._render_mode == "always":
return True
# delegation_only: render only while a subagent result is being
# processed (a task ToolMessage carries subagent_status in its
# additional_kwargs — see subagents/status_contract.py). Scoped to the
# current turn: only messages after the latest genuine user message
# count, otherwise one completed delegation would keep the ledger
# rendering on every later ordinary turn and defeat the token saving.
# Without any genuine user message there is no turn boundary (e.g.
# scheduled/internal invocations), so the whole stream is in scope.
messages = list(request.messages)
latest_user_index = -1
for index, message in enumerate(messages):
if is_genuine_user_message(message):
latest_user_index = index
turn_messages = messages[latest_user_index + 1 :] if latest_user_index >= 0 else messages
for message in turn_messages:
if isinstance(message, ToolMessage) and (message.additional_kwargs or {}).get("subagent_status"):
return True
return False
def _inject(self, request: ModelRequest, ledger: str) -> ModelRequest:
if not ledger:
return request
ledger_message = HumanMessage(
content=ledger,
additional_kwargs={"hide_from_ui": True, _RECEIPT_CONTEXT_KEY: True},
)
messages = insert_after_leading_system_messages(list(request.messages), [ledger_message])
return request.override(messages=messages)
def _prepare_model_call(self, request: ModelRequest) -> tuple[ModelRequest, list[ToolReceipt] | None]:
if not self._should_render(request):
return request, None
receipts = extract_tool_receipts(list(request.messages))
ledger, rendered_receipts = render_tool_receipts_with_snapshot(receipts)
return self._inject(request, ledger), rendered_receipts
@staticmethod
def _stamp_citing_ledger(result: ModelCallResult, receipts: list[ToolReceipt] | None) -> ModelCallResult:
if receipts is None:
return result
if isinstance(result, AIMessage):
messages = [result]
else:
response = getattr(result, "model_response", result)
messages = getattr(response, "result", [])
for message in messages:
if not isinstance(message, AIMessage):
continue
kwargs = dict(message.additional_kwargs or {})
# Runtime-owned and always overwritten so provider output cannot
# forge the ledger against which its citations will be checked.
kwargs[TOOL_RECEIPT_LEDGER_KEY] = [dict(receipt) for receipt in receipts]
message.additional_kwargs = kwargs
return result
@override
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelCallResult:
prepared, receipts = self._prepare_model_call(request)
return self._stamp_citing_ledger(handler(prepared), receipts)
@override
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelCallResult:
prepared, receipts = self._prepare_model_call(request)
return self._stamp_citing_ledger(await handler(prepared), receipts)