feat(harness): deterministic acceptance checklist for subagent delegations (RFC #4651, layer 2) (#5109)

* 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)
This commit is contained in:
Zeren Wang 2026-09-01 10:13:41 +02:00 committed by GitHub
parent a956bbc030
commit a06a6fed7e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 4665 additions and 33 deletions

View File

@ -76,7 +76,7 @@ from deerflow.runtime.secret_context import (
)
from deerflow.runtime.stream_modes import normalize_stream_modes
from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.subagents.status_contract import SUBAGENT_RECEIPT_VERDICT_KEY, SUBAGENT_TOOL_RECEIPTS_KEY
from deerflow.subagents.status_contract import SUBAGENT_ACCEPTANCE_VERDICT_KEY, SUBAGENT_RECEIPT_VERDICT_KEY, SUBAGENT_TOOL_RECEIPTS_KEY
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
from deerflow.utils.thread_id import validate_thread_id
@ -121,6 +121,7 @@ _SERVER_OWNED_MESSAGE_METADATA_KEYS = (
TOOL_TRANSFORMS_KEY,
SUBAGENT_TOOL_RECEIPTS_KEY,
SUBAGENT_RECEIPT_VERDICT_KEY,
SUBAGENT_ACCEPTANCE_VERDICT_KEY,
}
)
| PROVENANCE_KEYS
@ -272,18 +273,23 @@ def _strip_external_metadata_from_message_like(item: Any) -> Any:
return item
def _strip_external_delegation_verdict(entry: Any) -> Any:
"""Remove the runtime-stamped receipt verdict from a caller-supplied
delegation-ledger entry.
#: Server-owned verdict keys on a delegation-ledger entry: runtime-stamped
#: execution evidence (citation verdict PR2, acceptance checklist PR4) that a
#: caller must never supply.
_SERVER_OWNED_DELEGATION_VERDICT_KEYS = frozenset({"receipt_verdict", "acceptance_verdict"})
``receipt_verdict`` is server-owned execution evidence stamped at task
write-back. Ledger entries are plain dicts, not messages, so the
message-metadata stripper never sees them; without this a caller can
persist a forged verdict that ``render_delegation_ledger`` would present
as fact.
def _strip_external_delegation_verdict(entry: Any) -> Any:
"""Remove runtime-stamped verdicts from a caller-supplied ledger entry.
``receipt_verdict``/``acceptance_verdict`` are server-owned execution
evidence stamped at task write-back. Ledger entries are plain dicts, not
messages, so the message-metadata stripper never sees them; without this
a caller can persist a forged verdict that ``render_delegation_ledger``
would present as fact.
"""
if isinstance(entry, dict) and "receipt_verdict" in entry:
return {key: value for key, value in entry.items() if key != "receipt_verdict"}
if isinstance(entry, dict) and _SERVER_OWNED_DELEGATION_VERDICT_KEYS & entry.keys():
return {key: value for key, value in entry.items() if key not in _SERVER_OWNED_DELEGATION_VERDICT_KEYS}
return entry

View File

@ -11,6 +11,7 @@ from langchain_core.messages import AIMessage, AnyMessage, ToolMessage
from deerflow.agents.middlewares.receipt_verification import render_citation_verdict, validate_receipt_verdict
from deerflow.agents.thread_state import DelegationEntry
from deerflow.subagents.acceptance_checks import render_acceptance_segment, validate_acceptance_verdict
from deerflow.subagents.status_contract import (
read_subagent_result_metadata,
)
@ -139,6 +140,9 @@ def extract_delegations(messages: list[AnyMessage]) -> list[DelegationEntry]:
receipt_verdict = structured.get("receipt_verdict")
if receipt_verdict:
entry["receipt_verdict"] = receipt_verdict
acceptance_verdict = structured.get("acceptance_verdict")
if acceptance_verdict:
entry["acceptance_verdict"] = acceptance_verdict
result_text = structured.get("result_brief") or structured.get("error") or _STATUS_ONLY_RESULT_BRIEFS.get(structured["status"])
if result_text:
result_sha256 = structured.get("result_sha256") or hashlib.sha256(result_text.encode("utf-8")).hexdigest()
@ -170,6 +174,11 @@ def _render_entry_line(entry: DelegationEntry) -> str:
segment = render_citation_verdict(receipt_verdict)
if segment:
line += f" · {segment}"
acceptance_verdict = validate_acceptance_verdict(entry.get("acceptance_verdict"))
if acceptance_verdict is not None:
segment = render_acceptance_segment(acceptance_verdict)
if segment:
line += f" · {segment}"
return line

View File

@ -180,6 +180,9 @@ class DelegationEntry(TypedDict):
# RFC #4651 PR2: parent-side citation-check verdict (advisory execution
# evidence), stamped at task write-back; absent on legacy history.
receipt_verdict: NotRequired[dict]
# RFC #4651 PR4: deterministic acceptance-checklist verdict, same
# provenance as receipt_verdict.
acceptance_verdict: NotRequired[dict]
created_at: str

View File

@ -43,6 +43,11 @@ class AioSandbox(Sandbox):
from corrupting the container's single persistent session (see #1433).
"""
#: The legacy exec path reuses one persistent shell session across calls,
#: so shell state (exports, cwd, functions) carries from one command into
#: the next — recorded bash evidence cannot prove a clean environment.
persistent_shell_sessions = True
def __init__(self, id: str, base_url: str, home_dir: str | None = None):
"""Initialize the AIO sandbox.
@ -192,6 +197,7 @@ class AioSandbox(Sandbox):
try:
result = self._client.shell.exec_command(command=command, no_change_timeout=self._DEFAULT_NO_CHANGE_TIMEOUT)
output = result.data.output if result.data else ""
exit_code = getattr(result.data, "exit_code", None) if result.data else None
if output and _ERROR_OBSERVATION_SIGNATURE in output:
logger.warning("ErrorObservation detected in sandbox output, retrying on a fresh session")
@ -203,6 +209,7 @@ class AioSandbox(Sandbox):
try:
result = self._client.shell.exec_command(command=command, id=fresh_id, no_change_timeout=self._DEFAULT_NO_CHANGE_TIMEOUT)
output = result.data.output if result.data else ""
exit_code = getattr(result.data, "exit_code", None) if result.data else None
finally:
# Release the one-shot recovery session, best-effort, so
# repeated corruption can't accumulate sessions.
@ -211,6 +218,10 @@ class AioSandbox(Sandbox):
except Exception as cleanup_error:
logger.warning(f"Failed to release recovery session {fresh_id}: {cleanup_error}")
if exit_code not in (0, None):
# Mirror LocalSandbox: keep the actual shell status in the
# output text (acceptance-checklist evidence).
output = f"{output}\nExit Code: {exit_code}" if output else f"Command exited with code {exit_code}"
return output if output else "(no output)"
except Exception as e:
logger.error(f"Failed to execute command in sandbox: {e}")
@ -266,9 +277,14 @@ class AioSandbox(Sandbox):
data = result.data if result else None
stdout = (data.stdout or "") if data else ""
stderr = (data.stderr or "") if data else ""
exit_code = getattr(data, "exit_code", None) if data else None
output = stdout
if stderr:
output += f"\nStd Error:\n{stderr}" if output else stderr
if exit_code not in (0, None):
# Mirror LocalSandbox: keep the actual shell status in the
# output text (acceptance-checklist evidence).
output = f"{output}\nExit Code: {exit_code}" if output else f"Command exited with code {exit_code}"
return output if output else "(no output)"
except ApiError as e:
if e.status_code == 404:

View File

@ -57,6 +57,10 @@ class BoxliteBox(Sandbox):
per-call ``env`` (request-scoped secrets).
"""
#: Every call is a fresh ``sh -lc`` exec in the box — no shell state
#: survives into the next command.
persistent_shell_sessions = False
TERMINAL_ERROR_MARKERS = (
"vsock",
"disconnected",
@ -199,8 +203,10 @@ class BoxliteBox(Sandbox):
output = f"{stdout}\n{stderr}"
else:
output = stdout or stderr
if result.exit_code not in (0, None) and not output:
output = f"Command exited with code {result.exit_code}"
if result.exit_code not in (0, None):
# Mirror LocalSandbox: preserve a nonzero exit in the output text
# even when the command produced output (see e2b_sandbox).
output = f"{output}\nExit Code: {result.exit_code}" if output else f"Command exited with code {result.exit_code}"
return output if output else "(no output)"
# ── file operations ─────────────────────────────────────────────────

View File

@ -47,6 +47,10 @@ class E2BSandbox(Sandbox):
:data:`DEFAULT_E2B_HOME_DIR`.
"""
#: Every call is a fresh ``sandbox.commands.run`` execution — no shell
#: state survives into the next command.
persistent_shell_sessions = False
def __init__(
self,
id: str,
@ -161,8 +165,12 @@ class E2BSandbox(Sandbox):
output = f"{stdout}\n{stderr}"
else:
output = stdout or stderr
if exit_code not in (0, None) and not output:
output = f"Command exited with code {exit_code}"
if exit_code not in (0, None):
# Mirror LocalSandbox: a nonzero exit must survive in the
# output text even when the command produced output, so
# evidence consumers (acceptance checklist) can recover the
# actual shell status.
output = f"{output}\nExit Code: {exit_code}" if output else f"Command exited with code {exit_code}"
return output if output else "(no output)"
except Exception as e:
if _is_sandbox_gone_error(e):

View File

@ -91,6 +91,10 @@ def format_execution(execution: Any) -> str:
class OpenSandboxSandbox(Sandbox):
"""Wrap one live ``opensandbox.sync.SandboxSync`` instance."""
#: Every call is a fresh ``run_command`` execution — no shell state
#: survives into the next command.
persistent_shell_sessions = False
def __init__(
self,
id: str,
@ -250,8 +254,10 @@ class OpenSandboxSandbox(Sandbox):
if exit_code is None:
detail = output or "no completion or error event"
return f"Error: OpenSandbox command completed without an exit code: {detail}"
if exit_code != 0 and not output:
output = f"Command exited with code {exit_code}"
if exit_code != 0:
# Mirror LocalSandbox: preserve a nonzero exit in the output text
# even when the command produced output (see e2b_sandbox).
output = f"{output}\nExit Code: {exit_code}" if output else f"Command exited with code {exit_code}"
return output if output else "(no output)"
def read_file(self, path: str, start_line: int | None = None, end_line: int | None = None) -> str:

View File

@ -89,6 +89,10 @@ class TenkiSandbox(Sandbox):
can evict the dead sandbox.
"""
#: Every call is a fresh ``sh -lc`` exec in the sandbox — no shell state
#: survives into the next command.
persistent_shell_sessions = False
def __init__(
self,
id: str,
@ -268,8 +272,10 @@ class TenkiSandbox(Sandbox):
output = f"{stdout}\n{stderr}"
else:
output = stdout or stderr
if result.exit_code not in (0, None) and not output:
output = f"Command exited with code {result.exit_code}"
if result.exit_code not in (0, None):
# Mirror LocalSandbox: preserve a nonzero exit in the output text
# even when the command produced output (see e2b_sandbox).
output = f"{output}\nExit Code: {result.exit_code}" if output else f"Command exited with code {result.exit_code}"
return output if output else "(no output)"
# ── file operations ─────────────────────────────────────────────────

View File

@ -93,6 +93,10 @@ class ResolvedPath(NamedTuple):
class LocalSandbox(Sandbox):
#: Every call is a fresh ``subprocess.run([shell, "-c", ...])`` process —
#: no shell state survives into the next command.
persistent_shell_sessions = False
@staticmethod
def _shell_name(shell: str) -> str:
"""Return the executable name for a shell path or command."""
@ -524,6 +528,10 @@ class LocalSandbox(Sandbox):
if timed_out:
notice = self._format_timeout_notice(timeout)
output += f"\n{notice}" if output else notice
# A timeout is a failed execution: mark it authoritatively (the
# coreutils ``timeout`` convention) so exit-status evidence
# consumers cannot read partial output as success.
output += "\nExit Code: 124"
elif returncode != 0:
output += f"\nExit Code: {returncode}"

View File

@ -46,6 +46,23 @@ class Sandbox(ABC):
_id: str
#: Whether ``execute_command`` reuses one persistent shell session across
#: calls (shell state — exports, cwd, functions — survives from one call
#: into the next). When True, a recorded command's environment cannot be
#: proven clean from the command text alone, so evidence consumers (the
#: acceptance checklist's ``tests_passed`` matcher) must treat recorded
#: bash evidence as untrusted and degrade to UNVERIFIED.
#:
#: Tri-state, failing closed: ``None`` (the default) means the
#: implementation has NOT declared its session semantics — custom
#: providers are loaded by class path and may reuse a persistent
#: session, so silence cannot be read as fresh-shell. Consumers must
#: trust only an explicit ``False`` and degrade to UNVERIFIED on
#: ``None`` exactly as on ``True``. Every shipped implementation
#: declares explicitly (AIO: ``True``; the per-call exec providers:
#: ``False``).
persistent_shell_sessions: bool | None = None
def __init__(self, id: str):
self._id = id

View File

@ -1652,32 +1652,65 @@ def mask_secret_values(output: str, injected_env: dict[str, str] | None) -> str:
return output
#: Providers append the shell's authoritative exit marker at the very end of
#: failing output (local ``Exit Code: N`` incl. timeout 124; remote
#: ``Command exited with code N`` when output was empty). Truncation must
#: never cut it away — evidence consumers (acceptance checklist) parse the
#: marker to recover the actual shell status.
_BASH_EXIT_MARKER_TAIL_RE = re.compile(r"(?:\nExit Code: -?\d+|\n?Command exited with code -?\d+)\s*$")
#: Floor for the bash output limit: the longest exit marker
#: (``Command exited with code -128``) is 30 chars, so any smaller configured
#: limit is raised to keep a failing command's marker preservable. The config
#: accepts any nonnegative value; below this floor truncation would destroy
#: the failure evidence it exists to measure.
_BASH_OUTPUT_MIN_LIMIT_CHARS = 32
def _truncate_bash_output(output: str, max_chars: int) -> str:
"""Middle-truncate bash output, preserving head and tail (50/50 split).
bash output may have errors at either end (stderr/stdout ordering is
non-deterministic), so both ends are preserved equally.
non-deterministic), so both ends are preserved equally. A trailing
shell exit marker is always preserved (see
``_BASH_EXIT_MARKER_TAIL_RE``): its budget comes out of the tail, not
out of the status evidence.
The returned string (including the truncation marker) is guaranteed to be
no longer than max_chars characters. Pass max_chars=0 to disable truncation
and return the full output unchanged.
The returned string (including the truncation marker) is no longer than
the EFFECTIVE limit, ``max(max_chars, _BASH_OUTPUT_MIN_LIMIT_CHARS)``
the 32-char floor is applied before anything else (even when the output
carries no exit marker) so a trailing marker always stays preservable,
meaning a configured limit in 1..31 yields up to 32 chars. Pass
max_chars=0 to disable truncation and return the full output unchanged.
"""
if max_chars == 0:
return output
# Clamp the effective limit to the marker-preserving floor (see
# ``_BASH_OUTPUT_MIN_LIMIT_CHARS``): a configured limit smaller than the
# exit marker would silently discard failure status.
max_chars = max(max_chars, _BASH_OUTPUT_MIN_LIMIT_CHARS)
if len(output) <= max_chars:
return output
preserved = ""
marker_match = _BASH_EXIT_MARKER_TAIL_RE.search(output)
if marker_match is not None and len(marker_match.group(0)) < max_chars:
preserved = marker_match.group(0)
output = output[: marker_match.start()]
max_chars -= len(preserved)
if len(output) <= max_chars:
return output + preserved
total_len = len(output)
# Compute the exact worst-case marker length: skipped chars is at most
# total_len, so this is a tight upper bound.
marker_max_len = len(f"\n... [middle truncated: {total_len} chars skipped] ...\n")
kept = max(0, max_chars - marker_max_len)
if kept == 0:
return output[:max_chars]
return output[:max_chars] + preserved
head_len = kept // 2
tail_len = kept - head_len
skipped = total_len - kept
marker = f"\n... [middle truncated: {skipped} chars skipped] ...\n"
return f"{output[:head_len]}{marker}{output[-tail_len:] if tail_len > 0 else ''}"
return f"{output[:head_len]}{marker}{output[-tail_len:] if tail_len > 0 else ''}" + preserved
def _truncate_read_file_output(output: str, max_chars: int) -> str:

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,10 @@
import asyncio
import atexit
import json
import logging
import os
import re
import threading
import uuid
from collections.abc import Callable, Coroutine, Mapping
@ -18,7 +20,7 @@ from typing import TYPE_CHECKING, Any
from langchain.agents import create_agent
from langchain.tools import BaseTool
from langchain_core.callbacks.base import BaseCallbackManager
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from langchain_core.runnables import RunnableConfig
from langchain_core.runnables.config import var_child_runnable_config
from langgraph.errors import GraphRecursionError
@ -112,6 +114,21 @@ class SubagentResult:
streaming produced a state (e.g. pre-stream cancellation), when
receipts are disabled, or when harvesting failed; an empty list
means the stream carried no stamped receipts (zero tool calls).
bash_executions: Bounded bash command/output evidence accumulated from
every streamed chunk (RFC #4651 PR4), letting the parent anchor a
``tests_passed:<command>`` acceptance leaf to a specific recorded
execution. Each entry also carries ``status_marker`` the exit
marker text the recorded status was derived from, when one was
seen so the leaf detail can report what was actually observed
and ``shell_persistent``, the producing sandbox's
``persistent_shell_sessions`` flag resolved from the state that
carried the evidence (``None`` when unidentifiable the matcher
fails closed on it).
Accumulated per chunk (merged by ``tool_call_id``) so
summarization compacting earlier messages cannot erase a recorded
execution. ``None`` when the delegation carried no acceptance
criteria, the run ended before streaming, or harvesting failed;
an empty list means the stream carried no bash-family tool calls.
"""
task_id: str
@ -128,6 +145,7 @@ class SubagentResult:
usage_reported: bool = False
admission_failure: bool = False
tool_receipts: list[dict[str, Any]] | None = field(default=None, kw_only=True)
bash_executions: list[dict[str, Any]] | None = field(default=None, kw_only=True)
cancel_event: threading.Event = field(default_factory=threading.Event, repr=False)
_state_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
@ -150,6 +168,28 @@ class SubagentResult:
if not self.status.is_terminal:
self.tool_receipts = [dict(receipt) for receipt in receipts]
def update_bash_executions(self, executions: list[dict[str, Any]] | None) -> None:
"""Merge bash evidence from the latest yielded state while still running.
Entries merge by ``tool_call_id`` in first-seen order and are capped to
the newest ``_BASH_EVIDENCE_MAX_ENTRIES`` unlike a terminal
``final_state`` scan, accumulation survives summarization compacting
earlier AI/ToolMessages out of the streamed history. ``None`` leaves
the field untouched (no evidence this chunk); an empty list still
publishes, keeping "the stream carried no bash-family tool calls"
distinguishable from "no evidence was collected" (mirrors
``update_tool_receipts``).
"""
if executions is None:
return
with self._state_lock:
if self.status.is_terminal:
return
merged = {str(entry.get("tool_call_id")): entry for entry in (self.bash_executions or [])}
for execution in executions:
merged[str(execution.get("tool_call_id"))] = dict(execution)
self.bash_executions = list(merged.values())[-_BASH_EVIDENCE_MAX_ENTRIES:]
def snapshot_tool_receipts(self) -> list[dict[str, Any]] | None:
"""Copy the latest published receipts for a racing terminal writer."""
with self._state_lock:
@ -346,6 +386,165 @@ def _harvest_tool_receipts(
return None
#: Bash-family tool names whose calls count as recorded command executions.
_BASH_EVIDENCE_TOOL_NAMES = frozenset({"bash", "bash_tool"})
#: Bounds for the harvested evidence: only the newest few executions travel,
#: with command/output text capped (test summaries print at the tail).
_BASH_EVIDENCE_MAX_ENTRIES = 20
_BASH_EVIDENCE_COMMAND_CHARS = 500
_BASH_EVIDENCE_OUTPUT_TAIL_CHARS = 1000
#: Exit-status markers in bash *output text*: a nonzero exit does not raise —
#: local sandboxes append ``Exit Code: N``; e2b/opensandbox emit
#: ``Command exited with code N`` when the command produced no output.
_BASH_EXIT_CODE_MARKER_RE = re.compile(r"Exit Code: (-?\d+)\s*$")
#: Remote providers emit ``Command exited with code N`` ONLY as the complete
#: output of a silent command — anchor it to the whole (trimmed) content so
#: a successful command that merely prints the phrase while exercising an
#: error path is not misrecorded as failed.
_BASH_EXITED_WITH_CODE_RE = re.compile(r"Command exited with code (-?\d+)")
def _bash_evidence_status(content: str, meta_status: str) -> tuple[str, str | None]:
"""Derive the recorded status from the shell exit marker when present.
Returns ``(status, marker)``: the marker text actually seen (e.g. ``Exit
Code: 5``), so consumers can report it instead of asserting a failure the
harness cannot distinguish from the command's own trailing text. The
explicit marker is authoritative: ``deerflow_tool_meta`` reports the
generic ToolMessage status, which stays ``success`` for a nonzero exit
rendered as ordinary output text.
"""
match = _BASH_EXIT_CODE_MARKER_RE.search(content) or _BASH_EXITED_WITH_CODE_RE.fullmatch(content.strip())
if match is None:
return meta_status, None
# Signal-killed local subprocesses report signed codes (Exit Code: -9);
# only an exact zero is a success.
return ("success" if int(match.group(1)) == 0 else "error"), " ".join(match.group(0).split())
def _harvest_shell_persistence(final_state: Any) -> bool | None:
"""Whether the sandbox that produced this state's bash evidence reuses one
persistent shell session (``Sandbox.persistent_shell_sessions`` AIO's
legacy exec path).
Read from the state that CARRIED the evidence the subagent's own graph
state, whose ``sandbox`` channel is seeded from the parent or written by
the subagent's own lazy acquisition — so the producing sandbox is the one
resolved. Resolving against the parent task runtime instead would
mis-adjudicate the common path where the parent never touched a sandbox:
its state has no ``sandbox`` key, the lookup would report "no persistent
session", and persistent-session evidence would pass as trusted. ``None``
when the producing sandbox cannot be identified and also when it never
declared its session semantics: a custom provider's silence is not
fresh-shell proof. Consumers must fail closed (UNVERIFIED) on ``None``.
"""
try:
from deerflow.sandbox.overwrite import unwrap_sandbox
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
sandbox_state, _ = unwrap_sandbox(final_state.get("sandbox")) if isinstance(final_state, dict) else (None, False)
sandbox_id = sandbox_state.get("sandbox_id") if isinstance(sandbox_state, dict) else None
if not isinstance(sandbox_id, str):
return None
sandbox = get_sandbox_provider().get(sandbox_id)
if sandbox is None:
return None
# Tri-state: an implementation that never declared its session
# semantics (custom provider loaded by class path) stays ``None`` —
# unknown — and the matcher fails closed on it exactly as on True.
declared = getattr(sandbox, "persistent_shell_sessions", None)
return None if declared is None else bool(declared)
except Exception:
return None
def _harvest_bash_executions(
final_state: Any,
) -> list[dict[str, Any]] | None:
"""Harvest bounded bash command/output evidence from one streamed state.
RFC #4651 PR4: a ``tests_passed:<command>`` acceptance leaf must anchor to
a specific recorded execution the command text lets the parent match the
criterion against the call that actually ran, and the bounded output tail
carries the test-summary shape. The recorded status is the **actual shell
exit status**: a nonzero bash exit comes back as ordinary output text
(local: a trailing ``Exit Code: N``; e2b/opensandbox with empty output:
``Command exited with code N``), which ``deerflow_tool_meta`` still reports
as success so an explicit exit marker wins, and the meta status is only
the fallback when no marker exists. Every entry is stamped with
``shell_persistent`` the producing sandbox's
``persistent_shell_sessions`` flag, resolved against the sandbox recorded
in THIS state (the subagent's own graph state), so provenance survives
even when the parent never touched a sandbox. Failure-isolated like the
receipt harvest: an error returns ``None`` and the leaves degrade to
UNVERIFIED.
"""
if not final_state:
return None
messages = final_state.get("messages") if isinstance(final_state, dict) else None
if not messages:
return None
try:
from deerflow.agents.middlewares.tool_result_meta import TOOL_META_KEY
commands: dict[str, tuple[str, str, bool]] = {}
for message in messages:
if not isinstance(message, AIMessage):
continue
for tool_call in message.tool_calls or []:
name = str(tool_call.get("name") or "")
if name not in _BASH_EVIDENCE_TOOL_NAMES:
continue
tool_call_id = str(tool_call.get("id") or "")
args = tool_call.get("args")
command = args.get("command") if isinstance(args, dict) else None
command = command if isinstance(command, str) else ""
# A truncated command loses its suffix; the matcher must not
# treat shell-structure analysis of the prefix as proof (a
# selection-changing suffix like ``-k smoke`` could be cut).
commands[tool_call_id] = (name, command[:_BASH_EVIDENCE_COMMAND_CHARS], len(command) > _BASH_EVIDENCE_COMMAND_CHARS)
if not commands:
return []
executions: list[dict[str, Any]] = []
for message in messages:
if not isinstance(message, ToolMessage):
continue
tool_call_id = str(message.tool_call_id or "")
entry = commands.get(tool_call_id)
if entry is None:
continue
name, command, command_truncated = entry
meta = (message.additional_kwargs or {}).get(TOOL_META_KEY) or {}
meta_status = str(meta.get("status") or getattr(message, "status", "success") or "success")
content = message.content if isinstance(message.content, str) else json.dumps(message.content, sort_keys=True, default=str)
status, status_marker = _bash_evidence_status(content, meta_status)
executions.append(
{
"tool_call_id": tool_call_id,
"tool_name": name,
"command": command,
"command_truncated": command_truncated,
"output_tail": content[-_BASH_EVIDENCE_OUTPUT_TAIL_CHARS:],
"status": status,
"status_marker": status_marker,
}
)
# Provenance stamp: whether the producing sandbox reuses one
# persistent shell session. Captured here — while the state that
# carried the evidence is at hand — because the parent-side checker
# cannot derive it (its runtime has no ``sandbox`` key when the
# parent delegated before touching one). ``None`` (unknown) fails
# closed in the acceptance matcher.
shell_persistent = _harvest_shell_persistence(final_state)
for execution in executions:
execution["shell_persistent"] = shell_persistent
return executions[-_BASH_EVIDENCE_MAX_ENTRIES:]
except Exception:
logger.warning("Failed to harvest subagent bash execution evidence", exc_info=True)
return None
# Persistent event loop for isolated subagent executions triggered from an
# already-running parent loop. Reusing one long-lived loop avoids creating a
# fresh loop per execution and then closing async resources bound to it.
@ -1143,6 +1342,15 @@ class SubagentExecutor:
return None
return _harvest_tool_receipts(final_state, prefer_citing_turn=prefer_citing_turn)
def current_bash_executions() -> list[dict[str, Any]] | None:
# RFC #4651 PR4: evidence for tests_passed acceptance leaves.
# Accumulated from every chunk (not harvested once at terminal) so
# summarization compacting earlier messages cannot erase a recorded
# execution. Criteria-free runs pay nothing.
if not self.acceptance_criteria:
return None
return _harvest_bash_executions(final_state)
try:
if task_info is not None and task_store is not None:
await notify_task_start(
@ -1262,6 +1470,7 @@ class SubagentExecutor:
# cancellation request was in flight.
final_state = chunk
result.update_tool_receipts(terminal_receipts())
result.update_bash_executions(current_bash_executions())
# Cooperative cancellation: check if parent requested stop.
# Note: cancellation is only detected at astream iteration boundaries,

View File

@ -25,9 +25,17 @@ consumers read the structured facts carried inside
- ``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 across Python and TypeScript.
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
@ -39,6 +47,7 @@ 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"
@ -49,6 +58,7 @@ 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
@ -124,6 +134,7 @@ class StructuredSubagentResult(TypedDict):
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:
@ -150,6 +161,7 @@ def make_subagent_additional_kwargs(
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.
@ -190,6 +202,9 @@ def make_subagent_additional_kwargs(
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
@ -313,4 +328,7 @@ def read_subagent_result_metadata(
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

View File

@ -22,6 +22,7 @@ from deerflow.extensions import resolve_run_extensions
from deerflow.runtime.user_context import resolve_runtime_user_id
from deerflow.sandbox.security import LOCAL_BASH_SUBAGENT_DISABLED_MESSAGE, is_host_bash_allowed
from deerflow.subagents import SubagentExecutor, get_available_subagent_names, get_subagent_config
from deerflow.subagents.acceptance_checks import check_acceptance_criteria, render_acceptance_section
from deerflow.subagents.capacity import SubagentExecutionCapacity
from deerflow.subagents.config import resolve_subagent_model_name
from deerflow.subagents.executor import (
@ -540,8 +541,13 @@ def _task_result_command(
usage: dict[str, int] | None = None,
tool_receipts: list[dict] | None = None,
receipt_verdict: dict | None = None,
acceptance_verdict: dict | None = None,
) -> Command:
content, metadata_error = format_subagent_result_message(status, result=result, error=error, stop_reason=stop_reason)
if acceptance_verdict is not None:
# RFC #4651 PR4: the rendered checklist rides the model-visible result
# text; metadata carries the structured verdict for the ledger/judge.
content = f"{content}\n\n{render_acceptance_section(acceptance_verdict)}"
return Command(
update={
"messages": [
@ -558,6 +564,7 @@ def _task_result_command(
token_usage=usage,
tool_receipts=tool_receipts,
receipt_verdict=receipt_verdict,
acceptance_verdict=acceptance_verdict,
),
)
]
@ -629,6 +636,13 @@ async def task_tool(
- A resolved citation means the cited call happened with the recorded status
it does not validate that the adjacent claim is correct. Before relying
on a load-bearing claim, spot-check its verifiable handle yourself.
- When you attach `acceptance_criteria`, the result includes a deterministic
acceptance checklist: decidable criteria (`file:<path> exists|non-empty`,
`file_written:<path>`, `tests_passed:<command>`) are checked in code
against the shared thread workspace and the recorded bash executions, and
every criterion that cannot be checked deterministically is marked
UNVERIFIED never silently passed. A `holds` leaf is execution evidence,
not a guarantee that the deliverable is correct.
Args:
prompt: The task description for the subagent. Be specific and clear about what needs to be done.
@ -639,8 +653,10 @@ async def task_tool(
report. Attach them when
the outcome is objectively checkable; prefer the canonical forms
`file:<path> exists`, `file:<path> non-empty`, `file_written:<path>`,
and `tests_passed:<command>` so each criterion stays objectively
decidable. Example for a report-writing delegation:
and `tests_passed:<command>` these are checked deterministically
against the shared thread workspace and the recorded execution
evidence when the subagent completes, while any other wording comes
back marked UNVERIFIED. Example for a report-writing delegation:
["file:../outputs/report.md non-empty"]. Omit for open-ended
exploration where no crisp acceptance condition exists.
description: Optional short (3-5 word) description of the task for logging/display.
@ -909,6 +925,23 @@ async def task_tool(
# harvest (zero stamped calls) and still gets a verdict.
receipts = getattr(result, "tool_receipts", None)
receipt_verdict = verify_receipt_citations(result.result or "", receipts) if receipts is not None else None
# RFC #4651 PR4: deterministic acceptance checklist. Runs only
# when the delegation carried criteria; offloaded because the
# file leaves perform sandbox IO. Failure-isolated like the
# citation check — a checker error never changes the outcome,
# the result just flows back without a checklist verdict.
acceptance_verdict = None
if acceptance_criteria:
try:
acceptance_verdict = await asyncio.to_thread(
check_acceptance_criteria,
acceptance_criteria,
runtime=runtime,
thread_data=thread_data,
bash_executions=getattr(result, "bash_executions", None),
)
except Exception:
logger.warning(f"[trace={trace_id}] Acceptance checklist failed for task {tool_call_id}; result flows back unchecked", exc_info=True)
return _task_result_command(
tool_call_id=tool_call_id,
status="completed",
@ -918,6 +951,7 @@ async def task_tool(
usage=usage,
tool_receipts=receipts,
receipt_verdict=receipt_verdict,
acceptance_verdict=acceptance_verdict,
)
elif result.status == SubagentStatus.FAILED:
_report_subagent_usage(runtime, result)

View File

@ -0,0 +1,183 @@
"""Regression: the acceptance checklist must not block the event loop.
RFC #4651 PR4 runs deterministic acceptance checks (``file:`` leaves read
through the sandbox) on the ``task`` tool's completed branch — an async path
on the LangGraph event loop. The whole check is offloaded with
``asyncio.to_thread`` in ``task_tool``; this anchor locks that offload.
Under the strict Blockbuster context (this directory's conftest), any blocking
IO reached from ``deerflow.*`` while on the event loop raises
``BlockingError``.
The content reader is injected here as a **blocking probe**: it does real
file IO against the real local filesystem. What must be pinned is that the
reader call never executes on the event loop, not that today's sandbox read
happens to be cheap a remote sandbox provider turns the same call into
network IO. If the offload is removed, the main test fails; the meta-check
below proves the probe has teeth by calling the checker directly on the loop.
"""
from __future__ import annotations
import importlib
from enum import Enum
from pathlib import Path
from types import SimpleNamespace
import pytest
from langchain_core.messages import ToolMessage
from deerflow.subagents.config import SubagentConfig
# importlib.import_module binds the real module: the package attribute
# ``deerflow.tools.builtins.task_tool`` is shadowed by the StructuredTool.
task_tool_module = importlib.import_module("deerflow.tools.builtins.task_tool")
pytestmark = pytest.mark.asyncio
class _FakeSubagentStatus(Enum):
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
TIMED_OUT = "timed_out"
def _blocking_probe_reader(probe_file: Path):
"""A content reader that performs real blocking file IO."""
def read(_runtime, path: str) -> str:
# Real filesystem IO: trips the strict gate when it runs on the loop.
return probe_file.read_text(encoding="utf-8")
return read
def _runtime(tmp_path: Path) -> SimpleNamespace:
workspace = tmp_path / "user-data" / "workspace"
outputs = tmp_path / "user-data" / "outputs"
workspace.mkdir(parents=True, exist_ok=True)
outputs.mkdir(parents=True, exist_ok=True)
return SimpleNamespace(
state={
"sandbox": {"sandbox_id": "local"},
"thread_data": {
"workspace_path": str(workspace),
"uploads_path": str(tmp_path / "user-data" / "uploads"),
"outputs_path": str(outputs),
},
},
context={"thread_id": "thread-1"},
config={"metadata": {"model_name": "ark-model", "trace_id": "trace-1"}},
)
def _completed_result() -> SimpleNamespace:
return SimpleNamespace(
status=_FakeSubagentStatus.COMPLETED,
ai_messages=[],
result="done",
error=None,
stop_reason=None,
token_usage_records=[],
usage_reported=False,
tool_receipts=None,
bash_executions=None,
)
def _patch_task_tool_boundary(monkeypatch, tmp_path: Path) -> None:
"""Mock only the external boundaries; the offload under guard stays real."""
monkeypatch.setattr(
"deerflow.sandbox.tools.read_current_file_content",
_blocking_probe_reader(tmp_path / "probe.txt"),
)
class DummyExecutor:
def __init__(self, **kwargs):
pass
def execute_async(self, prompt, task_id=None):
return task_id or "generated-task-id"
monkeypatch.setattr(task_tool_module, "SubagentStatus", _FakeSubagentStatus)
monkeypatch.setattr(task_tool_module, "SubagentExecutor", DummyExecutor)
monkeypatch.setattr(
task_tool_module,
"get_subagent_config",
lambda _name: SubagentConfig(
name="general-purpose",
description="General helper",
system_prompt="Base system prompt",
max_turns=50,
timeout_seconds=10,
),
)
monkeypatch.setattr(task_tool_module, "get_background_task_result", lambda _id: _completed_result())
monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: lambda _event: None)
# Pre-existing blocking call (managed-subagents registry resolves the
# config base dir via os.getcwd on the loop) unrelated to this anchor —
# scoped out so the test stays pinned to the checklist offload.
monkeypatch.setattr(task_tool_module, "get_available_subagent_names", lambda **kwargs: ["general-purpose"])
async def _no_sleep(_: float) -> None:
return None
monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep)
monkeypatch.setattr("deerflow.tools.get_available_tools", lambda **kwargs: [])
async def test_acceptance_checklist_file_leaf_is_offloaded(monkeypatch, tmp_path):
"""The completed branch runs file-leaf stat+read off the event loop."""
(tmp_path / "probe.txt").write_text("probe body", encoding="utf-8")
# The size probe stats the real host path (local sandbox), so the
# criterion's target must exist on disk.
(tmp_path / "user-data" / "outputs").mkdir(parents=True, exist_ok=True)
(tmp_path / "user-data" / "outputs" / "report.md").write_text("real body", encoding="utf-8")
_patch_task_tool_boundary(monkeypatch, tmp_path)
tool = task_tool_module.task_tool
invoke = getattr(tool, "coroutine", None) or getattr(tool, "func", None)
assert invoke is not None
command = await invoke(
runtime=_runtime(tmp_path),
description="test",
prompt="p",
subagent_type="general-purpose",
tool_call_id="tc-blocking-io",
acceptance_criteria=["file:../outputs/report.md exists"],
)
messages = command.update["messages"]
assert len(messages) == 1
message = messages[0]
assert isinstance(message, ToolMessage)
verdict = message.additional_kwargs["subagent_acceptance_verdict"]
assert verdict["leaves"][0]["checked"] is True
assert verdict["leaves"][0]["holds"] is True
async def test_blocking_probe_reader_actually_trips_the_gate(monkeypatch, tmp_path):
"""Meta-check: the same read on the event loop must raise BlockingError,
so the anchor above cannot go vacuously green. (The size probe is
injected here so the read is reached: the real prober's broad failure
isolation swallows a BlockingError into an UNVERIFIED leaf by design,
since Blockbuster intercepts the syscall before it can block.)"""
from blockbuster import BlockingError
from deerflow.subagents.acceptance_checks import check_acceptance_criteria
(tmp_path / "probe.txt").write_text("probe body", encoding="utf-8")
thread_data = {
"workspace_path": str(tmp_path / "user-data" / "workspace"),
"outputs_path": str(tmp_path / "user-data" / "outputs"),
}
with pytest.raises(BlockingError):
check_acceptance_criteria(
["file:../outputs/report.md exists"],
runtime=None,
thread_data=thread_data,
content_reader=_blocking_probe_reader(tmp_path / "probe.txt"),
size_prober=lambda _rt, _p, _td: 10,
)

File diff suppressed because it is too large Load Diff

View File

@ -58,6 +58,21 @@ def sandbox():
return sb
def test_exec_command_appends_exit_marker_when_failure_has_output(sandbox):
"""The legacy exec path must propagate the structured exit_code into the
output text (LocalSandbox parity) instead of discarding it."""
sandbox._client.shell.exec_command = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(output="5 passed, 1 error\n", exit_code=1)))
assert sandbox.execute_command("make test") == "5 passed, 1 error\n\nExit Code: 1"
def test_bash_exec_appends_exit_marker_when_failure_has_output(sandbox):
"""The bash.exec (env-bearing) path must propagate exit_code the same way."""
sandbox._client.bash.exec = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(stdout="5 passed, 1 error\n", stderr="", exit_code=1)))
assert sandbox.execute_command("make test", env={"A": "1"}) == "5 passed, 1 error\n\nExit Code: 1"
class TestExecuteCommandSerialization:
"""Verify that concurrent exec_command calls are serialized."""

View File

@ -166,6 +166,19 @@ def test_execute_command_forwards_timeout_to_sdk_and_loop_runner() -> None:
assert run_timeouts == [5]
def test_execute_command_appends_exit_marker_when_failure_has_output(monkeypatch: pytest.MonkeyPatch) -> None:
"""LocalSandbox parity: a nonzero exit survives in the output text even
when the command produced output (acceptance-checklist evidence)."""
box = BoxliteBox("box-id", box=object(), run=_fake_run)
monkeypatch.setattr(
box,
"_exec",
lambda *argv, **kwargs: types.SimpleNamespace(exit_code=1, stdout="5 passed, 1 error\n", stderr=""),
)
assert box.execute_command("make test") == "5 passed, 1 error\n\nExit Code: 1"
def test_read_file_supports_optional_line_ranges(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[tuple[str, ...]] = []

View File

@ -535,3 +535,53 @@ class TestReceiptVerdictRendering:
assert "write report" in rendered
assert "citations:" not in rendered
def _acceptance_verdict() -> 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": "tests_passed:make test", "family": "tests_passed", "checked": True, "holds": False, "detail": "latest matching run recorded status=error"},
{"criterion": "open ended", "family": "undecidable", "checked": False, "holds": False, "detail": "not deterministically checkable"},
],
"unchecked": ["open ended"],
"all_hold": False,
}
class TestAcceptanceVerdictRendering:
def test_entry_carries_verdict_and_renders_segment(self):
from deerflow.subagents.status_contract import make_subagent_additional_kwargs
messages = [
_ai_task_call("c1", "write report"),
ToolMessage(
content="Task Succeeded. Result: done",
tool_call_id="c1",
name="task",
additional_kwargs=make_subagent_additional_kwargs("completed", result="done", acceptance_verdict=_acceptance_verdict()),
),
]
entries = extract_delegations(messages)
assert entries[0]["acceptance_verdict"]["all_hold"] is False
rendered = render_delegation_ledger(entries)
assert "acceptance: 1 hold, 1 does not hold, 1 UNVERIFIED — execution evidence only, does not validate claim correctness" in rendered
def test_legacy_entries_without_verdict_render_unchanged(self):
messages = [_ai_task_call("c1", "write report"), _completed_task_message("c1", None)]
rendered = render_delegation_ledger(extract_delegations(messages))
assert "acceptance:" not in rendered
def test_malformed_persisted_acceptance_verdict_is_ignored(self):
entry = {
**_entry("c1", "completed", description="write report"),
"acceptance_verdict": {"all_hold": True},
}
rendered = render_delegation_ledger([entry])
assert "write report" in rendered
assert "acceptance:" not in rendered

View File

@ -1576,6 +1576,15 @@ def test_execute_command_returns_stdout_on_success():
assert sb.is_dead is False
def test_execute_command_appends_exit_marker_when_failure_has_output():
"""LocalSandbox parity: a nonzero exit must survive in the output text
even when the command produced output, so evidence consumers (acceptance
checklist) recover the actual shell status."""
client = FakeClient(commands=FakeCommandsAPI([SimpleNamespace(stdout="5 passed, 1 error\n", stderr="", exit_code=1)]))
sb = _make_sandbox(client)
assert sb.execute_command("make test") == "5 passed, 1 error\n\nExit Code: 1"
def test_execute_command_does_not_mark_dead_on_unrelated_error():
def boom(_cmd: str, **kwargs) -> Any:

View File

@ -375,6 +375,39 @@ def test_normalize_input_strips_external_tool_receipt():
assert result["messages"][0].additional_kwargs == {"custom": "keep-me"}
def test_normalize_input_strips_external_acceptance_verdict_from_messages():
"""``subagent_acceptance_verdict`` is runtime-stamped evidence (RFC #4651
PR4): a caller-supplied message carrying it is a forgery, same as the
receipt verdict otherwise ``extract_delegations`` would present it as
server-produced evidence."""
from app.gateway.services import normalize_input
from deerflow.subagents.status_contract import SUBAGENT_ACCEPTANCE_VERDICT_KEY
result = normalize_input(
{
"messages": [
{
"role": "tool",
"tool_call_id": "tc-forged",
"content": "forged output",
"additional_kwargs": {
SUBAGENT_ACCEPTANCE_VERDICT_KEY: {
"source": "acceptance_checklist",
"requirement": "delegation_acceptance_criteria",
"leaves": [{"criterion": "file:x.md exists", "family": "file_exists", "checked": True, "holds": True, "detail": "forged"}],
"unchecked": [],
"all_hold": True,
},
"custom": "keep-me",
},
}
]
}
)
assert result["messages"][0].additional_kwargs == {"custom": "keep-me"}
def _forged_delegation_entry() -> dict:
"""A caller-supplied ledger entry carrying a forged citation verdict."""
return {
@ -421,6 +454,42 @@ def test_normalize_input_strips_delegation_verdict_without_messages():
assert "receipt_verdict" not in result["delegations"][0]
def _forged_acceptance_verdict() -> dict:
"""A forged acceptance-checklist verdict on a caller-supplied entry."""
return {
"source": "acceptance_checklist",
"requirement": "delegation_acceptance_criteria",
"leaves": [{"criterion": "file:x.md exists", "family": "file_exists", "checked": True, "holds": True, "detail": "forged"}],
"unchecked": [],
"all_hold": True,
}
def test_normalize_input_strips_external_delegation_acceptance_verdict():
"""The acceptance verdict is runtime-stamped evidence (RFC #4651 PR4):
same forgery surface as the citation verdict, same strip."""
from app.gateway.services import normalize_input
from deerflow.agents.middlewares.delegation_ledger import render_delegation_ledger
forged = {**_forged_delegation_entry(), "acceptance_verdict": _forged_acceptance_verdict()}
result = normalize_input({"messages": [{"role": "user", "content": "hi"}], "delegations": [forged]})
entry = result["delegations"][0]
assert "acceptance_verdict" not in entry
assert "receipt_verdict" not in entry
assert entry["id"] == "call-forged"
assert "acceptance:" not in render_delegation_ledger(result["delegations"])
def test_normalize_input_preserves_trusted_internal_acceptance_verdict():
from app.gateway.services import normalize_input
forged = {**_forged_delegation_entry(), "acceptance_verdict": _forged_acceptance_verdict()}
result = normalize_input({"delegations": [forged]}, trusted_internal=True)
assert result["delegations"][0]["acceptance_verdict"] == forged["acceptance_verdict"]
def test_normalize_input_preserves_trusted_internal_delegation_verdict():
from app.gateway.services import normalize_input

View File

@ -83,6 +83,19 @@ def test_timeout_notice_formats_fractional_and_singular_timeouts(monkeypatch):
assert "after 1 second" in LocalSandbox("t").execute_command("wait", timeout=1)
def test_timeout_output_carries_authoritative_failure_marker(monkeypatch):
"""A timed-out command is a failed execution: the output must carry an
exit marker so exit-status evidence (acceptance checklist) cannot read a
partial passing summary as success."""
monkeypatch.setattr(LocalSandbox, "_get_shell", lambda self: "/bin/sh")
monkeypatch.setattr(LocalSandbox, "_run_posix_command", staticmethod(lambda args, timeout, env=None: ("12 passed\n", "", 0, True)))
output = LocalSandbox("t").execute_command("make test", timeout=1)
assert "timed out" in output.lower()
assert output.endswith("Exit Code: 124")
def test_windows_timeout_returns_notice(monkeypatch):
monkeypatch.setattr(local_sandbox.os, "name", "nt")
monkeypatch.setattr(LocalSandbox, "_get_shell", lambda self: "cmd.exe")

View File

@ -464,7 +464,9 @@ def test_shutdown_stops_idle_reaper(monkeypatch: pytest.MonkeyPatch) -> None:
def test_execute_forwards_env_timeout_and_combines_streams() -> None:
remote = _FakeRemote("remote")
box = _box(remote, default_env={"BASE": "1"})
assert box.execute_command("mixed-output", env={"EXTRA": "2"}, timeout=5) == "out-1\nout-2\nerr-1"
# A nonzero exit with non-empty output keeps the authoritative marker
# (LocalSandbox parity) instead of losing the failure.
assert box.execute_command("mixed-output", env={"EXTRA": "2"}, timeout=5) == "out-1\nout-2\nerr-1\nExit Code: 7"
_, opts = remote.commands.calls[-1]
assert opts is not None
assert opts.envs == {"BASE": "1", "EXTRA": "2"}

View File

@ -4298,3 +4298,365 @@ class TestToolReceiptHarvest:
assert result.status == SubagentStatus.COMPLETED
assert result.tool_receipts is None
class TestBashExecutionHarvest:
"""RFC #4651 PR4: the executor harvests bounded bash command/output
evidence so the parent can anchor ``tests_passed`` acceptance leaves."""
def _final_state(self, classes):
ai = classes["AIMessage"](
content="",
tool_calls=[
{"name": "bash", "args": {"command": "make test", "description": "run tests"}, "id": "tc-1", "type": "tool_call"},
{"name": "write_file", "args": {"file_path": "a.md", "content": "x"}, "id": "tc-2", "type": "tool_call"},
],
)
tool_ok = classes["ToolMessage"](content=".....\n12 passed in 1.0s\n", tool_call_id="tc-1", name="bash")
tool_other = classes["ToolMessage"](content="wrote a.md", tool_call_id="tc-2", name="write_file")
return {"messages": [classes["HumanMessage"](content="task"), ai, tool_ok, tool_other]}
def test_harvests_only_bash_family_calls_with_bounded_fields(self, classes, monkeypatch):
executor_module = importlib.import_module("deerflow.subagents.executor")
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
executions = executor_module._harvest_bash_executions(self._final_state(classes))
assert executions is not None
assert len(executions) == 1
entry = executions[0]
assert entry["tool_call_id"] == "tc-1"
assert entry["tool_name"] == "bash"
assert entry["command"] == "make test"
assert entry["status"] == "success"
assert "12 passed" in entry["output_tail"]
def test_status_comes_from_tool_meta_when_present(self, classes, monkeypatch):
executor_module = importlib.import_module("deerflow.subagents.executor")
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
state = self._final_state(classes)
tool_msg = state["messages"][2]
tool_msg.additional_kwargs["deerflow_tool_meta"] = {"status": "error"}
executions = executor_module._harvest_bash_executions(state)
assert executions[0]["status"] == "error"
def test_nonzero_exit_code_marker_overrides_meta_success(self, classes, monkeypatch):
"""PR review: a failing test run returns ordinary text ending in
``Exit Code: N`` tool_meta stays success, so the pass summary would
otherwise satisfy the leaf. The recorded status must be the shell's."""
executor_module = importlib.import_module("deerflow.subagents.executor")
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
state = self._final_state(classes)
state["messages"][2].content = "12 passed, 1 error in 2.0s\nExit Code: 1"
state["messages"][2].additional_kwargs["deerflow_tool_meta"] = {"status": "success"}
executions = executor_module._harvest_bash_executions(state)
assert executions[0]["status"] == "error"
assert "12 passed" in executions[0]["output_tail"]
def test_command_exited_with_code_marker_is_error(self, classes, monkeypatch):
executor_module = importlib.import_module("deerflow.subagents.executor")
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
state = self._final_state(classes)
state["messages"][2].content = "Command exited with code 3"
executions = executor_module._harvest_bash_executions(state)
assert executions[0]["status"] == "error"
def test_exited_with_code_phrase_inside_output_is_not_a_marker(self, classes, monkeypatch):
"""PR review: remote providers use ``Command exited with code N``
only as the COMPLETE output a successful command that prints the
phrase while exercising an error path must not record failure."""
executor_module = importlib.import_module("deerflow.subagents.executor")
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
state = self._final_state(classes)
state["messages"][2].content = "validating error path: Command exited with code 3\n5 passed"
executions = executor_module._harvest_bash_executions(state)
assert executions[0]["status"] == "success"
def test_zero_exit_code_marker_is_success(self, classes, monkeypatch):
executor_module = importlib.import_module("deerflow.subagents.executor")
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
state = self._final_state(classes)
state["messages"][2].content = "12 passed\nExit Code: 0"
executions = executor_module._harvest_bash_executions(state)
assert executions[0]["status"] == "success"
def test_marker_text_is_recorded_as_status_marker(self, classes, monkeypatch):
"""PR review: the entry must carry the marker the status was derived
from, so the leaf detail can report what was seen instead of asserting
a failure indistinguishable from the command's own trailing text."""
executor_module = importlib.import_module("deerflow.subagents.executor")
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
state = self._final_state(classes)
state["messages"][2].content = "green\nExit Code: 5"
executions = executor_module._harvest_bash_executions(state)
assert executions[0]["status"] == "error"
assert executions[0]["status_marker"] == "Exit Code: 5"
def test_remote_form_records_its_marker_text(self, classes, monkeypatch):
executor_module = importlib.import_module("deerflow.subagents.executor")
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
state = self._final_state(classes)
state["messages"][2].content = "Command exited with code 3"
executions = executor_module._harvest_bash_executions(state)
assert executions[0]["status_marker"] == "Command exited with code 3"
def test_meta_status_without_marker_records_none(self, classes, monkeypatch):
executor_module = importlib.import_module("deerflow.subagents.executor")
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
executions = executor_module._harvest_bash_executions(self._final_state(classes))
assert executions[0]["status"] == "success"
assert executions[0]["status_marker"] is None
def test_timeout_marker_is_error(self, classes, monkeypatch):
"""A command killed on timeout carries Exit Code: 124 after the
notice partial passing output must not record success."""
executor_module = importlib.import_module("deerflow.subagents.executor")
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
state = self._final_state(classes)
state["messages"][2].content = "12 passed\nCommand timed out after 30 seconds and was terminated. ...\nExit Code: 124"
executions = executor_module._harvest_bash_executions(state)
assert executions[0]["status"] == "error"
def test_signal_signed_exit_code_is_error(self, classes, monkeypatch):
"""PR review: a signal-killed local subprocess reports a signed
marker (Exit Code: -9) it must record failure, not fall back to
the meta success of an ordinary bash return."""
executor_module = importlib.import_module("deerflow.subagents.executor")
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
state = self._final_state(classes)
state["messages"][2].content = "5 passed\nExit Code: -9"
executions = executor_module._harvest_bash_executions(state)
assert executions[0]["status"] == "error"
@pytest.mark.anyio
async def test_evidence_accumulated_survives_history_compaction(self, classes, base_config, mock_agent, msg, monkeypatch):
"""PR review: subagent summarization removes earlier AI/ToolMessages
from the stream, so a terminal-only scan would lose the matching test
execution and falsely render UNVERIFIED. Per-chunk accumulation must
retain it even when a later chunk no longer carries the messages."""
SubagentExecutor = classes["SubagentExecutor"]
SubagentStatus = classes["SubagentStatus"]
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
ai_with_call = classes["AIMessage"](
content="",
tool_calls=[{"name": "bash", "args": {"command": "make test"}, "id": "tc-1", "type": "tool_call"}],
)
chunk_with_test_run = {"messages": [msg.human("Do something"), ai_with_call, msg.tool("12 passed", "tc-1", name="bash")]}
# Compacted history: the test-run messages are gone from later chunks.
compacted_chunk = {"messages": [msg.human("Do something"), msg.ai("Done", "msg-9")]}
mock_agent.astream = lambda *args, **kwargs: async_iterator([chunk_with_test_run, compacted_chunk])
executor = SubagentExecutor(
config=base_config,
tools=[],
thread_id="test-thread",
acceptance_criteria=["tests_passed:make test"],
)
with (
patch.object(executor, "_build_initial_state", new=AsyncMock(return_value=({}, [], None))),
patch.object(executor, "_create_agent", return_value=mock_agent),
):
result = await executor._aexecute_admitted("Do something")
assert result.status == SubagentStatus.COMPLETED
assert result.bash_executions is not None
assert [e["command"] for e in result.bash_executions] == ["make test"]
def test_output_tail_is_bounded(self, classes, monkeypatch):
executor_module = importlib.import_module("deerflow.subagents.executor")
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
state = self._final_state(classes)
state["messages"][2].content = "x" * 5000
executions = executor_module._harvest_bash_executions(state)
assert len(executions[0]["output_tail"]) == 1000
def test_long_command_is_capped_and_flagged_truncated(self, classes, monkeypatch):
"""PR review: the matcher must know the command lost its suffix."""
executor_module = importlib.import_module("deerflow.subagents.executor")
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
state = self._final_state(classes)
long_command = "make test " + "--long-option " * 60
state["messages"][1].tool_calls[0]["args"]["command"] = long_command
executions = executor_module._harvest_bash_executions(state)
entry = executions[0]
assert len(entry["command"]) == 500
assert entry["command_truncated"] is True
def test_short_command_is_not_flagged(self, classes, monkeypatch):
executor_module = importlib.import_module("deerflow.subagents.executor")
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
executions = executor_module._harvest_bash_executions(self._final_state(classes))
assert executions[0]["command_truncated"] is False
def test_entries_carry_producing_sandbox_shell_persistence(self, classes, monkeypatch):
"""PR review (P1): the provenance stamp is resolved from the state
that carried the evidence the subagent's own graph state — not the
parent task runtime, so a parent that delegated before touching a
sandbox cannot mis-adjudicate persistent-session evidence as
trusted."""
executor_module = importlib.import_module("deerflow.subagents.executor")
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
class _PersistentShellSandbox:
persistent_shell_sessions = True
monkeypatch.setattr("deerflow.sandbox.sandbox_provider.get_sandbox_provider", lambda: SimpleNamespace(get=lambda _id: _PersistentShellSandbox()))
state = self._final_state(classes)
state["sandbox"] = {"sandbox_id": "sb-1"}
executions = executor_module._harvest_bash_executions(state)
assert executions[0]["shell_persistent"] is True
def test_fresh_process_sandbox_stamps_false(self, classes, monkeypatch):
executor_module = importlib.import_module("deerflow.subagents.executor")
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
class _OneShotSandbox:
persistent_shell_sessions = False
monkeypatch.setattr("deerflow.sandbox.sandbox_provider.get_sandbox_provider", lambda: SimpleNamespace(get=lambda _id: _OneShotSandbox()))
state = self._final_state(classes)
state["sandbox"] = {"sandbox_id": "sb-1"}
executions = executor_module._harvest_bash_executions(state)
assert executions[0]["shell_persistent"] is False
def test_undeclared_sandbox_capability_stamps_none(self, classes, monkeypatch):
"""PR review (P2): a custom provider that never declared
``persistent_shell_sessions`` is UNKNOWN, not fresh-shell silence
cannot be read as a clean-environment proof."""
executor_module = importlib.import_module("deerflow.subagents.executor")
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
class _UndeclaredSandbox:
pass
monkeypatch.setattr("deerflow.sandbox.sandbox_provider.get_sandbox_provider", lambda: SimpleNamespace(get=lambda _id: _UndeclaredSandbox()))
state = self._final_state(classes)
state["sandbox"] = {"sandbox_id": "sb-1"}
executions = executor_module._harvest_bash_executions(state)
assert executions[0]["shell_persistent"] is None
def test_unidentifiable_sandbox_stamps_none(self, classes, monkeypatch):
"""No sandbox channel in the evidence-carrying state → unknown
provenance; the acceptance matcher fails closed on it."""
executor_module = importlib.import_module("deerflow.subagents.executor")
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
executions = executor_module._harvest_bash_executions(self._final_state(classes))
assert executions[0]["shell_persistent"] is None
def test_no_bash_calls_returns_empty_list(self, classes, monkeypatch):
executor_module = importlib.import_module("deerflow.subagents.executor")
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
assert executor_module._harvest_bash_executions({"messages": [classes["HumanMessage"](content="task")]}) == []
def test_empty_state_returns_none(self, classes):
executor_module = importlib.import_module("deerflow.subagents.executor")
assert executor_module._harvest_bash_executions(None) is None
assert executor_module._harvest_bash_executions({}) is None
@pytest.mark.anyio
async def test_completed_run_attaches_bash_executions_only_with_criteria(self, classes, base_config, mock_agent, msg, monkeypatch):
SubagentExecutor = classes["SubagentExecutor"]
SubagentStatus = classes["SubagentStatus"]
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
ai_with_call = classes["AIMessage"](
content="",
tool_calls=[{"name": "bash", "args": {"command": "make test"}, "id": "tc-1", "type": "tool_call"}],
)
final_state = {"messages": [msg.human("Do something"), ai_with_call, msg.tool("12 passed", "tc-1", name="bash"), msg.ai("Done", "msg-9")]}
mock_agent.astream = lambda *args, **kwargs: async_iterator([final_state])
executor = SubagentExecutor(
config=base_config,
tools=[],
thread_id="test-thread",
acceptance_criteria=["tests_passed:make test"],
)
with (
patch.object(executor, "_build_initial_state", new=AsyncMock(return_value=({}, [], None))),
patch.object(executor, "_create_agent", return_value=mock_agent),
):
result = await executor._aexecute_admitted("Do something")
assert result.status == SubagentStatus.COMPLETED
assert result.bash_executions is not None
assert [e["command"] for e in result.bash_executions] == ["make test"]
@pytest.mark.anyio
async def test_completed_run_with_criteria_but_no_bash_calls_publishes_empty_list(self, classes, base_config, mock_agent, msg, monkeypatch):
"""PR review: the empty list is observable — "the stream carried no
bash-family tool calls" stays distinguishable from the ``None`` cases
(no criteria, pre-stream end, harvest failure), the same split
``tool_receipts`` already makes."""
SubagentExecutor = classes["SubagentExecutor"]
SubagentStatus = classes["SubagentStatus"]
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
final_state = {"messages": [msg.human("Do something"), msg.ai("Done", "msg-9")]}
mock_agent.astream = lambda *args, **kwargs: async_iterator([final_state])
executor = SubagentExecutor(
config=base_config,
tools=[],
thread_id="test-thread",
acceptance_criteria=["tests_passed:make test"],
)
with (
patch.object(executor, "_build_initial_state", new=AsyncMock(return_value=({}, [], None))),
patch.object(executor, "_create_agent", return_value=mock_agent),
):
result = await executor._aexecute_admitted("Do something")
assert result.status == SubagentStatus.COMPLETED
assert result.bash_executions == []
@pytest.mark.anyio
async def test_completed_run_without_criteria_harvests_nothing(self, classes, base_config, mock_agent, msg, monkeypatch):
SubagentExecutor = classes["SubagentExecutor"]
SubagentStatus = classes["SubagentStatus"]
final_state = {"messages": [msg.human("Do something"), msg.ai("Done", "msg-9")]}
mock_agent.astream = lambda *args, **kwargs: async_iterator([final_state])
executor = SubagentExecutor(config=base_config, tools=[], thread_id="test-thread")
with (
patch.object(executor, "_build_initial_state", new=AsyncMock(return_value=({}, [], None))),
patch.object(executor, "_create_agent", return_value=mock_agent),
):
result = await executor._aexecute_admitted("Do something")
assert result.status == SubagentStatus.COMPLETED
assert result.bash_executions is None

View File

@ -314,3 +314,41 @@ class TestToolReceiptTransport:
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

View File

@ -84,6 +84,7 @@ def _make_result(
stop_reason: str | None = None,
token_usage_records: list[dict] | None = None,
tool_receipts: list[dict] | None = None,
bash_executions: list[dict] | None = None,
) -> SimpleNamespace:
return SimpleNamespace(
status=status,
@ -94,6 +95,7 @@ def _make_result(
token_usage_records=token_usage_records or [],
usage_reported=False,
tool_receipts=tool_receipts,
bash_executions=bash_executions,
)
@ -2784,7 +2786,7 @@ def _receipt_fixture(rid: str = "r1", tool: str = "write_file", status: str = "s
}
def _run_completed_task_tool(monkeypatch, *, result_text: str, tool_receipts: list[dict] | None) -> ToolMessage:
def _run_completed_task_tool(monkeypatch, *, result_text: str, tool_receipts: list[dict] | None, **result_kwargs) -> ToolMessage:
"""Drive the completed branch and return the terminal ToolMessage."""
class DummyExecutor:
@ -2800,7 +2802,7 @@ def _run_completed_task_tool(monkeypatch, *, result_text: str, tool_receipts: li
monkeypatch.setattr(
task_tool_module,
"get_background_task_result",
lambda _: _make_result(FakeSubagentStatus.COMPLETED, result=result_text, tool_receipts=tool_receipts),
lambda _: _make_result(FakeSubagentStatus.COMPLETED, result=result_text, tool_receipts=tool_receipts, **result_kwargs),
)
monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: lambda _event: None)
monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep)
@ -2816,6 +2818,120 @@ def _run_completed_task_tool(monkeypatch, *, result_text: str, tool_receipts: li
return _task_tool_message(command)
def _run_completed_task_tool_with_criteria(monkeypatch, *, criteria: list[str], bash_executions: list[dict] | None = None) -> ToolMessage:
"""Drive the completed branch with acceptance criteria attached.
The workspace-scoped file leaves read through the lazily imported
``read_current_file_content``; patching the sandbox module attribute swaps
in a fake reader without touching the sandbox provider stack.
"""
# The checklist reads through the sandbox-native virtual path form.
files = {"/mnt/user-data/outputs/report.md": "report body"}
monkeypatch.setattr(
"deerflow.sandbox.tools.read_current_file_content",
lambda _runtime, path: files[path] if path in files else (_ for _ in ()).throw(FileNotFoundError(path)),
)
# A bounded size is established before any read; fake the prober over the
# same fake filesystem.
monkeypatch.setattr(
"deerflow.subagents.acceptance_checks._probe_file_size",
lambda _runtime, path, _thread_data: len(files[path].encode("utf-8")) if path in files else (_ for _ in ()).throw(FileNotFoundError(path)),
)
class DummyExecutor:
def __init__(self, **kwargs):
pass
def execute_async(self, prompt, task_id=None):
return task_id or "generated-task-id"
monkeypatch.setattr(task_tool_module, "SubagentStatus", FakeSubagentStatus)
monkeypatch.setattr(task_tool_module, "SubagentExecutor", DummyExecutor)
monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: _make_subagent_config())
monkeypatch.setattr(
task_tool_module,
"get_background_task_result",
lambda _: _make_result(FakeSubagentStatus.COMPLETED, result="done", tool_receipts=None, bash_executions=bash_executions),
)
monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: lambda _event: None)
monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep)
monkeypatch.setattr("deerflow.tools.get_available_tools", lambda **kwargs: [])
command = _run_task_tool(
runtime=_make_runtime(),
description="test",
prompt="p",
subagent_type="general-purpose",
tool_call_id="tc-acceptance",
acceptance_criteria=criteria,
)
return _task_tool_message(command)
def test_task_tool_completed_stamps_acceptance_verdict(monkeypatch):
message = _run_completed_task_tool_with_criteria(
monkeypatch,
criteria=["file:../outputs/report.md non-empty", "tests_passed:make test", "open ended"],
bash_executions=[{"tool_call_id": "tc-1", "tool_name": "bash", "command": "make test", "output_tail": "12 passed", "status": "success", "shell_persistent": False}],
)
verdict = message.additional_kwargs["subagent_acceptance_verdict"]
assert verdict["source"] == "acceptance_checklist"
assert [leaf["holds"] for leaf in verdict["leaves"]] == [True, True, False]
assert verdict["unchecked"] == ["open ended"]
# The rendered checklist section rides the model-visible result text.
assert "- [holds] file:../outputs/report.md non-empty" in message.content
assert "- [holds] tests_passed:make test" in message.content
assert "- [UNVERIFIED] open ended" in message.content
def test_task_tool_completed_without_criteria_stamps_no_acceptance_verdict(monkeypatch):
message = _run_completed_task_tool(monkeypatch, result_text="done", tool_receipts=None)
assert "subagent_acceptance_verdict" not in message.additional_kwargs
assert "Acceptance checklist" not in message.content
def test_task_tool_acceptance_check_failure_is_isolated(monkeypatch):
def exploding_check(*_args, **_kwargs):
raise RuntimeError("checker blew up")
monkeypatch.setattr(task_tool_module, "check_acceptance_criteria", exploding_check)
class DummyExecutor:
def __init__(self, **kwargs):
pass
def execute_async(self, prompt, task_id=None):
return task_id or "generated-task-id"
monkeypatch.setattr(task_tool_module, "SubagentStatus", FakeSubagentStatus)
monkeypatch.setattr(task_tool_module, "SubagentExecutor", DummyExecutor)
monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: _make_subagent_config())
monkeypatch.setattr(
task_tool_module,
"get_background_task_result",
lambda _: _make_result(FakeSubagentStatus.COMPLETED, result="done", tool_receipts=None),
)
monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: lambda _event: None)
monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep)
monkeypatch.setattr("deerflow.tools.get_available_tools", lambda **kwargs: [])
command = _run_task_tool(
runtime=_make_runtime(),
description="test",
prompt="p",
subagent_type="general-purpose",
tool_call_id="tc-acceptance-fail",
acceptance_criteria=["file:../outputs/report.md exists"],
)
message = _task_tool_message(command)
# The checker error never changes the outcome: completed result, no verdict.
assert message.content.startswith("Task Succeeded.")
assert "subagent_acceptance_verdict" not in message.additional_kwargs
def test_task_tool_completed_attaches_receipts_and_verdict(monkeypatch):
receipts = [_receipt_fixture("r1", "write_file")]
message = _run_completed_task_tool(monkeypatch, result_text="saved the report [r1]", tool_receipts=receipts)

