Tsai Yuan 03c95f5278
fix(subagents): report model output length caps (#7355)
* fix(subagents): report model_length_capped when a subagent hits the output limit

ModelLengthFinishReasonMiddleware only ran on the lead chain. A subagent
whose response was cut at the provider output limit (finish_reason=length,
stop_reason=max_tokens, MAX_TOKENS, Responses max_output_tokens) reached
the lead as a clean `completed`, and a tool call truncated at that
boundary still executed with partial arguments.

Register the same middleware on the subagent chain, in the lead's
relative position (after configured extensions, before Safety). It now
also records each detected cap per context run_id and exposes
`consume_stop_reason`, so the executor's existing duck-typed guard
collection reports `stop_reason=model_length_capped`. Add the value to
the subagent status contract (Python enum, contract fixture, frontend
whitelist) with the label "model output length limit", and update the
subagent docs.

Tests cover the middleware channel, chain placement, the contract, and
an end-to-end executor run with a fake model.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* test(tasks): cover cap reasons on completed and failed runs

Extend the existing shared-contract round-trip matrix to preserve both terminal statuses alongside every supported stop reason, including model_length_capped and loop_capped.

---------

Co-authored-by: huangzz <110116@nd.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-23 21:42:11 +08:00

337 lines
16 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`` / ``model_length_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.
- ``subagent_acceptance_verdict`` (optional, ``completed`` only, RFC #4651
PR4): the deterministic acceptance-checklist verdict — per-criterion
``checked``/``holds`` leaves; unchecked criteria render UNVERIFIED, never
silently passed.
The shared fixture at ``contracts/subagent_status_contract.json`` pins
the enum values (``valid_status_values`` / ``valid_stop_reason_values``)
across Python and TypeScript. ``subagent_acceptance_verdict`` is
deliberately outside that fixture: it is a validated JSON structure (see
``validate_acceptance_verdict``), not an enum vocabulary, and no
TypeScript consumer reads it.
"""
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
from deerflow.subagents.acceptance_checks import AcceptanceVerdict, validate_acceptance_verdict
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_ACCEPTANCE_VERDICT_KEY = "subagent_acceptance_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", "model_length_capped"]
SUBAGENT_STOP_REASON_VALUES: tuple[SubagentStopReasonValue, ...] = (
"token_capped",
"turn_capped",
"loop_capped",
"model_length_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",
"model_length_capped": "model output length limit",
}
#: 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]
acceptance_verdict: NotRequired[AcceptanceVerdict]
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,
acceptance_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
validated_acceptance = validate_acceptance_verdict(acceptance_verdict)
if validated_acceptance is not None:
payload[SUBAGENT_ACCEPTANCE_VERDICT_KEY] = validated_acceptance
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
validated_acceptance = validate_acceptance_verdict(additional_kwargs.get(SUBAGENT_ACCEPTANCE_VERDICT_KEY))
if validated_acceptance is not None:
payload["acceptance_verdict"] = validated_acceptance
return payload