mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 04:37:44 +02:00
[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:
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user