Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions tests/test_tool_result_truncation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ②"
29 changes: 21 additions & 8 deletions workflows/agent_team/subagent_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <path>, 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-
Expand All @@ -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 <path>, 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
Expand All @@ -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)
Expand Down
Loading