Skip to content

Commit 8513a6a

Browse files
basilalshukailiteknium1
authored andcommitted
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
1 parent ad8e577 commit 8513a6a

2 files changed

Lines changed: 152 additions & 0 deletions

File tree

agent/context_compressor.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1990,6 +1990,13 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f
19901990
if summary_body and not self._previous_summary:
19911991
self._previous_summary = summary_body
19921992
turns_to_summarize = messages[max(compress_start, summary_idx + 1):compress_end]
1993+
elif self._previous_summary:
1994+
# No handoff summary found in the current messages, but
1995+
# _previous_summary is non-empty — it was set by a different
1996+
# (now-ended) session (e.g., a cron job, a prior /new). Discard
1997+
# it so _generate_summary() does not inject cross-session content
1998+
# into the summarizer prompt via the iterative-update path.
1999+
self._previous_summary = None
19932000

19942001
if not self.quiet_mode:
19952002
logger.info(
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""Tests for cross-session _previous_summary contamination bug (#38788).
2+
3+
ContextCompressor._previous_summary is an instance variable that stores the
4+
previous compaction summary for iterative updates. It is cleared by
5+
on_session_reset() which is called for /new and /reset, but NOT when a cron
6+
session ends naturally. A cron session's compaction sets _previous_summary,
7+
then the cron session ends. A subsequent live messaging session inherits this
8+
stale summary, and _generate_summary() injects it as "PREVIOUS SUMMARY:" into
9+
the summarizer prompt — contaminating the live session's context.
10+
11+
Fix: compress() guards against this by clearing _previous_summary when no
12+
handoff summary is found in the current messages.
13+
"""
14+
15+
import sys
16+
import types
17+
from pathlib import Path
18+
from unittest.mock import patch
19+
20+
# Ensure repo root is importable
21+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
22+
23+
# Stub out optional heavy dependencies not installed in the test environment
24+
sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None))
25+
sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object))
26+
sys.modules.setdefault("fal_client", types.SimpleNamespace())
27+
28+
from agent.context_compressor import ContextCompressor
29+
30+
31+
def _make_compressor():
32+
"""Build a ContextCompressor with enough state to pass compress() guards."""
33+
c = ContextCompressor.__new__(ContextCompressor)
34+
c.quiet_mode = True
35+
c.model = "test/model"
36+
c.provider = "test"
37+
c.base_url = "http://test"
38+
c.api_key = "test-key"
39+
c.api_mode = ""
40+
c.context_length = 128000
41+
c.threshold_tokens = 64000
42+
c.threshold_percent = 0.50
43+
c.tail_token_budget = 20000
44+
c.protect_last_n = 12
45+
c.summary_model = ""
46+
c.last_prompt_tokens = 100000
47+
c.last_completion_tokens = 0
48+
c._summary_failure_cooldown_until = 0.0
49+
c._max_compaction_summary_tokens = 0
50+
c.summary_budget_tokens = 0
51+
c.abort_on_summary_failure = False
52+
c._last_compress_aborted = False
53+
c._summary_model_fallen_back = False
54+
c.compression_count = 0
55+
c._context_probed = False
56+
c._last_compression_savings_pct = 100.0
57+
c._ineffective_compression_count = 0
58+
c._last_summary_error = None
59+
c._last_summary_dropped_count = 0
60+
c._last_summary_fallback_used = False
61+
c._last_aux_model_failure_error = None
62+
c._last_aux_model_failure_model = None
63+
c.last_real_prompt_tokens = 0
64+
c.last_compression_rough_tokens = 0
65+
c.last_rough_tokens_when_real_prompt_fit = 0
66+
c.awaiting_real_usage_after_compression = False
67+
return c
68+
69+
70+
def _conversation_without_handoff(n_exchanges=12):
71+
"""Build message list with no compaction handoff in it."""
72+
msgs = [{"role": "system", "content": "You are a helpful assistant."}]
73+
for i in range(n_exchanges):
74+
msgs.append({"role": "user", "content": f"Question {i}"})
75+
msgs.append({"role": "assistant", "content": f"Answer {i}"})
76+
return msgs
77+
78+
79+
def _conversation_with_handoff(n_exchanges=12):
80+
"""Build message list WITH a compaction handoff in protected head."""
81+
from agent.context_compressor import SUMMARY_PREFIX
82+
msgs = [{"role": "system", "content": "You are a helpful assistant."}]
83+
msgs.append({"role": "user", "content": SUMMARY_PREFIX + "\nPrevious summary."})
84+
for i in range(n_exchanges):
85+
msgs.append({"role": "user", "content": f"Question {i}"})
86+
msgs.append({"role": "assistant", "content": f"Answer {i}"})
87+
return msgs
88+
89+
90+
def test_stale_previous_summary_cleared_when_no_handoff():
91+
"""Cross-session guard: stale _previous_summary cleared when no handoff."""
92+
c = _make_compressor()
93+
# Simulate state left by a prior cron session's compaction
94+
c._previous_summary = "STALE CRON SUMMARY - this must not leak"
95+
96+
messages = _conversation_without_handoff()
97+
98+
with patch.object(c, "_generate_summary",
99+
return_value="[CONTEXT COMPACTION] Fresh summary."):
100+
result = c.compress(messages)
101+
102+
assert c._previous_summary is None, (
103+
"compress() must clear stale _previous_summary when no handoff "
104+
f"summary exists in current messages. Got: {c._previous_summary!r}"
105+
)
106+
assert result != messages
107+
assert any(
108+
"[CONTEXT COMPACTION]" in (m.get("content", "") or "") for m in result
109+
)
110+
111+
112+
def test_previous_summary_preserved_when_handoff_found():
113+
"""When a handoff IS found, _previous_summary should be preserved for
114+
iterative update within the same session."""
115+
c = _make_compressor()
116+
c._previous_summary = "Summary from earlier compaction in same session"
117+
118+
messages = _conversation_with_handoff()
119+
120+
with patch.object(c, "_generate_summary",
121+
return_value="[CONTEXT COMPACTION] Updated summary."):
122+
c.compress(messages)
123+
124+
# When a handoff IS found, the staleness guard must NOT fire.
125+
# _previous_summary should be updated, not cleared.
126+
assert c._previous_summary is not None, (
127+
"compress() must NOT clear _previous_summary when handoff summary "
128+
"exists in current messages"
129+
)
130+
131+
132+
def test_no_false_positive_when_previous_summary_already_none():
133+
"""When _previous_summary is already None and no handoff found, nothing
134+
should break (the guard is a no-op in this case)."""
135+
c = _make_compressor()
136+
c._previous_summary = None
137+
138+
messages = _conversation_without_handoff()
139+
140+
with patch.object(c, "_generate_summary",
141+
return_value="[CONTEXT COMPACTION] Fresh summary."):
142+
c.compress(messages)
143+
144+
# Should still be None — guard is no-op
145+
assert c._previous_summary is None

0 commit comments

Comments
 (0)