diff --git a/backend/apps/agents/manager/session/history_compaction.py b/backend/apps/agents/manager/session/history_compaction.py index 9b3a9ec3..7a52d267 100644 --- a/backend/apps/agents/manager/session/history_compaction.py +++ b/backend/apps/agents/manager/session/history_compaction.py @@ -1,6 +1,6 @@ import json import logging -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple from typeguard import typechecked import os import re @@ -22,6 +22,11 @@ SESSION_RECAP_CLOSE = "" # Per-turn caps so the re-grounded recap stays compact (summaries, not replays) and cannot reinflate the context window from one giant tool input/output. RECAP_TOOL_INPUT_CAP = 200 RECAP_TOOL_RESULT_CAP = 500 +# Whole-trail budget: a rebuilt session must start far below the compaction trigger, whatever the chat's length. ~10K tokens. +RECAP_TRAIL_MAX_CHARS = 40_000 +# Older calls survive as bare stubs (the command, so it can be re-run) inside this second budget; older still fold to a count. +RECAP_STUB_MAX_CHARS = 24_000 +RECAP_STUB_CHARS = 120 # 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 @@ -133,7 +138,7 @@ def get_branch_messages(session) -> List: @typechecked -def trail_lines(messages, cutoff_msg_id: Optional[str] = None) -> List[str]: +def trail_lines(messages, cutoff_msg_id: Optional[str] = None, max_trail_chars: int = RECAP_TRAIL_MAX_CHARS) -> List[str]: """The user's asks and the tool trail, and NEVER a line of model-authored prose. Extracted so every renderer bound for a model's context shares one definition of what is safe @@ -163,7 +168,67 @@ def trail_lines(messages, cutoff_msg_id: Optional[str] = None) -> List[str]: tool_name = m.content.get("tool_name") if isinstance(m.content, dict) else None label = f"Tool result ({tool_name})" if tool_name else "Tool result" lines.append(f"{label}: {strip_forged_sentinels(body)}") - return lines + return bound_trail(lines, max_trail_chars) + + +@typechecked +def bound_trail(lines: List[str], max_trail_chars: int) -> List[str]: + """Three tiers, newest first: full lines within `max_trail_chars`; then bare re-runnable call + stubs (no result lines, each cut to RECAP_STUB_CHARS) within RECAP_STUB_MAX_CHARS; then ONE + counted line for everything older. Asks are never dropped. Aging alone left one full line per + tool call forever: a real 766-call chat rebuilt with a 98K-token recap, so its first request was + 192K tokens against a 180K trigger and the CLI compacted before the second tool call, every time + (the Recall Radar thrash, 2026-09-01).""" + p_tool = [i for i, l in enumerate(lines) if l.startswith("Tool call") or l.startswith("Tool result")] + if not p_tool: + return lines + p_spent = 0 + p_full_from = len(p_tool) + for k in range(len(p_tool) - 1, -1, -1): + p_spent += len(lines[p_tool[k]]) + 1 + if p_spent > max_trail_chars: + break + p_full_from = k + if p_full_from == 0: + return lines + p_stub: Dict[int, str] = {} + p_stub_spent = 0 + p_stub_from = p_full_from + for k in range(p_full_from - 1, -1, -1): + i = p_tool[k] + if not lines[i].startswith("Tool call"): + continue + p_line = lines[i][:RECAP_STUB_CHARS] + if p_stub_spent + len(p_line) + 1 > RECAP_STUB_MAX_CHARS: + break + p_stub_spent += len(p_line) + 1 + p_stub[i] = p_line + p_stub_from = k + p_folded_idx = p_tool[:p_stub_from] + p_counts: Dict[str, int] = {} + for i in p_folded_idx: + if lines[i].startswith("Tool call: "): + p_name = lines[i][len("Tool call: "):].split("(", 1)[0].strip() or "tool" + p_counts[p_name] = p_counts.get(p_name, 0) + 1 + p_calls = sum(p_counts.values()) + p_by_tool = ", ".join(f"{n} {c}" for n, c in sorted(p_counts.items(), key=lambda kv: -kv[1])[:8]) + p_fold = f"[{p_calls} earlier tool calls are not shown to save space" + (f": {p_by_tool}" if p_by_tool else "") + "]" + p_folded = set(p_folded_idx) + p_middle = set(p_tool[p_stub_from:p_full_from]) + p_out: List[str] = [] + p_fold_written = p_calls == 0 + for i, l in enumerate(lines): + if i in p_folded: + if not p_fold_written: + p_out.append(p_fold) + p_fold_written = True + continue + if i in p_middle: + if i in p_stub: + p_out.append(p_stub[i]) + continue + p_out.append(l) + return p_out @typechecked diff --git a/backend/tests/test_recap_trail_is_bounded.py b/backend/tests/test_recap_trail_is_bounded.py new file mode 100644 index 00000000..331e27bd --- /dev/null +++ b/backend/tests/test_recap_trail_is_bounded.py @@ -0,0 +1,68 @@ +"""The recap's tool trail must be bounded whatever the chat's length. + +Aging kept one line per tool call forever. Eric's real Recall Radar chat (766 tool calls) rebuilt +with a 311,990-char / 97,923-token recap, so its first request was 192,056 tokens against a 180,000 +trigger: the CLI compacted before the second tool call, the window refilled, and the chat died of +"Autocompact is thrashing" five times on exp.5 and again, identically, on exp.3 (2026-09-01).""" +from backend.apps.agents.core.models import Message +from backend.apps.agents.manager.session.history_compaction import ( + RECAP_STUB_MAX_CHARS, RECAP_TRAIL_MAX_CHARS, bound_trail, build_history_prefix, trail_lines, +) + + +def p_long_chat(calls: int): + msgs = [Message(role="user", content="Build the recall app, for real.", branch_id="main")] + for i in range(calls): + tool = "Bash" if i % 3 else "Read" + msgs.append(Message(role="tool_call", content={"tool": tool, "input": {"command": f"step {i} " + "x" * 120}}, branch_id="main")) + msgs.append(Message(role="tool_result", content={"tool_name": tool, "text": f"result {i} " + "y" * 300}, branch_id="main")) + if i % 100 == 99: + msgs.append(Message(role="user", content=f"checkpoint ask {i}", branch_id="main")) + msgs.append(Message(role="user", content="what's the progress?", branch_id="main")) + return msgs + + +def test_an_800_call_chat_rebuilds_under_the_budget_and_says_what_it_dropped(): + lines = trail_lines(p_long_chat(800)) + p_tool = [l for l in lines if l.startswith("Tool ")] + assert sum(len(l) + 1 for l in p_tool) <= RECAP_TRAIL_MAX_CHARS + RECAP_STUB_MAX_CHARS, "full tier plus stub tier is the whole tool budget" + p_fold = [l for l in lines if "earlier tool calls are not shown" in l] + assert len(p_fold) == 1, "exactly one counted fold line" + assert "Bash" in p_fold[0] and "Read" in p_fold[0], "the fold names what it dropped, by tool" + assert lines.index(p_fold[0]) < lines.index(p_tool[0]), "the fold sits where the dropped span was, before the kept tail" + + +def test_every_ask_survives_and_the_newest_calls_stay_verbatim(): + msgs = p_long_chat(800) + lines = trail_lines(msgs) + asks = [l for l in lines if l.startswith("The user asked")] + assert len(asks) == 1 + 8 + 1, "no ask is ever folded" + assert any("step 799 " in l for l in lines), "the newest call is verbatim" + assert any("result 799 " in l for l in lines), "the newest result is verbatim" + assert not any("step 5 " in l for l in lines), "the oldest call is folded away" + + +def test_a_short_chat_is_untouched(): + msgs = p_long_chat(20) + lines = trail_lines(msgs) + assert not any("not shown" in l for l in lines) + assert sum(1 for l in lines if l.startswith("Tool call")) == 20 + + +def test_the_whole_prefix_is_bounded_for_the_real_shape(): + out = build_history_prefix(p_long_chat(766)) + assert len(out) < RECAP_TRAIL_MAX_CHARS + RECAP_STUB_MAX_CHARS + 8_000, f"prefix is {len(out)} chars; the frame plus a bounded trail" + + +def test_the_middle_tier_keeps_bare_rerunnable_stubs_and_no_result_lines(): + lines = trail_lines(p_long_chat(800)) + p_first_full = next(i for i, l in enumerate(lines) if l.startswith("Tool result")) + p_stubs = [l for l in lines[:p_first_full] if l.startswith("Tool call")] + assert p_stubs, "an 800-call chat has a middle tier of bare stubs" + assert all(len(l) <= 120 for l in p_stubs) + assert not any(l.startswith("Tool result") for l in lines[:p_first_full]) + + +def test_bound_trail_is_a_no_op_under_budget(): + lines = ["The user asked: a", "Tool call: Read(x)", "Tool result (Read): ok"] + assert bound_trail(lines, 10_000) == lines