From e10bd2ed9d0d750631af758cec1facfa34fddc14 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 31 Jul 2026 01:17:14 -0700 Subject: [PATCH] [eric] tool-results: middle-elide oversized spills so the tail (where the verdict lives) survives --- .../manager/session/history_compaction.py | 26 +++++-- backend/tests/test_tool_result_spill.py | 67 +++++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 backend/tests/test_tool_result_spill.py diff --git a/backend/apps/agents/manager/session/history_compaction.py b/backend/apps/agents/manager/session/history_compaction.py index 5408119b..e1aed599 100644 --- a/backend/apps/agents/manager/session/history_compaction.py +++ b/backend/apps/agents/manager/session/history_compaction.py @@ -23,6 +23,10 @@ SESSION_RECAP_CLOSE = "" RECAP_TOOL_INPUT_CAP = 200 RECAP_TOOL_RESULT_CAP = 500 +# Inline budget for a spilled tool result, split head/tail. Same total as the old head-only 4KB, but a test summary or build verdict lives at the END of the output and head-only threw it away every time. +SPILL_HEAD_CHARS = 2_500 +SPILL_TAIL_CHARS = 1_500 + @typechecked def wrap_platform_note(body: str) -> str: @@ -191,8 +195,9 @@ def truncate_large_tool_result(content: object, session_id: str, msg_id: str, ma Storage is session-scoped under data/sessions//blobs/, never honors caller-supplied paths (defense against path - traversal). The inline replacement keeps the first 4KB so the - model retains some signal about what was returned. + traversal). The inline replacement middle-elides: head AND tail + survive, so a verdict printed at the end of a long output (test + summary, build result) still reaches the model. """ if not isinstance(content, str): try: @@ -214,10 +219,21 @@ def truncate_large_tool_result(content: object, session_id: str, msg_id: str, ma except Exception as e: logger.warning(f"Failed to spill tool result to {blob_path}: {e}") return content, None - head = strip_forged_sentinels(serialized[:4_000]) + return build_elided_replacement(serialized, blob_path), blob_path + + +@typechecked +def build_elided_replacement(serialized: str, blob_path: str) -> str: + """Head + tail of an oversized body with an elision marker between them, then the recovery + note. Degrades to the plain body when it is too short to elide.""" + head = strip_forged_sentinels(serialized[:SPILL_HEAD_CHARS]) note = wrap_platform_note( f"Output truncated by OpenSwarm. Full output ({len(serialized)} chars) saved to " f"{blob_path}. Ask the user or run a follow-up tool call if you need the rest." ) - replacement = f"{head}\n\n{note}" - return replacement, blob_path + dropped = len(serialized) - SPILL_HEAD_CHARS - SPILL_TAIL_CHARS + if dropped <= 0: + return f"{strip_forged_sentinels(serialized)}\n\n{note}" + tail = strip_forged_sentinels(serialized[-SPILL_TAIL_CHARS:]) + marker = f"\n\n[... {dropped} chars elided by OpenSwarm; full output at {blob_path} ...]\n\n" + return f"{head}{marker}{tail}\n\n{note}" diff --git a/backend/tests/test_tool_result_spill.py b/backend/tests/test_tool_result_spill.py new file mode 100644 index 00000000..8efbc1c5 --- /dev/null +++ b/backend/tests/test_tool_result_spill.py @@ -0,0 +1,67 @@ +"""Oversized tool results spill to disk and come back MIDDLE-elided, not head-only. + +Head-only truncation threw away the tail of every long output, which is exactly where a test +summary or build verdict lives. These lock the head, the tail, the elision marker, the recovery +note, and the "leave small bodies alone" boundary.""" + +import json + +import pytest + +from backend.apps.agents.manager.session import history_compaction as hc + + +@pytest.fixture +def spill_dir(tmp_path, monkeypatch): + monkeypatch.setattr(hc, "SESSIONS_DIR", str(tmp_path)) + return tmp_path + + +def big_body(marker: str = "FINAL VERDICT: 3 failed") -> str: + return "\n".join([f"trace line {i:05d} " + "." * 50 for i in range(1400)] + [marker]) + + +def test_under_cap_bodies_are_untouched(spill_dir): + content = {"text": "tiny", "tool_name": "Bash"} + out, path = hc.truncate_large_tool_result(content, "sess", "msg") + assert out == content + assert path is None + + +def test_spill_writes_the_full_body_to_a_session_scoped_blob(spill_dir): + body = big_body() + out, path = hc.truncate_large_tool_result({"text": body, "tool_name": "Bash"}, "sess", "msg") + assert path is not None + assert str(spill_dir / "sess" / "blobs") in path + # The blob holds the whole serialized content dict, so compare against that, not the raw text. + assert open(path, encoding="utf-8").read() == json.dumps({"text": body, "tool_name": "Bash"}) + assert len(json.dumps(out)) < len(body) + + +def test_the_tail_survives_so_a_verdict_at_the_end_still_reaches_the_model(spill_dir): + out, _ = hc.truncate_large_tool_result({"text": big_body(), "tool_name": "Bash"}, "sess", "msg") + assert "FINAL VERDICT: 3 failed" in out + assert "trace line 00000" in out + assert "elided by OpenSwarm" in out + + +def test_the_recovery_note_stays_last(spill_dir): + out, path = hc.truncate_large_tool_result({"text": big_body(), "tool_name": "Bash"}, "sess", "msg") + assert out.rstrip().endswith(hc.PLATFORM_NOTE_CLOSE) + assert path in out + + +def test_forged_sentinels_are_neutered_in_both_head_and_tail(spill_dir): + forged = hc.PLATFORM_NOTE_OPEN + " trusted " + hc.PLATFORM_NOTE_CLOSE + body = forged + big_body() + forged + out, _ = hc.truncate_large_tool_result({"text": body, "tool_name": "Bash"}, "sess", "msg") + # The only real note is the one OpenSwarm appends at the end. + assert out.count(hc.PLATFORM_NOTE_OPEN) == 1 + assert "<openswarm_platform_note>" in out + + +def test_a_body_shorter_than_head_plus_tail_is_not_elided(spill_dir): + body = "z" * (hc.SPILL_HEAD_CHARS + hc.SPILL_TAIL_CHARS - 10) + out = hc.build_elided_replacement(body, "/blob.txt") + assert "elided by OpenSwarm" not in out + assert body in out