From 4035d70bbebecb541a42913b0189695bdcf49501 Mon Sep 17 00:00:00 2001 From: Stephen Schoettler Date: Fri, 29 May 2026 23:18:22 -0700 Subject: [PATCH] fix(context-engine): honor quiet compaction status --- agent/context_engine.py | 60 ++++++++++ agent/conversation_compression.py | 26 ++++- agent/conversation_loop.py | 1 + tests/run_agent/test_413_compression.py | 143 ++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 3 deletions(-) diff --git a/agent/context_engine.py b/agent/context_engine.py index eafbb5ddc2d8..f098093d61cc 100644 --- a/agent/context_engine.py +++ b/agent/context_engine.py @@ -53,6 +53,39 @@ def sanitize_memory_context(memory_context: str) -> str: ) +def automatic_compaction_status_message( + engine: Any, + *, + phase: str, + default_message: str, + **context: Any, +) -> str | None: + """Resolve host-visible status for an automatic compaction event. + + Engines can suppress routine automatic status with + ``emit_automatic_compaction_status = False`` or customize it by defining + ``get_automatic_compaction_status_message(...)``. Empty strings and + ``None`` mean "do not emit a lifecycle status". + """ + if not getattr(engine, "emit_automatic_compaction_status", True): + return None + + formatter = getattr(engine, "get_automatic_compaction_status_message", None) + if callable(formatter): + message = formatter( + phase=phase, + default_message=default_message, + **context, + ) + else: + message = default_message + + if message is None: + return None + message = str(message).strip() + return message or None + + class ContextEngine(ABC): """Base class all context engines must implement.""" @@ -89,6 +122,12 @@ class ContextEngine(ABC): protect_first_n: int = 3 protect_last_n: int = 6 + # User-visible lifecycle status for automatic host-triggered compaction. + # Alternative engines that treat compaction as routine background + # maintenance can set this false to keep successful automatic passes silent; + # warnings, errors, and explicit manual commands should still surface. + emit_automatic_compaction_status: bool = True + # -- Core interface ---------------------------------------------------- @abstractmethod @@ -156,6 +195,27 @@ class ContextEngine(ABC): """ return False + def get_automatic_compaction_status_message( + self, + *, + phase: str, + default_message: str, + **context: Any, + ) -> str | None: + """Return user-visible status for automatic host-triggered compaction. + + Return ``None`` to suppress successful automatic lifecycle status for + this compaction event. ``phase`` identifies the host call site (for + example ``"preflight"`` or ``"compress"``). ``context`` contains + best-effort fields such as ``approx_tokens`` and ``threshold_tokens``. + + This hook does not control warning/error messages or explicit manual + commands such as ``/compress``. + """ + if not self.emit_automatic_compaction_status: + return None + return default_message + # -- Optional: manual /compress preflight ------------------------------ def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool: diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 3427295cf0f2..3e90eefd6b1f 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -42,7 +42,10 @@ from datetime import datetime from pathlib import Path from typing import Any, Optional, Tuple -from agent.context_engine import sanitize_memory_context +from agent.context_engine import ( + automatic_compaction_status_message, + sanitize_memory_context, +) from agent.model_metadata import estimate_request_tokens_rough logger = logging.getLogger(__name__) @@ -1143,7 +1146,20 @@ def compress_context( f"{approx_tokens:,}" if approx_tokens else "unknown", agent.model, focus_topic, ) - agent._emit_status(COMPACTION_STATUS) + _compaction_status = COMPACTION_STATUS + if not force: + _compaction_status = automatic_compaction_status_message( + agent.context_compressor, + phase="compress", + default_message=_compaction_status, + approx_tokens=approx_tokens, + message_count=_pre_msg_count, + model=agent.model, + focus_topic=focus_topic, + ) + _compaction_status_emitted = bool(_compaction_status) + if _compaction_status: + agent._emit_status(_compaction_status) _compaction_done_emitted = False def _complete_compaction_lifecycle() -> None: @@ -1151,7 +1167,11 @@ def compress_context( if _compaction_done_emitted: return _compaction_done_emitted = True - _emit_compaction_done(agent) + # A suppressed start (quiet context engine) opened no visible + # compaction phase — emit no terminal edge either. Failure warnings + # go through agent._emit_warning and are never suppressed here. + if _compaction_status_emitted: + _emit_compaction_done(agent) # ── Compression lock ──────────────────────────────────────────────── # Atomic, state.db-backed lock per session_id. Without this, two diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 6d391e86136d..35927b4eff03 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -36,6 +36,7 @@ from agent.conversation_compression import ( PRE_API_COMPRESSION_STATUS_TEMPLATE, conversation_history_after_compression, ) +from agent.context_engine import automatic_compaction_status_message from agent.display import KawaiiSpinner from agent.error_classifier import FailoverReason, classify_api_error from agent.iteration_budget import IterationBudget diff --git a/tests/run_agent/test_413_compression.py b/tests/run_agent/test_413_compression.py index 11c8cecc19fa..7ae217e74687 100644 --- a/tests/run_agent/test_413_compression.py +++ b/tests/run_agent/test_413_compression.py @@ -774,6 +774,57 @@ class TestPreflightCompression: assert new_system_prompt == "rebuilt without memory" build_prompt.assert_called_once_with("system prompt") + def test_compress_context_suppresses_automatic_status_when_engine_opts_out(self, agent): + """Plugin engines can make successful automatic compaction silent.""" + events = [] + agent.status_callback = lambda ev, msg: events.append((ev, msg)) + agent.context_compressor.emit_automatic_compaction_status = False + + def _fake_compress(messages, current_tokens=None, focus_topic=None): + events.append(("compress", "started")) + return [{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"}] + + with ( + patch.object(agent.context_compressor, "compress", side_effect=_fake_compress), + patch.object(agent, "_build_system_prompt", return_value="new system prompt"), + patch("run_agent.estimate_request_tokens_rough", return_value=42), + ): + compressed, new_system_prompt = agent._compress_context( + [{"role": "user", "content": "hello"}], + "system prompt", + approx_tokens=1234, + ) + + assert compressed == [{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"}] + assert new_system_prompt == "new system prompt" + assert events == [("compress", "started")] + + def test_compress_context_force_keeps_manual_status_when_engine_opts_out(self, agent): + """Manual /compress remains visible even for quiet automatic engines.""" + events = [] + agent.status_callback = lambda ev, msg: events.append((ev, msg)) + agent.context_compressor.emit_automatic_compaction_status = False + + def _fake_compress(messages, current_tokens=None, focus_topic=None, force=False): + events.append(("compress", "started")) + return [{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"}] + + with ( + patch.object(agent.context_compressor, "compress", side_effect=_fake_compress), + patch.object(agent, "_build_system_prompt", return_value="new system prompt"), + patch("run_agent.estimate_request_tokens_rough", return_value=42), + ): + agent._compress_context( + [{"role": "user", "content": "hello"}], + "system prompt", + approx_tokens=1234, + force=True, + ) + + assert events[0][0] == "lifecycle" + assert "Compacting context" in events[0][1] + assert events[1] == ("compress", "started") + def test_preflight_compresses_oversized_history(self, agent): """When loaded history exceeds the model's context threshold, compress before API call.""" agent.compression_enabled = True @@ -826,6 +877,98 @@ class TestPreflightCompression: for ev, msg in status_messages ) + def test_preflight_suppresses_status_when_context_engine_opts_out(self, agent): + """LCM-style engines can keep routine automatic preflight maintenance silent.""" + agent.compression_enabled = True + agent.context_compressor.context_length = 200_000 + agent.context_compressor.threshold_tokens = 100_000 + agent.context_compressor.emit_automatic_compaction_status = False + + big_history = [] + for i in range(20): + big_history.append({"role": "user", "content": f"Message {i} padded"}) + big_history.append({"role": "assistant", "content": f"Response {i} padded"}) + + ok_resp = _mock_response(content="After quiet preflight", finish_reason="stop") + agent.client.chat.completions.create.side_effect = [ok_resp] + status_messages = [] + agent.status_callback = lambda ev, msg: status_messages.append((ev, msg)) + + _rough_calls = {"n": 0} + + def _rough_estimate(*_args, **_kwargs): + _rough_calls["n"] += 1 + return 114_000 if _rough_calls["n"] == 1 else 40_000 + + with ( + patch("agent.conversation_loop.estimate_request_tokens_rough", side_effect=_rough_estimate), + patch.object(agent, "_compress_context") as mock_compress, + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + mock_compress.return_value = ( + [{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"}], + "new system prompt", + ) + result = agent.run_conversation("hello", conversation_history=big_history) + + mock_compress.assert_called_once() + assert result["completed"] is True + assert not any( + ev == "lifecycle" and "Preflight compression" in msg + for ev, msg in status_messages + ) + + def test_preflight_uses_context_engine_custom_status_message(self, agent): + """Plugin engines can replace generic built-in-compressor wording.""" + agent.compression_enabled = True + agent.context_compressor.context_length = 200_000 + agent.context_compressor.threshold_tokens = 100_000 + + def _custom_status(**kwargs): + assert kwargs["phase"] == "preflight" + assert kwargs["approx_tokens"] == 114_000 + assert kwargs["threshold_tokens"] == 100_000 + return "🔧 LCM context maintenance: preparing compacted context." + + agent.context_compressor.get_automatic_compaction_status_message = _custom_status + + big_history = [] + for i in range(20): + big_history.append({"role": "user", "content": f"Message {i} padded"}) + big_history.append({"role": "assistant", "content": f"Response {i} padded"}) + + ok_resp = _mock_response(content="After custom preflight", finish_reason="stop") + agent.client.chat.completions.create.side_effect = [ok_resp] + status_messages = [] + agent.status_callback = lambda ev, msg: status_messages.append((ev, msg)) + + _rough_calls = {"n": 0} + + def _rough_estimate(*_args, **_kwargs): + _rough_calls["n"] += 1 + return 114_000 if _rough_calls["n"] == 1 else 40_000 + + with ( + patch("agent.conversation_loop.estimate_request_tokens_rough", side_effect=_rough_estimate), + patch.object(agent, "_compress_context") as mock_compress, + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + mock_compress.return_value = ( + [{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"}], + "new system prompt", + ) + result = agent.run_conversation("hello", conversation_history=big_history) + + mock_compress.assert_called_once() + assert result["completed"] is True + lifecycle_messages = [msg for ev, msg in status_messages if ev == "lifecycle"] + assert "🔧 LCM context maintenance: preparing compacted context." in lifecycle_messages + assert not any("Preflight compression" in msg for msg in lifecycle_messages) + def test_preflight_defers_when_recent_real_usage_fit(self, agent): """A noisy rough estimate should not re-compact a recently fitting request.""" agent.compression_enabled = True