mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
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:
parent
ad8e57793d
commit
8513a6aec7
2 changed files with 152 additions and 0 deletions
|
|
@ -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(
|
||||
|
|
|
|||
145
tests/agent/test_context_compressor_cross_session_guard.py
Normal file
145
tests/agent/test_context_compressor_cross_session_guard.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue