mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 16:08: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)
355 lines
14 KiB
Python
355 lines
14 KiB
Python
"""Contract tests for ``deerflow.subagents.status_contract``."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from deerflow.subagents.status_contract import (
|
|
SUBAGENT_ERROR_KEY,
|
|
SUBAGENT_METADATA_TEXT_MAX_CHARS,
|
|
SUBAGENT_MODEL_NAME_KEY,
|
|
SUBAGENT_RESULT_BRIEF_KEY,
|
|
SUBAGENT_RESULT_SHA256_KEY,
|
|
SUBAGENT_STATUS_KEY,
|
|
SUBAGENT_STATUS_VALUES,
|
|
SUBAGENT_STOP_REASON_KEY,
|
|
SUBAGENT_STOP_REASON_VALUES,
|
|
SUBAGENT_TOKEN_USAGE_KEY,
|
|
_bound_metadata_text,
|
|
format_subagent_result_message,
|
|
make_subagent_additional_kwargs,
|
|
read_subagent_result_metadata,
|
|
)
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
_CONTRACT_PATH = _REPO_ROOT / "contracts" / "subagent_status_contract.json"
|
|
|
|
|
|
def _load_contract() -> dict:
|
|
return json.loads(_CONTRACT_PATH.read_text(encoding="utf-8"))
|
|
|
|
|
|
def test_contract_file_exists():
|
|
assert _CONTRACT_PATH.is_file(), f"missing shared fixture: {_CONTRACT_PATH}"
|
|
|
|
|
|
def test_status_values_match_contract():
|
|
"""Backend status enum stays aligned with the contract document."""
|
|
contract = _load_contract()
|
|
assert set(SUBAGENT_STATUS_VALUES) == set(contract["valid_status_values"])
|
|
|
|
|
|
def test_stop_reason_values_match_contract():
|
|
"""Backend stop_reason vocabulary stays aligned with the contract document (#3875 Phase 2)."""
|
|
contract = _load_contract()
|
|
assert set(SUBAGENT_STOP_REASON_VALUES) == set(contract["valid_stop_reason_values"])
|
|
|
|
|
|
def test_make_subagent_additional_kwargs_includes_status():
|
|
kwargs = make_subagent_additional_kwargs("completed")
|
|
assert kwargs == {SUBAGENT_STATUS_KEY: "completed"}
|
|
|
|
|
|
def test_make_subagent_additional_kwargs_carries_terminal_runtime_metadata():
|
|
kwargs = make_subagent_additional_kwargs(
|
|
"completed",
|
|
result="done",
|
|
model_name="claude-3-7-sonnet",
|
|
token_usage={"input_tokens": 100, "output_tokens": 20, "total_tokens": 120},
|
|
)
|
|
|
|
assert kwargs[SUBAGENT_MODEL_NAME_KEY] == "claude-3-7-sonnet"
|
|
assert kwargs[SUBAGENT_TOKEN_USAGE_KEY] == {
|
|
"input_tokens": 100,
|
|
"output_tokens": 20,
|
|
"total_tokens": 120,
|
|
}
|
|
|
|
|
|
def test_make_subagent_additional_kwargs_includes_error_when_present():
|
|
kwargs = make_subagent_additional_kwargs("failed", error="boom")
|
|
assert kwargs == {SUBAGENT_STATUS_KEY: "failed", SUBAGENT_ERROR_KEY: "boom"}
|
|
|
|
|
|
def test_make_subagent_additional_kwargs_includes_bounded_result_metadata():
|
|
kwargs = make_subagent_additional_kwargs("completed", result="done")
|
|
assert kwargs[SUBAGENT_STATUS_KEY] == "completed"
|
|
assert kwargs[SUBAGENT_RESULT_BRIEF_KEY] == "done"
|
|
assert len(kwargs[SUBAGENT_RESULT_SHA256_KEY]) == 64
|
|
assert SUBAGENT_ERROR_KEY not in kwargs
|
|
|
|
|
|
def test_make_subagent_additional_kwargs_bounds_large_result_metadata():
|
|
huge = "x" * (SUBAGENT_METADATA_TEXT_MAX_CHARS + 5000)
|
|
kwargs = make_subagent_additional_kwargs("completed", result=huge)
|
|
assert len(kwargs[SUBAGENT_RESULT_BRIEF_KEY]) <= SUBAGENT_METADATA_TEXT_MAX_CHARS
|
|
assert kwargs[SUBAGENT_RESULT_BRIEF_KEY] != huge
|
|
assert len(kwargs[SUBAGENT_RESULT_SHA256_KEY]) == 64
|
|
|
|
|
|
def test_make_subagent_additional_kwargs_stamps_stop_reason_when_present():
|
|
"""#3875 Phase 2: a capped run keeps a normal status and carries the cap
|
|
on the additive ``subagent_stop_reason`` field. A token-capped run produced
|
|
a final answer, so it is ``completed`` + ``token_capped`` and stays
|
|
result-bearing (the partial work survives on ``result_brief``)."""
|
|
kwargs = make_subagent_additional_kwargs("completed", result="investigated 3 of 5 sources", stop_reason="token_capped")
|
|
assert kwargs[SUBAGENT_STATUS_KEY] == "completed"
|
|
assert kwargs[SUBAGENT_RESULT_BRIEF_KEY] == "investigated 3 of 5 sources"
|
|
assert len(kwargs[SUBAGENT_RESULT_SHA256_KEY]) == 64
|
|
assert kwargs[SUBAGENT_STOP_REASON_KEY] == "token_capped"
|
|
# A clean completed run (no cap) does not carry the field at all.
|
|
assert SUBAGENT_STOP_REASON_KEY not in make_subagent_additional_kwargs("completed", result="done")
|
|
|
|
|
|
def test_format_subagent_result_message_completed_with_stop_reason_notes_the_cap():
|
|
"""The model-visible text folds a ``(capped: ...)`` note in so the lead can
|
|
tell a budget-capped completion from a clean one without parsing metadata."""
|
|
content, metadata_error = format_subagent_result_message("completed", result="investigated 3 of 5 sources", stop_reason="token_capped")
|
|
assert content.startswith("Task Succeeded (capped: token budget)")
|
|
assert "investigated 3 of 5 sources" in content
|
|
# completed suppresses the error blob; the cap lives on stop_reason only.
|
|
assert metadata_error is None
|
|
|
|
|
|
def test_format_subagent_result_message_failed_with_stop_reason_notes_the_cap():
|
|
"""A turn-capped run with no usable output is ``failed`` + ``turn_capped``;
|
|
the cap note distinguishes "out of budget" from a broken subagent."""
|
|
content, metadata_error = format_subagent_result_message("failed", error="Reached max_turns=10", stop_reason="turn_capped")
|
|
assert content.startswith("Task failed (capped: turn budget)")
|
|
assert metadata_error == "Reached max_turns=10"
|
|
|
|
|
|
def test_bound_metadata_text_respects_small_caps():
|
|
text = "A" * 100
|
|
|
|
assert _bound_metadata_text(text, cap=0) == ""
|
|
assert _bound_metadata_text(text, cap=1) == "A"
|
|
assert len(_bound_metadata_text(text, cap=15)) <= 15
|
|
|
|
|
|
def test_make_subagent_additional_kwargs_omits_blank_error():
|
|
"""Empty / whitespace error must not leak as ``subagent_error: ""``."""
|
|
assert make_subagent_additional_kwargs("failed", error="") == {SUBAGENT_STATUS_KEY: "failed"}
|
|
assert make_subagent_additional_kwargs("failed", error=" ") == {SUBAGENT_STATUS_KEY: "failed"}
|
|
assert make_subagent_additional_kwargs("failed", error=None) == {SUBAGENT_STATUS_KEY: "failed"}
|
|
|
|
|
|
def test_make_subagent_additional_kwargs_bounds_large_error_metadata():
|
|
huge = "boom " * 2000
|
|
kwargs = make_subagent_additional_kwargs("failed", error=huge)
|
|
assert kwargs[SUBAGENT_STATUS_KEY] == "failed"
|
|
assert len(kwargs[SUBAGENT_ERROR_KEY]) <= SUBAGENT_METADATA_TEXT_MAX_CHARS
|
|
assert SUBAGENT_RESULT_BRIEF_KEY not in kwargs
|
|
|
|
|
|
def test_read_subagent_result_metadata_returns_bounded_payload():
|
|
parsed = read_subagent_result_metadata(
|
|
{
|
|
SUBAGENT_STATUS_KEY: "completed",
|
|
SUBAGENT_RESULT_BRIEF_KEY: "structured",
|
|
SUBAGENT_RESULT_SHA256_KEY: "a" * 64,
|
|
SUBAGENT_ERROR_KEY: "ignored",
|
|
}
|
|
)
|
|
assert parsed == {
|
|
"status": "completed",
|
|
"result_brief": "structured",
|
|
"result_sha256": "a" * 64,
|
|
}
|
|
|
|
|
|
def test_read_subagent_result_metadata_reads_stop_reason_for_capped_run():
|
|
"""A capped run's reader surfaces the additive ``stop_reason`` alongside
|
|
the normal status/result fields so the delegation ledger and frontend can
|
|
show "capped" without parsing result text (#3875 Phase 2)."""
|
|
parsed = read_subagent_result_metadata(
|
|
{
|
|
SUBAGENT_STATUS_KEY: "completed",
|
|
SUBAGENT_RESULT_BRIEF_KEY: "investigated 3 of 5 sources",
|
|
SUBAGENT_RESULT_SHA256_KEY: "a" * 64,
|
|
SUBAGENT_STOP_REASON_KEY: "turn_capped",
|
|
}
|
|
)
|
|
assert parsed == {
|
|
"status": "completed",
|
|
"result_brief": "investigated 3 of 5 sources",
|
|
"result_sha256": "a" * 64,
|
|
"stop_reason": "turn_capped",
|
|
}
|
|
|
|
|
|
def test_read_subagent_result_metadata_normalizes_legacy_max_turns_reached():
|
|
"""Phase 1 (#3949) wrote ``max_turns_reached`` into checkpointed thread
|
|
history; Phase 2 (#3980) stopped producing it. The reader normalizes the
|
|
legacy value so old delegations still resolve terminally instead of
|
|
stranding as ``in_progress`` in the durable ledger — partial ``result_brief``
|
|
preserved as ``completed + turn_capped`` (Phase 1 was result-bearing), or
|
|
``failed + turn_capped`` when no result survived."""
|
|
# With a recovered partial -> completed + turn_capped, partial preserved.
|
|
parsed = read_subagent_result_metadata(
|
|
{
|
|
SUBAGENT_STATUS_KEY: "max_turns_reached",
|
|
SUBAGENT_RESULT_BRIEF_KEY: "investigated 3 of 5 sources",
|
|
SUBAGENT_RESULT_SHA256_KEY: "a" * 64,
|
|
SUBAGENT_ERROR_KEY: "Reached max_turns=150",
|
|
}
|
|
)
|
|
assert parsed == {
|
|
"status": "completed",
|
|
"result_brief": "investigated 3 of 5 sources",
|
|
"result_sha256": "a" * 64,
|
|
"stop_reason": "turn_capped",
|
|
}
|
|
|
|
# No usable result -> failed + turn_capped (terminal, not in_progress).
|
|
parsed_no_result = read_subagent_result_metadata(
|
|
{
|
|
SUBAGENT_STATUS_KEY: "max_turns_reached",
|
|
SUBAGENT_ERROR_KEY: "Reached max_turns=150",
|
|
}
|
|
)
|
|
assert parsed_no_result == {
|
|
"status": "failed",
|
|
"error": "Reached max_turns=150",
|
|
"stop_reason": "turn_capped",
|
|
}
|
|
|
|
|
|
def test_read_subagent_result_metadata_rejects_unknown_status():
|
|
assert read_subagent_result_metadata({SUBAGENT_STATUS_KEY: "future"}) is None
|
|
|
|
|
|
def test_read_subagent_result_metadata_rejects_non_hex_sha256():
|
|
"""A 64-char value that is not a lowercase hex digest must be dropped."""
|
|
base = {SUBAGENT_STATUS_KEY: "completed", SUBAGENT_RESULT_BRIEF_KEY: "structured"}
|
|
for bad_hash in ("z" * 64, "A" * 64, "a" * 63, "a" * 65, ("a" * 63) + " "):
|
|
parsed = read_subagent_result_metadata({**base, SUBAGENT_RESULT_SHA256_KEY: bad_hash})
|
|
assert parsed == {"status": "completed", "result_brief": "structured"}, bad_hash
|
|
|
|
|
|
def test_make_subagent_additional_kwargs_rejects_unknown_status():
|
|
import pytest
|
|
|
|
with pytest.raises(ValueError, match="invalid subagent status"):
|
|
make_subagent_additional_kwargs("garbage") # type: ignore[arg-type]
|
|
|
|
|
|
def test_make_subagent_additional_kwargs_rejects_unknown_stop_reason():
|
|
import pytest
|
|
|
|
with pytest.raises(ValueError, match="invalid subagent stop_reason"):
|
|
make_subagent_additional_kwargs("completed", stop_reason="garbage") # type: ignore[arg-type]
|
|
|
|
|
|
class TestToolReceiptTransport:
|
|
"""RFC #4651 PR2: receipts + verdict ride additional_kwargs additively."""
|
|
|
|
def _receipt(self, rid: str = "r1") -> dict:
|
|
return {
|
|
"id": rid,
|
|
"tool_call_id": f"tc-{rid}",
|
|
"tool_name": "write_file",
|
|
"status": "success",
|
|
"args_sha256": "a" * 16,
|
|
"output_sha256": "b" * 16,
|
|
"output_bytes": 10,
|
|
"created_at": "2026-08-24T00:00:00+00:00",
|
|
}
|
|
|
|
def _verdict(self) -> dict:
|
|
return {
|
|
"source": "receipt_citations",
|
|
"requirement": "cited_ids_in_execution_record",
|
|
"citation_resolved": True,
|
|
"cited": ["r1"],
|
|
"resolved": ["r1"],
|
|
"failed": [],
|
|
"unknown": [],
|
|
"no_citation_claims": False,
|
|
}
|
|
|
|
def test_round_trip_receipts_and_verdict(self):
|
|
kwargs = make_subagent_additional_kwargs(
|
|
"completed",
|
|
result="done [r1]",
|
|
tool_receipts=[self._receipt()],
|
|
receipt_verdict=self._verdict(),
|
|
)
|
|
assert kwargs["subagent_tool_receipts"] == [self._receipt()]
|
|
assert kwargs["subagent_receipt_verdict"] == self._verdict()
|
|
|
|
structured = read_subagent_result_metadata(kwargs)
|
|
assert structured is not None
|
|
assert structured["tool_receipts"] == [self._receipt()]
|
|
assert structured["receipt_verdict"] == self._verdict()
|
|
|
|
def test_malformed_receipts_dropped(self):
|
|
kwargs = make_subagent_additional_kwargs(
|
|
"completed",
|
|
result="done",
|
|
tool_receipts=[self._receipt(), {"broken": True}],
|
|
)
|
|
assert kwargs["subagent_tool_receipts"] == [self._receipt()]
|
|
|
|
def test_all_malformed_receipts_omit_key(self):
|
|
kwargs = make_subagent_additional_kwargs(
|
|
"completed",
|
|
result="done",
|
|
tool_receipts=[{"broken": True}],
|
|
)
|
|
assert "subagent_tool_receipts" not in kwargs
|
|
|
|
def test_malformed_verdict_dropped(self):
|
|
kwargs = make_subagent_additional_kwargs(
|
|
"completed",
|
|
result="done",
|
|
receipt_verdict={"citation_resolved": "yes"},
|
|
)
|
|
assert "subagent_receipt_verdict" not in kwargs
|
|
|
|
def test_old_payloads_read_clean(self):
|
|
kwargs = make_subagent_additional_kwargs("completed", result="done")
|
|
structured = read_subagent_result_metadata(kwargs)
|
|
assert structured is not None
|
|
assert "tool_receipts" not in structured
|
|
assert "receipt_verdict" not in structured
|
|
|
|
def _acceptance_verdict(self) -> dict:
|
|
return {
|
|
"source": "acceptance_checklist",
|
|
"requirement": "delegation_acceptance_criteria",
|
|
"leaves": [
|
|
{"criterion": "file:../outputs/r.md exists", "family": "file_exists", "checked": True, "holds": True, "detail": "exists, 5 bytes"},
|
|
{"criterion": "open ended", "family": "undecidable", "checked": False, "holds": False, "detail": "not deterministically checkable"},
|
|
],
|
|
"unchecked": ["open ended"],
|
|
"all_hold": False,
|
|
}
|
|
|
|
def test_round_trip_acceptance_verdict(self):
|
|
kwargs = make_subagent_additional_kwargs(
|
|
"completed",
|
|
result="done",
|
|
acceptance_verdict=self._acceptance_verdict(),
|
|
)
|
|
assert kwargs["subagent_acceptance_verdict"] == self._acceptance_verdict()
|
|
|
|
structured = read_subagent_result_metadata(kwargs)
|
|
assert structured is not None
|
|
assert structured["acceptance_verdict"] == self._acceptance_verdict()
|
|
|
|
def test_malformed_acceptance_verdict_dropped(self):
|
|
kwargs = make_subagent_additional_kwargs(
|
|
"completed",
|
|
result="done",
|
|
acceptance_verdict={"all_hold": "yes"},
|
|
)
|
|
assert "subagent_acceptance_verdict" not in kwargs
|
|
|
|
def test_old_payloads_have_no_acceptance_verdict(self):
|
|
kwargs = make_subagent_additional_kwargs("completed", result="done")
|
|
structured = read_subagent_result_metadata(kwargs)
|
|
assert structured is not None
|
|
assert "acceptance_verdict" not in structured
|