From 4a3e3e20e5b2ed2fd0c2e727f8204efea4de8a5a Mon Sep 17 00:00:00 2001 From: revaraver <29756950+revaraver@users.noreply.github.com> Date: Wed, 29 Apr 2026 05:04:47 +0800 Subject: [PATCH] fix(compression): preserve iterative summary continuity --- agent/context_compressor.py | 43 ++++++++++-- ...t_context_compressor_summary_continuity.py | 67 +++++++++++++++++++ 2 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 tests/agent/test_context_compressor_summary_continuity.py diff --git a/agent/context_compressor.py b/agent/context_compressor.py index f9111f9600..7475c5f5d4 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -993,15 +993,39 @@ The user has requested that this compaction PRIORITISE preserving all informatio return None @staticmethod - def _with_summary_prefix(summary: str) -> str: - """Normalize summary text to the current compaction handoff format.""" + def _strip_summary_prefix(summary: str) -> str: + """Return summary body without the current or legacy handoff prefix.""" text = (summary or "").strip() - for prefix in (LEGACY_SUMMARY_PREFIX, SUMMARY_PREFIX): + for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX): if text.startswith(prefix): - text = text[len(prefix):].lstrip() - break + return text[len(prefix):].lstrip() + return text + + @classmethod + def _with_summary_prefix(cls, summary: str) -> str: + """Normalize summary text to the current compaction handoff format.""" + text = cls._strip_summary_prefix(summary) return f"{SUMMARY_PREFIX}\n{text}" if text else SUMMARY_PREFIX + @staticmethod + def _is_context_summary_content(content: Any) -> bool: + text = _content_text_for_contains(content).lstrip() + return text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX) + + @classmethod + def _find_latest_context_summary( + cls, + messages: List[Dict[str, Any]], + start: int, + end: int, + ) -> tuple[Optional[int], str]: + """Find the newest handoff summary inside a compression window.""" + for idx in range(end - 1, start - 1, -1): + content = messages[idx].get("content") + if cls._is_context_summary_content(content): + return idx, cls._strip_summary_prefix(_content_text_for_contains(content)) + return None, "" + # ------------------------------------------------------------------ # Tool-call / tool-result pair integrity helpers # ------------------------------------------------------------------ @@ -1308,6 +1332,15 @@ The user has requested that this compaction PRIORITISE preserving all informatio return messages turns_to_summarize = messages[compress_start:compress_end] + summary_idx, summary_body = self._find_latest_context_summary( + messages, + compress_start, + compress_end, + ) + if summary_idx is not None: + if summary_body and not self._previous_summary: + self._previous_summary = summary_body + turns_to_summarize = messages[summary_idx + 1:compress_end] if not self.quiet_mode: logger.info( diff --git a/tests/agent/test_context_compressor_summary_continuity.py b/tests/agent/test_context_compressor_summary_continuity.py new file mode 100644 index 0000000000..d9a2737583 --- /dev/null +++ b/tests/agent/test_context_compressor_summary_continuity.py @@ -0,0 +1,67 @@ +"""Regression tests for iterative context-summary continuity.""" + +from unittest.mock import MagicMock, patch + +from agent.context_compressor import ContextCompressor, SUMMARY_PREFIX + + +def _compressor() -> ContextCompressor: + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + return ContextCompressor( + model="test/model", + threshold_percent=0.85, + protect_first_n=1, + protect_last_n=1, + quiet_mode=True, + ) + + +def _response(content: str): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = content + return mock_response + + +def _messages_with_handoff(summary_body: str): + return [ + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": f"{SUMMARY_PREFIX}\n{summary_body}"}, + {"role": "user", "content": "new user turn after resume"}, + {"role": "assistant", "content": "new assistant work after resume"}, + {"role": "user", "content": "more new work after resume"}, + {"role": "assistant", "content": "latest tail response"}, + ] + + +def test_existing_previous_summary_is_not_serialized_again_as_new_turn(): + """Same-process iterative compression should not feed the old handoff twice.""" + compressor = _compressor() + old_summary = "OLD-SUMMARY-BODY unique continuity facts" + compressor._previous_summary = old_summary + + with patch("agent.context_compressor.call_llm", return_value=_response("updated summary")) as mock_call: + compressor.compress(_messages_with_handoff(old_summary)) + + prompt = mock_call.call_args.kwargs["messages"][0]["content"] + assert "PREVIOUS SUMMARY:" in prompt + assert "NEW TURNS TO INCORPORATE:" in prompt + assert prompt.count(old_summary) == 1 + assert f"[USER]: {SUMMARY_PREFIX}" not in prompt + + +def test_resume_rehydrates_previous_summary_from_handoff_message(): + """After restart/resume, the persisted handoff should regain summary identity.""" + compressor = _compressor() + old_summary = "RESUMED-SUMMARY-BODY durable continuity facts" + assert compressor._previous_summary is None + + with patch("agent.context_compressor.call_llm", return_value=_response("updated summary")) as mock_call: + compressor.compress(_messages_with_handoff(old_summary)) + + prompt = mock_call.call_args.kwargs["messages"][0]["content"] + assert "PREVIOUS SUMMARY:" in prompt + assert "NEW TURNS TO INCORPORATE:" in prompt + assert "TURNS TO SUMMARIZE:" not in prompt + assert prompt.count(old_summary) == 1 + assert f"[USER]: {SUMMARY_PREFIX}" not in prompt