View File

@ -150,6 +150,8 @@ class _FakeSandbox:
def _run_script(self, script: str) -> _FakeResult:
if script == "echo ok":
return _FakeResult(stdout=b"ok\n")
if script == "failing-tests":
return _FakeResult(exit_code=1, stdout=b"5 passed, 1 error\n")
if "BOOTSTRAP_OK" in script: # provider create-time bootstrap script
return _FakeResult(stdout=b"BOOTSTRAP_OK\n")
if script.startswith("find "):
@ -344,6 +346,14 @@ def test_execute_command_formats_stdout_and_forwards_env_timeout() -> None:
assert call["cwd"] is None # no forced cwd; runs in sandbox default dir
def test_execute_command_appends_exit_marker_when_failure_has_output() -> None:
"""LocalSandbox parity: a nonzero exit survives in the output text even
when the command produced output (acceptance-checklist evidence)."""
fake = _FakeSandbox()
box = TenkiSandbox("sb", fake)
assert box.execute_command("failing-tests") == "5 passed, 1 error\n\nExit Code: 1"
def test_execute_command_returns_error_as_text() -> None:
box = TenkiSandbox("sb", _FakeSandbox(exec_error=RuntimeError("boom")))
assert box.execute_command("echo hi") == "Error: boom"

View File

