fix(compression): guard against cross-session stale _previous_summary contamination

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
This commit is contained in:
Basil Al Shukaili 2026-06-04 11:54:40 +04:00 committed by Teknium
parent ad8e57793d
commit 8513a6aec7
2 changed files with 152 additions and 0 deletions

View file

@ -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(

View file

@ -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