From eeb2c4dbcc4396b1f72b2c1d05fbc13cfe025d2d Mon Sep 17 00:00:00 2001 From: wgwfn3 <43378854+wgwfn3@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:44:49 +0800 Subject: [PATCH] fix(middleware): keep the bash exit marker last after budget rewrites (#6354) * fix(middleware): keep the bash exit marker last after budget rewrites * fix(middleware): keep exit marker at exact-fit fallback budget; harden test cleanup * docs(changelog): reference #6354 in the middleware entry --- CHANGELOG.md | 13 ++ CHANGELOG_zh.md | 10 + .../deerflow/agents/middlewares/AGENTS.md | 2 +- .../middlewares/TOOL_OUTPUT_PERSISTENCE.md | 12 ++ .../tool_output_budget_middleware.py | 70 +++++- .../test_tool_output_budget_middleware.py | 202 +++++++++++++++++- 6 files changed, 300 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4917003b1f..4409085ee9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -623,6 +623,18 @@ This release closes that milestone with **439 merged pull requests**. ### Fixed +- **middleware:** Tool-output budgeting no longer hides a failed shell exit from + subagent evidence. A bash result between `externalize_min_chars` (12,000) and + the sandbox limit (20,000) was replaced by a preview ending in its `Access:` + footer, so the trailing `Exit Code: N` was no longer last, + `_bash_evidence_status` fell back to `deerflow_tool_meta` (`success`), and a + failed `pytest` whose output still said `12 passed` could satisfy a + `tests_passed` acceptance criterion. `ToolOutputBudgetMiddleware` now + re-appends the original trailing `Exit Code: N` / `Command exited with code N` + after the preview, and the storage-unavailable fallback reserves it from the + `fallback_max_chars` budget the way sandbox truncation does. Only `bash` and + `bash_tool` results are affected; the persisted full output is unchanged. + ([#6354]) - **make:** `make clean` now says what it deletes and refuses to run under a live Docker Gateway. `make help` described it as cleaning up "temporary files", but it deletes `backend/.deer-flow`: the local database, users, threads, uploads, @@ -8828,3 +8840,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#6347]: https://github.com/bytedance/deer-flow/pull/6347 [#6350]: https://github.com/bytedance/deer-flow/pull/6350 [#6351]: https://github.com/bytedance/deer-flow/pull/6351 +[#6354]: https://github.com/bytedance/deer-flow/pull/6354 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 4053062c45..f7b88dad35 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -558,6 +558,15 @@ ### 修复 +- **中间件:** 工具输出预算不再让子智能体证据丢失失败的 shell 退出码。长度介于 + `externalize_min_chars`(12,000)与沙箱上限(20,000)之间的 bash 结果会被替换为 + 以 `Access:` 页脚结尾的预览,结尾的 `Exit Code: N` 不再位于最后, + `_bash_evidence_status` 退回到报告 `success` 的 `deerflow_tool_meta`,输出里仍写着 + `12 passed` 的失败 `pytest` 可能满足 `tests_passed` 验收条件。现在 + `ToolOutputBudgetMiddleware` 会在预览之后重新追加原始结尾的 `Exit Code: N` / + `Command exited with code N`;存储不可用时的回退截断也像沙箱截断一样,从 + `fallback_max_chars` 预算中为它预留位置。仅影响 `bash` 与 `bash_tool` 的结果, + 落盘的完整输出保持不变。([#6354]) - **make:** `make clean` 现在会说明它删除的内容,并拒绝在运行中的 Docker Gateway 下执行。`make help` 此前称其清理"临时文件",但它实际删除 `backend/.deer-flow`:本地数据库、用户、线程、上传、记忆和密钥。 两套 Docker 栈都把该目录挂载进 `deer-flow-gateway` 容器,而 `make stop` 不会停止它,因此数据可能在 @@ -7309,3 +7318,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#6344]: https://github.com/bytedance/deer-flow/pull/6344 [#6350]: https://github.com/bytedance/deer-flow/pull/6350 [#6351]: https://github.com/bytedance/deer-flow/pull/6351 +[#6354]: https://github.com/bytedance/deer-flow/pull/6354 diff --git a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md index ff62018214..685274aed8 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md +++ b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md @@ -50,7 +50,7 @@ strict providers reject. 1. **InputSanitizationMiddleware** - First, so it is the outermost `wrap_model_call` wrapper; every inner middleware (including LLM retries) sees sanitized messages. `additional_kwargs.original_user_content` is server-owned provenance: Gateway strips caller-supplied values for non-internal run requests, trusted IM calls may carry the string they captured before adding transport/file context, and the middleware replaces any non-string value before wrapping. Uploads and sanitization retain first-writer-wins only for validated strings. Caller markers are marked `untrusted_input`, never stripped; scope is every turn. **KnowledgeScopeMiddleware** follows input sanitization: it exposes only Gateway-admitted execution scope, removes scope/display data from model messages, and blocks `knowledge_search` when disabled without reading storage or RAGFlow. -2. **ToolOutputBudgetMiddleware** - Budgets model-bound text/JSON with configured limits and exemptions; externalizes to thread outputs (`tool_output.storage_subdir`, default `.tool-results`, `TOOL_RESULTS_DIRNAME`) with a synopsis + `read_file` reference. Preserve media in mixed JSON results. Blob refs restore across hosts; hash/write failures use inline fallback. Internal outputs bypass workspace/delivery scans. Publication and model-only write elision: [contract](TOOL_OUTPUT_PERSISTENCE.md). Elision uses shared `tool_call_args`; retain `keep_recent_writes` newest writes. Controls: `elide_superseded_writes`, `superseded_write_min_chars`. +2. **ToolOutputBudgetMiddleware** - Budgets model-bound text/JSON with configured limits and exemptions; externalizes to thread outputs (`tool_output.storage_subdir`, default `.tool-results`, `TOOL_RESULTS_DIRNAME`) with a synopsis + `read_file` reference. Preserve media in mixed JSON results. Blob refs restore across hosts; hash/write failures use inline fallback. Internal outputs bypass workspace/delivery scans. Publication, write elision and bash exit marker: [contract](TOOL_OUTPUT_PERSISTENCE.md). Elision uses shared `tool_call_args`; retain `keep_recent_writes` newest writes. Controls: `elide_superseded_writes`, `superseded_write_min_chars`. 3. **ToolResultSanitizationMiddleware** - Neutralizes framework/injection tags (e.g. ``) and boundary markers in *remote-content* tool results (`web_fetch`/`web_search`/`image_search`/`web_capture`) so attacker-controlled fetched pages cannot forge trusted framework context. Mirrors `InputSanitizationMiddleware`'s user-input guardrail for the other untrusted-content entry point; sits inner of `ToolOutputBudgetMiddleware` (neutralizes the raw output, then the budget truncates). Local tool output (bash/read_file) is left untouched. Scope is a name-based allowlist for the first-party web tools, plus every MCP-sourced tool via its `deerflow_mcp` metadata tag, so an MCP server naming its fetcher `fetch_url` is still covered Result-rewriting middlewares between the raw callable boundary and the diff --git a/backend/packages/harness/deerflow/agents/middlewares/TOOL_OUTPUT_PERSISTENCE.md b/backend/packages/harness/deerflow/agents/middlewares/TOOL_OUTPUT_PERSISTENCE.md index 5680625188..9701476f1e 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/TOOL_OUTPUT_PERSISTENCE.md +++ b/backend/packages/harness/deerflow/agents/middlewares/TOOL_OUTPUT_PERSISTENCE.md @@ -43,6 +43,18 @@ configured fallback. Disabled budgets, raised limits and tool exemptions apply before provider normalization. Pure text/media results without JSON retain their existing pass-through behavior. +## Bash exit marker + +The subagent executor reads a bash result's exit status only from its end +(`Exit Code: N`, or a whole-output `Command exited with code N`); with no marker +it falls back to `deerflow_tool_meta`, which reports `success` for a nonzero +exit. A model-visible rewrite of a `bash`/`bash_tool` result must therefore keep +the original trailing marker last: the preview re-appends it after the `Access:` +footer, and the storage-unavailable fallback reserves it from `max_chars` like +sandbox truncation. The local marker regex mirrors +`sandbox.tools._BASH_EXIT_MARKER_TAIL_RE` and a test pins them together. The +persisted output is the original bytes; other tools are not rewritten. + ## Unclean shutdown SIGKILL, OOM termination, and host failure bypass exception cleanup and can leave diff --git a/backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py index fd60fe53a5..e330c7646b 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py @@ -29,6 +29,7 @@ import json import logging import os import posixpath +import re import shlex import tempfile import uuid @@ -81,6 +82,15 @@ _MAX_TOOL_OUTPUT_BLOB_BYTES = 64 * 1024 * 1024 # even when the operator disabled the ordinary disk-unavailable fallback. _DURABLE_FAILURE_FALLBACK_MAX_CHARS = 30_000 +# Mirrors ``sandbox.tools._BASH_EXIT_MARKER_TAIL_RE`` (pinned by a test). The +# subagent executor reads the exit status only from the end of a bash result +# (trailing ``Exit Code: N``, or whole-output ``Command exited with code N``), +# so a budget rewrite must keep the marker last or a failed command is harvested +# as success. Restricted to the same names ``executor._BASH_EVIDENCE_TOOL_NAMES`` +# uses; other tools may print the phrase as ordinary text. +_BASH_EXIT_MARKER_TAIL_RE = re.compile(r"(?:\nExit Code: -?\d+|\n?Command exited with code -?\d+)\s*$") +_EXIT_MARKER_TOOL_NAMES = frozenset({"bash", "bash_tool"}) + class _BudgetedContent(NamedTuple): replacement: str @@ -352,6 +362,32 @@ def _externalize_to_sandbox( # --------------------------------------------------------------------------- +def _canonical_exit_marker(content: str) -> str | None: + """Return the trailing bash exit marker as a single line, or ``None``.""" + match = _BASH_EXIT_MARKER_TAIL_RE.search(content) + if match is None: + return None + return match.group(0).strip() + + +def _keep_exit_marker_last(original: str, rewritten: str, *, tool_name: str) -> str: + """Re-append the original trailing exit marker after a budget rewrite. + + Preview construction always ends with an Access footer, so the harvest + regex (``Exit Code: N`` anchored at the end) would otherwise miss the + status and fall back to ``deerflow_tool_meta``'s success. Restricted to + bash-family tools; other tools' text is left alone. + """ + if tool_name not in _EXIT_MARKER_TOOL_NAMES: + return rewritten + marker = _canonical_exit_marker(original) + if marker is None: + return rewritten + if rewritten.rstrip().endswith(marker): + return rewritten + return f"{rewritten.rstrip()}\n{marker}" + + def _build_preview( content: str, *, @@ -361,13 +397,14 @@ def _build_preview( tail_chars: int, ) -> str: """Build a typed synopsis preview with a file reference for externalized output.""" - return render_tool_output_preview( + preview = render_tool_output_preview( content, tool_name=tool_name, virtual_path=virtual_path, head_chars=head_chars, tail_chars=tail_chars, ) + return _keep_exit_marker_last(content, preview, tool_name=tool_name) def _build_fallback( @@ -381,26 +418,45 @@ def _build_fallback( """Build a head+tail truncation when disk persistence is unavailable. The returned string is guaranteed to be no longer than *max_chars*. + A trailing bash exit marker (``Exit Code: N`` / ``Command exited with + code N``) is reserved from the budget the way sandbox truncation does, + so harvest can still recover the shell status. Only when *max_chars* + is smaller than the marker line (including its leading newline) does + the marker drop. """ total = len(content) if max_chars <= 0 or total <= max_chars: return content + preserved = "" + body = content + if tool_name in _EXIT_MARKER_TOOL_NAMES: + match = _BASH_EXIT_MARKER_TAIL_RE.search(content) + if match is not None: + line = f"\n{match.group(0).strip()}" + if len(line) <= max_chars: + body = content[: match.start()] + preserved = line + max_chars -= len(preserved) + if len(body) <= max_chars: + return body + preserved + total = len(body) + marker_template = "\n\n[... {n} chars omitted from {tn} output. Persistent storage unavailable. Consider narrowing the query or using more specific parameters.]\n\n" marker_overhead = len(marker_template.format(n=total, tn=tool_name)) if marker_overhead >= max_chars: - return content[:max_chars] + return body[:max_chars] + preserved budget = max_chars - marker_overhead effective_head = min(head_chars, budget) effective_tail = min(tail_chars, max(0, budget - effective_head)) - head_end = _snap_to_line_boundary(content, min(effective_head, total)) - tail_start = _snap_start_to_line_boundary(content, max(head_end, total - effective_tail)) + head_end = _snap_to_line_boundary(body, min(effective_head, total)) + tail_start = _snap_start_to_line_boundary(body, max(head_end, total - effective_tail)) - head = content[:head_end] - tail = content[tail_start:] if tail_start < total else "" + head = body[:head_end] + tail = body[tail_start:] if tail_start < total else "" omitted = total - len(head) - len(tail) marker = marker_template.format(n=omitted, tn=tool_name) @@ -408,7 +464,7 @@ def _build_fallback( parts = [head, marker] if tail: parts.append(tail) - return "".join(parts) + return "".join(parts) + preserved # --------------------------------------------------------------------------- diff --git a/backend/tests/test_tool_output_budget_middleware.py b/backend/tests/test_tool_output_budget_middleware.py index 9a7da24699..fa6022b344 100644 --- a/backend/tests/test_tool_output_budget_middleware.py +++ b/backend/tests/test_tool_output_budget_middleware.py @@ -3,7 +3,8 @@ Covers: pass-through, disk externalization, fallback truncation, UTF-8 boundaries, Command results, model-request history patching, superseded write_file payload elision (issue #5328), config variations, exempt tools, -per-tool overrides, edge cases, and both sync/async code paths. +per-tool overrides, bash exit-marker preservation after budget rewrites, +edge cases, and both sync/async code paths. """ from __future__ import annotations @@ -11,11 +12,13 @@ from __future__ import annotations import contextlib import csv import hashlib +import importlib.util import io import json import os import pathlib import re +import sys import tempfile from types import SimpleNamespace @@ -25,12 +28,14 @@ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langgraph.types import Command from deerflow.agents.middlewares.tool_output_budget_middleware import ( + _BASH_EXIT_MARKER_TAIL_RE, TOOL_OUTPUT_BLOB_KEY, ToolOutputBudgetMiddleware, _build_fallback, _build_preview, _effective_trigger, _externalize, + _keep_exit_marker_last, _message_text, _needs_budget, _patch_model_messages, @@ -99,6 +104,22 @@ def _tm(content: str = "ok", name: str = "tool", tool_call_id: str = "tc-1") -> return ToolMessage(content=content, name=name, tool_call_id=tool_call_id) +def _pytest_like_output(exit_line: str = "Exit Code: 1") -> str: + """Pytest-shaped bash output in the default 12k–20k budget window.""" + return "============================= test session starts =============================\n" + ("collected 12 items\n" + "." * 12 + "\n") * 500 + f"12 passed in 1.23s\n{exit_line}" + + +def _bash_tm(content: str, name: str = "bash") -> ToolMessage: + return ToolMessage( + content=content, + name=name, + tool_call_id="tc-1", + id="tool-msg-1", + artifact={"k": "v"}, + additional_kwargs={"deerflow_tool_meta": {"status": "success", "source": "normalized"}}, + ) + + # =========================================================================== # Unit tests for helper functions # =========================================================================== @@ -797,6 +818,185 @@ class TestBuildFallback: assert len(result) <= 50 +# =========================================================================== +# Bash exit-marker preservation (harvest reads Exit Code: N from the end) +# =========================================================================== + + +class TestBashExitMarkerPreservation: + """Budget rewrites must keep a trailing bash exit marker last. + + The subagent executor harvests status with ``Exit Code: N\\s*$``. Preview + construction always ends with an Access footer, so without this the + marker is no longer last and a failed command is harvested as success. + """ + + def test_keep_exit_marker_last_appends_for_bash(self): + original = "out\nExit Code: 1" + rewritten = "preview\nAccess:\n- Use read_file" + assert _keep_exit_marker_last(original, rewritten, tool_name="bash") == "preview\nAccess:\n- Use read_file\nExit Code: 1" + + def test_keep_exit_marker_last_is_noop_for_other_tools(self): + original = "out\nExit Code: 1" + rewritten = "preview\nAccess:\n- Use read_file" + assert _keep_exit_marker_last(original, rewritten, tool_name="web_fetch") == rewritten + + def test_keep_exit_marker_last_is_noop_when_already_last(self): + original = "out\nExit Code: 0" + rewritten = "preview\nExit Code: 0" + assert _keep_exit_marker_last(original, rewritten, tool_name="bash") == rewritten + + def test_wrap_tool_call_keeps_failed_exit_marker_last(self): + content = _pytest_like_output("Exit Code: 1") + assert 12_000 < len(content) <= 20_000 + with tempfile.TemporaryDirectory() as tmpdir: + mw = ToolOutputBudgetMiddleware(config=ToolOutputConfig()) + msg = _bash_tm(content) + result = mw.wrap_tool_call(_make_request(tool_name="bash", outputs_path=tmpdir), lambda _: msg) + + assert isinstance(result, ToolMessage) + assert result is not msg + assert str(result.content).rstrip().endswith("Exit Code: 1") + assert "Access:" in result.content + assert "read_file" in result.content + assert result.additional_kwargs["deerflow_tool_meta"] == {"status": "success", "source": "normalized"} + assert result.artifact == {"k": "v"} + assert result.id == "tool-msg-1" + + @pytest.mark.anyio + async def test_awrap_tool_call_keeps_failed_exit_marker_last(self): + content = _pytest_like_output("Exit Code: 1") + with tempfile.TemporaryDirectory() as tmpdir: + mw = ToolOutputBudgetMiddleware(config=ToolOutputConfig()) + msg = _bash_tm(content) + + async def handler(_): + return msg + + result = await mw.awrap_tool_call(_make_request(tool_name="bash", outputs_path=tmpdir), handler) + + assert str(result.content).rstrip().endswith("Exit Code: 1") + assert "Access:" in result.content + assert result.artifact == {"k": "v"} + assert result.id == "tool-msg-1" + + def test_wrap_tool_call_keeps_successful_exit_marker_last(self): + content = _pytest_like_output("Exit Code: 0") + with tempfile.TemporaryDirectory() as tmpdir: + mw = ToolOutputBudgetMiddleware(config=ToolOutputConfig()) + result = mw.wrap_tool_call(_make_request(tool_name="bash", outputs_path=tmpdir), lambda _: _bash_tm(content)) + assert str(result.content).rstrip().endswith("Exit Code: 0") + assert "Access:" in result.content + + def test_wrap_tool_call_without_marker_ends_at_access_footer(self): + content = _pytest_like_output("12 failed in 1.23s") + assert not content.rstrip().endswith("Exit Code: 1") + with tempfile.TemporaryDirectory() as tmpdir: + mw = ToolOutputBudgetMiddleware(config=ToolOutputConfig()) + result = mw.wrap_tool_call(_make_request(tool_name="bash", outputs_path=tmpdir), lambda _: _bash_tm(content)) + assert "Access:" in result.content + assert "read_file" in result.content + assert not str(result.content).rstrip().endswith("Exit Code: 1") + assert "Exit Code:" not in str(result.content).rsplit("Access:", 1)[-1] + + def test_non_bash_tool_does_not_promote_exit_marker(self): + content = _pytest_like_output("Exit Code: 1") + with tempfile.TemporaryDirectory() as tmpdir: + mw = ToolOutputBudgetMiddleware(config=ToolOutputConfig()) + result = mw.wrap_tool_call( + _make_request(tool_name="web_fetch", outputs_path=tmpdir), + lambda _: _bash_tm(content, name="web_fetch"), + ) + assert "Access:" in result.content + assert not str(result.content).rstrip().endswith("Exit Code: 1") + + def test_bash_tool_alias_also_preserves_marker(self): + content = _pytest_like_output("Exit Code: 1") + with tempfile.TemporaryDirectory() as tmpdir: + mw = ToolOutputBudgetMiddleware(config=ToolOutputConfig()) + result = mw.wrap_tool_call( + _make_request(tool_name="bash_tool", outputs_path=tmpdir), + lambda _: _bash_tm(content, name="bash_tool"), + ) + assert str(result.content).rstrip().endswith("Exit Code: 1") + + def test_fallback_tiny_budget_keeps_marker_and_stays_within_max_chars(self): + content = _pytest_like_output("Exit Code: 1") + result = _build_fallback(content, tool_name="bash", max_chars=80, head_chars=8000, tail_chars=3000) + assert len(result) <= 80 + assert result.rstrip().endswith("Exit Code: 1") + + def test_fallback_default_budget_leaves_payload_unchanged(self): + content = _pytest_like_output("Exit Code: 1") + result = _build_fallback(content, tool_name="bash", max_chars=30_000, head_chars=8000, tail_chars=3000) + assert result == content + assert result.rstrip().endswith("Exit Code: 1") + + def test_fallback_never_exceeds_max_chars_with_exit_marker(self): + content = _pytest_like_output("Exit Code: 1") + marker_line = "\nExit Code: 1" + for max_chars in [10, len(marker_line) - 1, len(marker_line), 20, 80, 200, 500, 1000, 5000, 20000]: + result = _build_fallback(content, tool_name="bash", max_chars=max_chars, head_chars=max_chars // 2, tail_chars=max_chars // 4) + assert len(result) <= max_chars, f"max_chars={max_chars}: got {len(result)}" + if max_chars >= len(marker_line): + assert result.rstrip().endswith("Exit Code: 1"), f"max_chars={max_chars}" + else: + assert not result.rstrip().endswith("Exit Code: 1") + + def test_exit_marker_regex_matches_sandbox_truncation_tail_shapes(self): + from deerflow.sandbox.tools import _BASH_EXIT_MARKER_TAIL_RE as sandbox_re + + for content in ("out\nExit Code: 1", "out\nExit Code: -9 \n", "out\nCommand exited with code 3", "Command exited with code 3"): + ours = _BASH_EXIT_MARKER_TAIL_RE.search(content) + theirs = sandbox_re.search(content) + assert ours is not None and theirs is not None + assert ours.start() == theirs.start(), content + + def test_budgeted_failed_pytest_is_harvested_as_error_not_pass(self): + """End-to-end: wrap_tool_call rewrite → harvest status=error → leaf holds=False. + + ``tests/conftest.py`` mocks ``deerflow.subagents.executor``, so the + production harvest helpers are loaded under a unique module name. + """ + path = pathlib.Path(__file__).parents[1] / "packages/harness/deerflow/subagents/executor.py" + spec = importlib.util.spec_from_file_location("_budget_exit_marker_executor", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + try: + sys.modules[spec.name] = module + spec.loader.exec_module(module) + from deerflow.subagents import acceptance_checks + + content = _pytest_like_output("Exit Code: 1") + with tempfile.TemporaryDirectory() as tmpdir: + mw = ToolOutputBudgetMiddleware(config=ToolOutputConfig()) + result = mw.wrap_tool_call(_make_request(tool_name="bash", outputs_path=tmpdir), lambda _: _bash_tm(content)) + + assert str(result.content).rstrip().endswith("Exit Code: 1") + cmd = "pytest -q" + ai = AIMessage( + content="", + tool_calls=[{"name": "bash", "args": {"command": cmd}, "id": "tc-1", "type": "tool_call"}], + ) + state = {"messages": [HumanMessage(content="task"), ai, result]} + executions = module._harvest_bash_executions(state) + assert executions, "harvest returned no bash executions" + for entry in executions: + entry["shell_persistent"] = False + latest = executions[-1] + assert latest["status"] == "error" + assert latest["status_marker"] == "Exit Code: 1" + leaf = acceptance_checks._check_tests_passed_leaf(cmd, executions) + assert leaf["checked"] is True + assert leaf["holds"] is False + finally: + sys.modules.pop(spec.name, None) + # Absent when exec_module failed part-way; don't mask that error. + shutdown = getattr(module, "_shutdown_isolated_subagent_loop", None) + if shutdown is not None: + shutdown() + + # =========================================================================== # Middleware integration tests — wrap_tool_call # ===========================================================================