From 8513a6aec784b927cfb8e13f75f10eeb6db893c4 Mon Sep 17 00:00:00 2001 From: Basil Al Shukaili <189737461+basilalshukaili@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:54:40 +0400 Subject: [PATCH] fix(compression): guard against cross-session stale _previous_summary contamination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a cron or background session compacts, it sets _previous_summary for iterative updates. If that session ends without /new or /reset (which calls on_session_reset()), the stale summary survives on the ContextCompressor instance. A subsequent live messaging session's compaction then injects it as 'PREVIOUS SUMMARY:' into the summarizer prompt — contaminating the live session with unrelated content from the prior session. Add an else guard in compress(): when no handoff summary is found in the current messages but _previous_summary is non-empty, discard it so _generate_summary() starts fresh instead of iteratively updating a stale cross-session summary. Fixes #38788 --- agent/context_compressor.py | 7 + ..._context_compressor_cross_session_guard.py | 145 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 tests/agent/test_context_compressor_cross_session_guard.py diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 8b6c932d0c6..4dbb189866e 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -1990,6 +1990,13 @@ The user has requested that this compaction PRIORITISE preserving all informatio if summary_body and not self._previous_summary: self._previous_summary = summary_body turns_to_summarize = messages[max(compress_start, summary_idx + 1):compress_end] + elif self._previous_summary: + # No handoff summary found in the current messages, but + # _previous_summary is non-empty — it was set by a different + # (now-ended) session (e.g., a cron job, a prior /new). Discard + # it so _generate_summary() does not inject cross-session content + # into the summarizer prompt via the iterative-update path. + self._previous_summary = None if not self.quiet_mode: logger.info( diff --git a/tests/agent/test_context_compressor_cross_session_guard.py b/tests/agent/test_context_compressor_cross_session_guard.py new file mode 100644 index 00000000000..e92edb16183 --- /dev/null +++ b/tests/agent/test_context_compressor_cross_session_guard.py @@ -0,0 +1,145 @@ +"""Tests for cross-session _previous_summary contamination bug (#38788). + +ContextCompressor._previous_summary is an instance variable that stores the +previous compaction summary for iterative updates. It is cleared by +on_session_reset() which is called for /new and /reset, but NOT when a cron +session ends naturally. A cron session's compaction sets _previous_summary, +then the cron session ends. A subsequent live messaging session inherits this +stale summary, and _generate_summary() injects it as "PREVIOUS SUMMARY:" into +the summarizer prompt — contaminating the live session's context. + +Fix: compress() guards against this by clearing _previous_summary when no +handoff summary is found in the current messages. +""" + +import sys +import types +from pathlib import Path +from unittest.mock import patch + +# Ensure repo root is importable +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) + +# Stub out optional heavy dependencies not installed in the test environment +sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None)) +sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object)) +sys.modules.setdefault("fal_client", types.SimpleNamespace()) + +from agent.context_compressor import ContextCompressor + + +def _make_compressor(): + """Build a ContextCompressor with enough state to pass compress() guards.""" + c = ContextCompressor.__new__(ContextCompressor) + c.quiet_mode = True + c.model = "test/model" + c.provider = "test" + c.base_url = "http://test" + c.api_key = "test-key" + c.api_mode = "" + c.context_length = 128000 + c.threshold_tokens = 64000 + c.threshold_percent = 0.50 + c.tail_token_budget = 20000 + c.protect_last_n = 12 + c.summary_model = "" + c.last_prompt_tokens = 100000 + c.last_completion_tokens = 0 + c._summary_failure_cooldown_until = 0.0 + c._max_compaction_summary_tokens = 0 + c.summary_budget_tokens = 0 + c.abort_on_summary_failure = False + c._last_compress_aborted = False + c._summary_model_fallen_back = False + c.compression_count = 0 + c._context_probed = False + c._last_compression_savings_pct = 100.0 + c._ineffective_compression_count = 0 + c._last_summary_error = None + c._last_summary_dropped_count = 0 + c._last_summary_fallback_used = False + c._last_aux_model_failure_error = None + c._last_aux_model_failure_model = None + c.last_real_prompt_tokens = 0 + c.last_compression_rough_tokens = 0 + c.last_rough_tokens_when_real_prompt_fit = 0 + c.awaiting_real_usage_after_compression = False + return c + + +def _conversation_without_handoff(n_exchanges=12): + """Build message list with no compaction handoff in it.""" + msgs = [{"role": "system", "content": "You are a helpful assistant."}] + for i in range(n_exchanges): + msgs.append({"role": "user", "content": f"Question {i}"}) + msgs.append({"role": "assistant", "content": f"Answer {i}"}) + return msgs + + +def _conversation_with_handoff(n_exchanges=12): + """Build message list WITH a compaction handoff in protected head.""" + from agent.context_compressor import SUMMARY_PREFIX + msgs = [{"role": "system", "content": "You are a helpful assistant."}] + msgs.append({"role": "user", "content": SUMMARY_PREFIX + "\nPrevious summary."}) + for i in range(n_exchanges): + msgs.append({"role": "user", "content": f"Question {i}"}) + msgs.append({"role": "assistant", "content": f"Answer {i}"}) + return msgs + + +def test_stale_previous_summary_cleared_when_no_handoff(): + """Cross-session guard: stale _previous_summary cleared when no handoff.""" + c = _make_compressor() + # Simulate state left by a prior cron session's compaction + c._previous_summary = "STALE CRON SUMMARY - this must not leak" + + messages = _conversation_without_handoff() + + with patch.object(c, "_generate_summary", + return_value="[CONTEXT COMPACTION] Fresh summary."): + result = c.compress(messages) + + assert c._previous_summary is None, ( + "compress() must clear stale _previous_summary when no handoff " + f"summary exists in current messages. Got: {c._previous_summary!r}" + ) + assert result != messages + assert any( + "[CONTEXT COMPACTION]" in (m.get("content", "") or "") for m in result + ) + + +def test_previous_summary_preserved_when_handoff_found(): + """When a handoff IS found, _previous_summary should be preserved for + iterative update within the same session.""" + c = _make_compressor() + c._previous_summary = "Summary from earlier compaction in same session" + + messages = _conversation_with_handoff() + + with patch.object(c, "_generate_summary", + return_value="[CONTEXT COMPACTION] Updated summary."): + c.compress(messages) + + # When a handoff IS found, the staleness guard must NOT fire. + # _previous_summary should be updated, not cleared. + assert c._previous_summary is not None, ( + "compress() must NOT clear _previous_summary when handoff summary " + "exists in current messages" + ) + + +def test_no_false_positive_when_previous_summary_already_none(): + """When _previous_summary is already None and no handoff found, nothing + should break (the guard is a no-op in this case).""" + c = _make_compressor() + c._previous_summary = None + + messages = _conversation_without_handoff() + + with patch.object(c, "_generate_summary", + return_value="[CONTEXT COMPACTION] Fresh summary."): + c.compress(messages) + + # Should still be None — guard is no-op + assert c._previous_summary is None