diff --git a/tests/test_tool_result_truncation.py b/tests/test_tool_result_truncation.py index 0116c30..3d0681f 100644 --- a/tests/test_tool_result_truncation.py +++ b/tests/test_tool_result_truncation.py @@ -492,3 +492,64 @@ def test_site3_keeps_the_self_pagination_pointer(processor, tool: str) -> None: # work instead of being handed the path or offset that continues it. assert "/spill/abc123.md" in out, f"{tool}: recovery pointer was cut" assert out == body, f"{tool}: body was shortened despite fitting gate ①" + + +_EXEC_TOOLS = ["bash", "run_python_code"] + + +@pytest.mark.parametrize("processor", _processors()) +@pytest.mark.parametrize("tool", _EXEC_TOOLS) +def test_the_exec_pointer_survives_the_default_gate_1( + processor, tool: str, spill_store +) -> None: + """gate ② must not cut below the gate ① preview it is protecting. + + ``bash``/``run_python_code`` overflow to disk at gate ① + (``meta.max_result_chars`` = ``tool_exec_result_max_chars``, 8K by default) + and the preview that comes back carries both the recovery pointer and, for + bash, the exit status. Those live at the tail. If gate ②'s budget for these + tools is a hardcoded number below gate ①, it takes the pointer and the + verdict with it and the agent is left with a truncated result it cannot + recover — the exact failure the 10K grep/glob entries above exist to prevent, + one layer down. + """ + from frontier_agent.core.loop_types import ToolResult + from frontier_agent.infra.config import get_config + from plugins.tools import _overflow + + cap = get_config().tool_exec_result_max_chars + body = _overflow.maybe_overflow(tool, _pytest_output(cap * 3)) + assert "saved read-only at" in body, f"{tool}: fixture did not overflow" + + out = processor.process(ToolResult( + name=tool, args={}, result=body, duration_ms=0, + tool_call_id="call-1", is_error=False, + )) + + assert "saved read-only at" in out, f"{tool}: gate ② cut gate ①'s recovery pointer" + assert "1 failed, 812 passed" in out, f"{tool}: gate ② cut gate ①'s verdict" + + +@pytest.mark.parametrize("processor", _processors()) +@pytest.mark.parametrize("tool", _EXEC_TOOLS) +def test_gate_2_follows_a_raised_exec_cap(processor, tool: str, monkeypatch) -> None: + """Raising ``TOOL_EXEC_RESULT_MAX_CHARS`` has to widen gate ② too. + + The cap is documented as tunable (docs/tool-result-truncation-ab.md runs the + A/B at 8000 vs 50000). A deployment that raises it to give long scans more + room must actually get more room; a fixed gate ② would silently cap the gain + at its own hardcoded number. + """ + from frontier_agent.core.loop_types import ToolResult + from frontier_agent.infra.config import get_config + + monkeypatch.setattr(get_config(), "tool_exec_result_max_chars", 50_000) + body = _pytest_output(40_000) + + out = processor.process(ToolResult( + name=tool, args={}, result=body, duration_ms=0, + tool_call_id="call-1", is_error=False, + )) + + # 50K + 2K headroom covers the 40K body, so nothing is cut. + assert out == body, f"{tool}: a raised gate ① did not widen gate ②" diff --git a/workflows/agent_team/subagent_runtime.py b/workflows/agent_team/subagent_runtime.py index fa720b7..ec3aceb 100644 --- a/workflows/agent_team/subagent_runtime.py +++ b/workflows/agent_team/subagent_runtime.py @@ -58,6 +58,7 @@ compaction_trigger_tokens, ) from frontier_agent.core.runtime.loop.tool_exec import ToolResultPostProcessor +from frontier_agent.infra.config import get_config from frontier_agent.state.event_store.sqlite import EventStore from plugins.tools._filesystem_state import current_filesystem_state from plugins.tools.bash import BASH_STDERR_SEPARATOR @@ -741,13 +742,6 @@ class SwarmToolResultPostProcessor(ToolResultPostProcessor): }) _BUDGETS: ClassVar[dict[str, int]] = { - "bash": 4_000, - "run_python_code": 5_000, - # Gate ① overflows these to disk at meta.max_result_chars=8K and puts the - # "saved to , use read_file" pointer at the TAIL. The 6K default cut - # below that and took the pointer with it, leaving a truncated result with - # no way back to the rest. 10K leaves the 8K body and its footer room to - # both survive — the value react has carried for this reason all along. "grep_search": 10_000, "glob_search": 10_000, # Belt-and-suspenders: collect_reports is whitelisted above (pass- @@ -756,6 +750,17 @@ class SwarmToolResultPostProcessor(ToolResultPostProcessor): # TOOL_RESULT_MAX_CHARS ceiling) rather than falling back to 6K. "collect_reports": 150_000, } + # bash / run_python_code overflow to disk at gate ①, which is configurable + # via TOOL_EXEC_RESULT_MAX_CHARS, and they carry two things worth keeping: + # the "saved to , use read_file" pointer and the exit status, both of + # which live at the tail. Gate ② must therefore track gate ① — a deployment + # that raises the exec cap to give long scans more room would otherwise have + # gate ② cut the extra room straight back off, and a deployment that raises + # it above the fixed 6K default would lose the recovery path entirely. Same + # rule, same headroom, as the React processor in + # workflows.stateful_react_agent._runtime. + _CONFIGURED_EXEC_TOOLS = frozenset({"bash", "run_python_code"}) + _EXEC_FOOTER_HEADROOM = 2_000 _BUDGET_DEFAULT = 6_000 _BASH_STDERR_KEEP = 3_000 @@ -777,7 +782,15 @@ def process(self, tool_result: ToolResult) -> str: if name in self._PASS_THROUGH: return content - budget = self._BUDGETS.get(name, self._BUDGET_DEFAULT) + if name in self._CONFIGURED_EXEC_TOOLS: + # Gate ① is configurable. Gate ② must track it or a deployment + # raising TOOL_EXEC_RESULT_MAX_CHARS can lose the overflow pointer. + budget = ( + get_config().tool_exec_result_max_chars + + self._EXEC_FOOTER_HEADROOM + ) + else: + budget = self._BUDGETS.get(name, self._BUDGET_DEFAULT) if name == "bash": return self._compact_bash(content, budget) return self._head_cap(content, budget)