mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-16 09:38:41 +00:00
* feat(harness): deterministic acceptance checklist for subagent delegations (RFC #4651, layer 2) PR4 of RFC #4651: check lead-supplied acceptance_criteria in code when a subagent completes, so objectively checkable requirements can never be silently passed by a self-report. - subagents/acceptance_checks.py: deterministic leaf families — file:<path> exists|non-empty and file_written:<path> read through read_current_file_content scoped to the shared thread workspace; the read uses the sandbox-native virtual path form (the local read validator and provider mount tables resolve /mnt/user-data/... paths, not host paths); the scope decision canonicalizes with realpath on the local sandbox so workspace symlinks cannot escape into uploads; a remote provider's "Error: ..." return string is normalized to a failed check (provider-typed via is_local_sandbox); a UnicodeDecodeError marks a binary deliverable as existing and non-empty; out-of-scope paths degrade to UNVERIFIED. tests_passed:<command> anchors to a matching recorded bash execution with status=success and a test-summary shape; matching is shell-structure aware with control-flow attribution (span must end at the last segment with provable execution), negating-option values are ineligible evidence and a target negated anywhere in the command degrades the match, extra flags must be selection-preserving, extra positionals widen only after a path-scoped criterion, truncated commands degrade via command_truncated, the summary shape is read only from output attributable to the matched segment (preceding segments provably silent by invocation form), and pass shapes require a nonzero passed count. Criterion text is neutralized with neutralize_untrusted_tags before storage/rendering. Anything else renders UNVERIFIED, never silently passed. - executor: accumulate bounded bash command/output evidence per streamed chunk (merged by tool_call_id, newest-capped) so subagent summarization compacting earlier messages cannot erase a recorded execution; the recorded status is the actual shell exit status parsed from the output's exit marker (signed codes included; the remote Command exited with code N form is accepted only as the whole trimmed output), falling back to deerflow_tool_meta only when no marker exists. - sandbox providers: e2b/opensandbox/tenki/boxlite append the LocalSandbox-style "Exit Code: N" marker on nonzero exit even with non-empty output; aio propagates the SDK's structured exit_code on both exec paths the same way; local timeouts append Exit Code: 124; and _truncate_bash_output always preserves a trailing exit marker (signed included) inside its budget, with a 32-char floor raising any smaller configured limit, so the actual shell outcome always survives in the output text. - task_tool: run the checklist offloaded (asyncio.to_thread) on the completed branch, failure-isolated; stamp the verdict into result metadata and render the per-criterion section into the model-visible result text. - status contract: additive subagent_acceptance_verdict transport with read-side structural validation. - delegation ledger: entry carries the verdict and renders a compact acceptance segment; gateway strips caller-forged verdicts from both ledger entries and message metadata, like the citation verdict. - blocking-IO anchor pins the offload (teeth proven red->green); leaf read errors catch only OSError/SandboxError so unexpected errors reach the task-tool-level isolation instead of being mislabeled. * fix(harness): close acceptance evidence gaps from review (RFC #4651 PR4) - negating options: overlap with a matched criterion target is now checked by path/nodeid prefix, not exact token equality — excluding a sub-path of the criterion's selection (pytest tests --deselect tests/unit/test_auth.py) degrades to UNVERIFIED instead of holds - output attribution: any redirection token in the matched final segment makes the recorded tail non-attributable (> / >> / 2> are word characters to the parser, so redirection was invisible to the matcher) - silent-source allowlist narrowed from any *activate suffix to the */bin/activate shape - status_contract docstring: restore the shared-fixture sentence and note subagent_acceptance_verdict is deliberately outside the fixture - executor: update_bash_executions publishes [] (stream carried no bash-family calls) instead of collapsing it into None, mirroring update_tool_receipts * fix(harness): close acceptance residual gaps from re-review (RFC #4651 PR4) - tests_passed: add error outcomes to the fail shapes — "4 passed, 1 error" and pytest's "ERROR <nodeid>" short summary no longer satisfy the pass shape when the exit status is swallowed (|| true) or absent; zero-error counts stay clean. - file leaves: bound the deliverable read — a "wc -c" shell size probe answers files above 50k bytes without loading ~2x their size, honoring the host-bash kill switch and falling back to the full read on any non-integer rendering, so verdicts never get less sound. - executor: record the exit marker text as status_marker on harvested bash evidence; the leaf detail now reports the marker actually seen instead of asserting a failure indistinguishable from the command's own trailing text. - extend the blocking-IO anchor to drive the probe branch inside the offload; teeth re-verified red->green. * fix(harness): close acceptance forgery and bound gaps from P2 re-review (RFC #4651 PR4) - file leaves: never read unbounded — size is established first (os.stat on the validated local host path, so the host-bash-disabled configuration needs no shell; a guarded wc -c on remote providers that renders missing/unreadable in its own words). Above the 50k cap the leaf answers from the size alone, at/below it the full read runs, and an unestablishable size degrades to UNVERIFIED instead of an unlimited fallback read. - output attribution: source/. prefixes are never provably silent — a crafted */bin/activate path shape says nothing about what the script prints, so sourced segments can no longer lend a passing summary. - executable identity: an explicitly path-spelled criterion now requires the same normalized executable path; the basename rule stays only for deliberately bare criterion commands. * fix(harness): run acceptance size probe outside subagent-controlled state (RFC #4651 PR4) - remote probe no longer runs in the sandbox's persistent shell: a fresh env -i /bin/sh with absolute-path stat/realpath (poisoned functions, aliases, PATH, exported functions, IFS, locale cannot steer it), plus a marker env routing AIO onto a fresh per-call bash.exec session. - metadata-only: stat never opens content, so a FIFO deliverable cannot block the parent for the provider's idle timeout; non-regular files (fifo/dir/symlink) degrade to UNVERIFIED. - containment canonicalized against the literal mount root: a final-component symlink or a swapped parent directory (root included) cannot redirect the check outside shared storage; unprovable layouts degrade to UNVERIFIED. * fix(harness): canonicalize probe containment against the canonical mount root (RFC #4651 PR4) Literal-root equality made every remote file leaf permanently UNVERIFIED on e2b and Tenki, which realize /mnt/user-data as a symlink to the home dir by default (e2b bootstrap 'sudo ln -sfn', Tenki best-effort symlink). Containment now compares the file's realpath against the mount root's realpath — exactly what the provider's own read path resolves, so probe and read-back stay consistent; final-component symlinks stay rejected by the non-dereferencing stat, and an intermediate dir-link escape under a sane root still lands ESCAPED. The inner script is a module constant and the suite now executes the composed probe for real against on-disk layouts (real dir, symlinked prefix, final symlink, fifo, missing, dir-link escape), which the canned-output stub could not see. * fix(harness): close bare-criterion negation and CDPATH summary channels (RFC #4651 PR4) - matching: a criterion with no positional selection target (bare pytest, make test) stands for the runner's default selection, so ANY negating option (--ignore/--deselect/...) makes the recorded run a different selection — unprovable. The overlap guard only sees consumed criterion tokens, which a bare criterion does not have; scoped criteria keep the unrelated-exclusion behavior. - attribution: cd is no longer blanket-silent — CDPATH makes cd print the resolved (subagent-chosen) destination and the pass shapes match as substrings, so one mkdir 'all tests passed' plus an export minted a pass for any quiet command. A cd argument or CDPATH= value (export or leading assignment) carrying any summary shape makes the segment non-silent; shape-free cd dir wrappers keep matching. - docs: _truncate_bash_output states the effective 32-char floor (the guarantee previously read as an unconditional max_chars bound). * fix(harness): close env-assignment and expansion channels in acceptance matching (RFC #4651 PR4) Self-audit in the shape of the last review rounds — channels the matcher classified as accounted-for that can change what runs, narrow the selection, or lend the summary text: - env assignments are no longer blanket-stripped: only an allowlist of inert display/CI knobs (CI, NO_COLOR, PY_COLORS, ...) may prefix a matched span, and a non-allowlisted assignment in any preceding segment (pure-assignment or export NAME=) is state pollution — PATH redirects the executable, LD_PRELOAD/PYTHONPATH/NODE_OPTIONS inject code, PYTEST_ADDOPTS/GOFLAGS/MAKEFILES inject selection-changing inputs, BASH_ENV runs arbitrary shell startup. All degrade to unprovable. - runtime expansions: any span token carrying /$( )/backticks, any negating-option value carrying an expansion or glob (unknown excluded set), and any extra executed token carrying glob metacharacters (crafted option-looking filenames narrow invisibly) are unprovable. Criterion-side globs stay self-consistent (literal match). - cd: an argument carrying a runtime expansion or glob is non-silent (unknown destination, unknown print); CDPATH= assignments are now handled as state pollution at the match layer, subsuming the value-shape special case. * fix(harness): persistent-shell evidence, exact env sets, option-arity scoping (RFC #4651 PR4) - tests_passed: on a persistent-shell provider (new Sandbox.persistent_shell_sessions capability, set by AioSandbox) every leaf degrades to UNVERIFIED — any earlier call in the shared session could have mutated the state the clean-looking run executed in, and only a fresh controlled session (RFC section 6 verifier) can prove otherwise. The flag is read from the provider registry without acquiring a sandbox. - env assignments: the allowlist is gone — no variable is provably inert across repositories (CI/DEBUG are routinely read by tests). The span's assignment prefix must equal the criterion's exactly (values included, order-insensitive); any assignment or export NAME= in a preceding segment is state pollution. - scoping: positional targets are now read by option arity, so a path embedded in an option (--basetemp=/tmp/p, --junitxml=/tmp/r.xml) never counts as a selection target and an extra positional after such a criterion narrows the default selection it denotes. * fix(harness): stamp shell provenance at harvest, close export/unset and arity gaps (RFC #4651 PR4) * fix(harness): split physical newlines as shell separators in acceptance matching (RFC #4651 PR4) * fix(harness): scope cd wrappers to thread data roots, pin accepted boundaries (RFC #4651 PR4) * fix(harness): preserve criterion connectors, prove file_written readable, fail-closed shell capability (RFC #4651 PR4) * fix(harness): compare only the connector prefix, tolerate trailing criterion semicolons (RFC #4651 PR4) * fix(harness): preserve continuation-line operators, keep ./-spelled executable identity (RFC #4651 PR4) * fix(harness): render criteria single-line so a multiline criterion cannot inject a forged checklist line (RFC #4651 PR4) * fix(harness): reject parent-traversal executable tokens in acceptance matching (RFC #4651 PR4) * fix(harness): reject parent-traversal negated values in acceptance matching (RFC #4651 PR4)
335 lines
16 KiB
Python
335 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``). 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"]
|
|
|
|
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]
|
|
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
|