@ -18,6 +18,49 @@ class TestTruncateBashOutput:
output = "hello world"
assert _truncate_bash_output(output, 20000) == output
def test_trailing_exit_marker_survives_truncation(self):
"""PR review: a failing command's pass-shaped text must not lose the
authoritative exit marker to truncation evidence consumers parse it."""
output = "1 passed\n" + "M" * 30000 + "\nExit Code: 1"
result = _truncate_bash_output(output, 20000)
assert result.endswith("\nExit Code: 1")
assert len(result) <= 20000
def test_trailing_exit_marker_survives_a_tiny_budget(self):
output = "x" * 5000 + "\nExit Code: 124"
result = _truncate_bash_output(output, 100)
assert result.endswith("\nExit Code: 124")
assert len(result) <= 100
def test_command_exited_with_code_form_is_preserved(self):
output = "M" * 5000 + "\nCommand exited with code 3"
result = _truncate_bash_output(output, 1000)
assert result.endswith("Command exited with code 3")
def test_signed_signal_exit_marker_is_preserved(self):
"""Signal-killed processes report signed codes (Exit Code: -9)."""
output = "5 passed\n" + "M" * 5000 + "\nExit Code: -9"
result = _truncate_bash_output(output, 100)
assert result.endswith("\nExit Code: -9")
assert len(result) <= 100
def test_marker_preserved_when_limit_is_below_marker_length(self):
"""PR review: a configured limit smaller than the exit marker must
not silently discard failure status the floor keeps it."""
result = _truncate_bash_output("1 passed\nExit Code: 1", 10)
assert result.endswith("\nExit Code: 1")
assert len(result) <= 32 # the marker-preserving floor
def test_small_limit_without_marker_uses_the_floor(self):
result = _truncate_bash_output("x" * 500, 10)
assert len(result) <= 32
def test_output_without_marker_truncates_as_before(self):
output = "A" * 30000
result = _truncate_bash_output(output, 20000)
assert len(result) <= 20000
assert not result.endswith("Exit Code: 1")
def test_output_equal_to_limit_returned_unchanged(self):
output = "A" * 20000
assert _truncate_bash_output(output, 20000) == output
@ -83,7 +126,10 @@ class TestTruncateBashOutput:
def test_small_max_chars_does_not_crash(self):
output = "A" * 1000
result = _truncate_bash_output(output, 10)
assert len(result) <= 10
# Limits below the marker-preserving floor (32) are raised so a
# failing command's exit marker always fits; see
# _BASH_OUTPUT_MIN_LIMIT_CHARS.
assert len(result) <= 32
def test_result_never_exceeds_max_chars_various_sizes(self):
output = "X" * 50000