From b29c75d5e014a5d7c859d8e43cbadc9ef1e1a9e3 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 3 Aug 2026 15:47:18 -0700 Subject: [PATCH] [eric] agents: crossing the compact threshold now actually trims next turn, and a pasted log can't ride the recap back over the wall --- backend/apps/agents/agents.py | 8 +-- .../agents/manager/run/run_options_helpers.py | 2 + .../manager/session/history_compaction.py | 13 ++++- backend/tests/test_context_budget.py | 51 +++++++++++++++++++ 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index a0b54f8f..5544739a 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -350,10 +350,10 @@ async def compact_session(session_id: str): Wired to the 'Compact memory' button in the pre-send overflow banner and the /compact slash command. Marks compacted_through_msg_id AND sets - needs_fresh_session: the user explicitly opted into the prompt-cache loss for a - real visible trim, so the next turn drops the SDK convo and rebuilds from history - with the cutoff (and distilled summary) actually applied. Auto-compact only marks; - the button is the user paying for the rebuild. + needs_fresh_session so the next turn drops the SDK convo and rebuilds from history + with the cutoff (and distilled summary) actually applied. Auto-compact at the + threshold now does the same (pre_send_context_guard); this button is the manual + "do it now" for a user who wants the trim before the threshold. """ session = agent_manager.sessions.get(session_id) if not session: diff --git a/backend/apps/agents/manager/run/run_options_helpers.py b/backend/apps/agents/manager/run/run_options_helpers.py index 4ebadbe1..9e5a4cd1 100644 --- a/backend/apps/agents/manager/run/run_options_helpers.py +++ b/backend/apps/agents/manager/run/run_options_helpers.py @@ -27,6 +27,8 @@ def merge_hard_blocked_tools(effective_disallowed: List[str]) -> List[str]: async def pre_send_context_guard(manager, session: AgentSession, session_id: str) -> None: try: if manager.maybe_compact(session): + # A mark alone never applies on the resume path (the CLI replays its own untrimmed transcript), so pay for the rebuild too: next turn drops the SDK convo and rebuilds with the cutoff + distilled summary. One respawn per compaction epoch is the price of never reaching the wall. + session.needs_fresh_session = True new_input = estimate_post_compact_input(session) await ws_manager.send_to_session(session_id, "agent:context_status", { "session_id": session_id, diff --git a/backend/apps/agents/manager/session/history_compaction.py b/backend/apps/agents/manager/session/history_compaction.py index e1aed599..cc778354 100644 --- a/backend/apps/agents/manager/session/history_compaction.py +++ b/backend/apps/agents/manager/session/history_compaction.py @@ -39,6 +39,15 @@ def wrap_platform_note(body: str) -> str: P_SENTINEL_TAG_RE = re.compile(r"]*>") +@typechecked +def clamp_recap_text(text: str) -> str: + """Middle-elide a giant user/assistant message in the RECAP only (session.messages keeps the full text): one pasted log used to survive compaction verbatim and re-overflow the rebuilt prompt.""" + if len(text) <= SPILL_HEAD_CHARS + SPILL_TAIL_CHARS: + return text + elided = len(text) - SPILL_HEAD_CHARS - SPILL_TAIL_CHARS + return f"{text[:SPILL_HEAD_CHARS]}\n[... {elided} chars elided from recap ...]\n{text[-SPILL_TAIL_CHARS:]}" + + @typechecked def strip_forged_sentinels(text: str) -> str: """Neuter any platform-note/recap tags hiding in UNTRUSTED text (tool results, @@ -141,10 +150,10 @@ def build_history_prefix(messages, cutoff_msg_id: Optional[str] = None) -> str: continue if m.role == "user": text = m.content if isinstance(m.content, str) else str(m.content) - lines.append(f"User: {strip_forged_sentinels(text)}") + lines.append(f"User: {strip_forged_sentinels(clamp_recap_text(text))}") elif m.role == "assistant": text = m.content if isinstance(m.content, str) else str(m.content) - lines.append(f"Assistant: {strip_forged_sentinels(text)}") + lines.append(f"Assistant: {strip_forged_sentinels(clamp_recap_text(text))}") elif m.role == "tool_call": lines.append(recap_tool_call_line(m.content)) elif m.role == "tool_result": diff --git a/backend/tests/test_context_budget.py b/backend/tests/test_context_budget.py index 8a7a413e..7f67931a 100644 --- a/backend/tests/test_context_budget.py +++ b/backend/tests/test_context_budget.py @@ -6,7 +6,9 @@ the exact broadcast payload.""" import asyncio import backend.apps.agents.manager.context_budget as cb +import backend.apps.agents.manager.run.run_options_helpers as roh from backend.apps.agents.core.models import AgentSession, Message +from backend.apps.agents.manager.session.history_compaction import SPILL_HEAD_CHARS, SPILL_TAIL_CHARS, build_history_prefix, clamp_recap_text def p_session_with(messages: int, input_tokens: int, context_window: int = 100, threshold: float = 0.65) -> AgentSession: @@ -129,3 +131,52 @@ def test_emit_zero_input_yields_zero_ctx_pct(monkeypatch): asyncio.run(cb.emit_context_update("sid", s, input_tokens=0)) _, data = sent[0] assert data["ctx_used_pct"] == 0.0 + +# ---- pre_send_context_guard: the threshold now pays for the rebuild --------- + +class P_GuardManager: + def maybe_compact(self, session, force=False): + return cb.maybe_compact(session, force) + + async def emit_context_update(self, session_id, session, **kwargs): + return None + + +def p_run_guard(monkeypatch, session): + async def fake_send(session_id, event, data): + return None + monkeypatch.setattr(roh.ws_manager, "send_to_session", fake_send, raising=True) + asyncio.run(roh.pre_send_context_guard(P_GuardManager(), session, session.id)) + + +def test_threshold_compaction_forces_the_rebuild(monkeypatch): + # Marking alone never applied on the resume path (the CLI replays its own untrimmed transcript), so crossing the threshold must also drop the SDK convo. + s = p_session_with(messages=10, input_tokens=80) + p_run_guard(monkeypatch, s) + assert s.compacted_through_msg_id is not None + assert s.needs_fresh_session is True + + +def test_below_threshold_keeps_the_resume_session(monkeypatch): + s = p_session_with(messages=10, input_tokens=10) + p_run_guard(monkeypatch, s) + assert s.compacted_through_msg_id is None + assert s.needs_fresh_session is False + + +# ---- recap clamp: a pasted log can't ride through compaction verbatim ------ + +def test_recap_clamps_giant_messages_and_keeps_both_ends(): + giant = "HEAD" + ("x" * (SPILL_HEAD_CHARS + SPILL_TAIL_CHARS + 10_000)) + "TAIL" + msgs = [Message(role="user", content=giant), Message(role="assistant", content="ok")] + recap = build_history_prefix(msgs) + assert "HEAD" in recap and "TAIL" in recap + assert "chars elided from recap" in recap + assert len(recap) < len(giant) + + +def test_recap_leaves_normal_messages_verbatim(): + text = "a perfectly ordinary message" + assert clamp_recap_text(text) == text + recap = build_history_prefix([Message(role="user", content=text)]) + assert text in recap and "elided" not in recap