[eric] agents: crossing the compact threshold now actually trims next turn, and a pasted log can't ride the recap back over the wall

This commit is contained in:
ciregenz
2026-08-03 15:47:18 -07:00
parent 9eef9d6a1d
commit b29c75d5e0
4 changed files with 68 additions and 6 deletions
+4 -4
View File
@@ -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:
@@ -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,
@@ -39,6 +39,15 @@ def wrap_platform_note(body: str) -> str:
P_SENTINEL_TAG_RE = re.compile(r"</?openswarm_(?:platform_note|session_recap)\b[^>]*>")
@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":
+51
View File
@@ -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