mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-23 13:06:20 +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.
188 lines
8.1 KiB
Python
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)
|