{"repo":"NousResearch/hermes-agent","public_id":"9413","display_label":"Context compression cross-session contamination bugs and fixes","summary":"A cluster of bug reports and fixes addressing context/compression logic that leaks summary state across sessions and drops recent conversation turns. Multiple fixes target clearing per-session state on session end and preserving recent turns during compaction.","subsystem":"agent","counts":{"members":9,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":3,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":41717},"resolution":{"state":"partially_resolved","by":"pr-41717"},"freshness":{"state":"verified","last_verified_at":"2026-07-01T09:48:33+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.20444444444444443},"fix_edges":[{"issue_number":9413,"pr_number":45259,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":38788,"pr_number":41717,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":38788,"pr_number":56239,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":9413,"title":"[Bug]: Context compression drops recent conversation记忆 and injects cross-session fragments","url":null,"state":"closed"},{"kind":"issue","number":38788,"title":"[Bug]: Context compaction cross-contamination — cron session summaries leak into unrelated live conversations","url":null,"state":"closed"},{"kind":"pr","number":38148,"title":"fix(gateway): avoid transcript rewrite on hygiene compression no-op","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#9413","number":9413}],"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":38832,"title":"fix(compression): guard against cross-session stale _previous_summary contamination","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"8513a6aec","number":0},{"kind":"pr","value":"#41717","number":41717},{"kind":"pr","value":"#39725","number":39725}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":39170,"title":"fix(agent): preserve recent turns during compression","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"aec38855b","number":0},{"kind":"pr","value":"#45259","number":45259}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":39725,"title":"fix(agent): clear _previous_summary on session end to prevent cross-contamination (#38788)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"cca3b77a4","number":0},{"kind":"pr","value":"#41717","number":41717}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":41717,"title":"fix(compression): stop cross-session _previous_summary contamination","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/context_compressor.py","scripts/release.py","tests/agent/test_context_compressor_cross_session_guard.py"]},{"kind":"pr","number":45259,"title":"fix(agent): honor protect_last_n (capped) as the compaction tail floor (salvages #39170)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/context_compressor.py","tests/agent/test_context_compressor.py"]},{"kind":"pr","number":56239,"title":"fix(compression): clear all per-session state in on_session_end, not just _previous_summary","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/context_compressor.py","tests/agent/test_context_compressor_session_end_clears_state.py"],"fix_hunks":[{"file":"agent/context_compressor.py","hunks":["@@ -640,26 +640,47 @@ def on_session_reset(self) -> None:\n         self._ineffective_compression_count = 0\n         self._summary_failure_cooldown_until = 0.0  # transient errors must not block a fresh session\n         self._last_summary_error = None\n+        self._last_compress_aborted = False\n         self.last_real_prompt_tokens = 0\n         self.last_compression_rough_tokens = 0\n         self.last_rough_tokens_when_real_prompt_fit = 0\n         self.awaiting_real_usage_after_compression = False\n \n     def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> None:\n-        \"\"\"Clear per-session compaction state at a real session boundary.\n-\n-        ``_previous_summary`` is per-session iterative-summary state. It is\n-        cleared on ``on_session_reset()`` (/new, /reset), but session *end*\n-        (CLI exit, gateway expiry, session-id rotation) goes through\n-        ``on_session_end()`` instead — which inherited a no-op from\n-        ``ContextEngine``. Without clearing here, a cron/background session's\n-        summary could survive on a reused compressor instance and leak into the\n-        next live session via the ``_generate_summary()`` iterative-update path\n-        (#38788). ``compress()`` already guards the leak at the point of use;\n-        this is defense-in-depth that drops the stale summary the moment the\n-        owning session ends.\n+        \"\"\"Clear all per-session compaction state at a real session boundary.\n+\n+        Session end (CLI exit, gateway expiry, session-id rotation) goes\n+        through this method rather than ``on_session_reset()`` (/new, /reset).\n+        The original fix (#38788) only cleared ``_previous_summary``, but the\n+        same cross-session contamination risk applies to every per-session\n+        variable that ``on_session_reset()`` clears: stale\n+        ``_ineffective_compression_count`` can suppress compression in a\n+        subsequent live session; ``_summary_failure_cooldown_until`` can block\n+        summary generation; ``_last_compress_aborted`` can make callers think\n+        compression is still aborted; ``_last_aux_model_failure_*`` can surface\n+        stale error warnings; ``_last_summary_dropped_count`` /\n+        ``_last_summary_fallback_used`` can produce misleading user warnings.\n+\n+        ``compress()`` already guards ``_previous_summary`` leakage at the\n+        point of use; this is defense-in-depth that resets the full per-session\n+        surface the moment the owning session ends.\n         \"\"\"\n         self._previous_summary = None\n+        self._last_summary_error = None\n+        self._last_summary_dropped_count = 0\n+        self._last_summary_fallback_used = False\n+        self._last_aux_model_failure_error = None\n+        self._last_aux_model_failure_model = None\n+        self._last_compression_savings_pct = 100.0\n+        self._ineffective_compression_count = 0\n+        self._summary_failure_cooldown_until = 0.0\n+        self._last_compress_aborted = False\n+        self._context_probed = False\n+        self._context_probe_persistable = False\n+        self.last_real_prompt_tokens = 0\n+        self.last_compression_rough_tokens = 0\n+        self.last_rough_tokens_when_real_prompt_fit = 0\n+        self.awaiting_real_usage_after_compression = False\n \n     def bind_session_state(self, session_db: Any = None, session_id: str = \"\") -> None:\n         \"\"\"Bind the current session row so durable cooldowns can round-trip.\"\"\""]},{"file":"tests/agent/test_context_compressor_session_end_clears_state.py","hunks":["@@ -0,0 +1,242 @@\n+\"\"\"Tests for on_session_end() clearing all per-session compressor state.\n+\n+Bug: on_session_end() (added in #38788) only cleared _previous_summary, but\n+on_session_reset() clears 14+ per-session variables. When a session ends\n+(cron exit, gateway expiry, session-id rotation) and the compressor instance\n+is reused, these stale values survive:\n+\n+- _ineffective_compression_count: can suppress compression in next session\n+- _summary_failure_cooldown_until: can block summary generation\n+- _last_compress_aborted: can make callers think compression is aborted\n+- _last_aux_model_failure_*: can surface stale error warnings\n+- _last_summary_dropped_count / _last_summary_fallback_used: misleading warnings\n+- _context_probed / _context_probe_persistable: stale context probe state\n+\n+Fix: on_session_end() now clears all per-session state, matching\n+on_session_reset()'s surface.\n+\"\"\"\n+\n+import sys\n+import types\n+from pathlib import Path\n+\n+# Ensure repo root is importable\n+sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))\n+\n+# Stub out optional heavy dependencies not installed in the test environment\n+sys.modules.setdefault(\"fire\", types.SimpleNamespace(Fire=lambda *a, **k: None))\n+sys.modules.setdefault(\"firecrawl\", types.SimpleNamespace(Firecrawl=object))\n+sys.modules.setdefault(\"fal_client\", types.SimpleNamespace())\n+\n+from agent.context_compressor import ContextCompressor\n+\n+\n+def _make_compressor():\n+    \"\"\"Build a ContextCompressor with enough state to pass compress() guards.\"\"\"\n+    c = ContextCompressor.__new__(ContextCompressor)\n+    c.quiet_mode = True\n+    c.model = \"test/model\"\n+    c.provider = \"test\"\n+    c.base_url = \"http://test\"\n+    c.api_key = \"[redacted:secret-assignment]\"\n+    c.api_mode = \"\"\n+    c.context_length = 128000\n+    c.threshold_tokens = 64000\n+    c.threshold_percent = 0.50\n+    c.tail_token_budget = 20000\n+    c.protect_last_n = 12\n+    c.summary_model = \"\"\n+    c.last_prompt_tokens = [redacted:secret-assignment]\n+    c.last_completion_tokens = 0\n+    c.last_total_tokens = [redacted:secret-assignment]\n+    c._summary_failure_cooldown_until = 0.0\n+    c._max_compaction_summary_tokens = 0\n+    c.summary_budget_tokens = 0\n+    c.abort_on_summary_failure = False\n+    c._last_compress_aborted = False\n+    c._summary_model_fallen_back = False\n+    c.compression_count = 0\n+    c._context_probed = False\n+    c._context_probe_persistable = False\n+    c._last_compression_savings_pct = 100.0\n+    c._ineffective_compression_count = 0\n+    c._last_summary_error = None\n+    c._last_summary_dropped_count = 0\n+    c._last_summary_fallback_used = False\n+    c._last_aux_model_failure_error = None\n+    c._last_aux_model_failure_model = None\n+    c.last_real_prompt_tokens = 0\n+    c.last_compression_rough_tokens = 0\n+    c.last_rough_tokens_when_real_prompt_fit = 0\n+    c.awaiting_real_usage_after_compression = False\n+    c._previous_summary = None\n+    return c\n+\n+\n+def _simulate_cron_session_state(c):\n+    \"\"\"Simulate per-session state that a cron compaction would leave behind.\"\"\"\n+    c._previous_summary = \"Cron session summary that must not leak\"\n+    c._last_summary_error = \"Cron session summary error\"\n+    c._last_summary_dropped_count = 5\n+    c._last_summary_fallback_used = True\n+    c._last_aux_model_failure_error = \"Cron aux model error\"\n+    c._last_aux_model_failure_model = \"cron-model/v1\"\n+    c._last_compression_savings_pct = 3.0\n+    c._ineffective_compression_count = 2\n+    c._summary_failure_cooldown_until = 9999999999.0\n+    c._last_compress_aborted = True\n+    c._context_probed = True\n+    c._context_probe_persistable = True\n+    c.last_real_prompt_tokens = 50000\n+    c.last_compression_rough_tokens = 60000\n+    c.last_rough_tokens_when_real_prompt_fit = 55000\n+    c.awaiting_real_usage_after_compression = True\n+\n+\n+def test_on_session_end_clears_all_per_session_state():\n+    \"\"\"on_session_end() must clear every per-session variable, not just\n+    _previous_summary. Otherwise stale state from a prior session\n+    (e.g. a cron job) contaminates the next live session.\"\"\"\n+    c = _make_compressor()\n+    _simulate_cron_session_state(c)\n+\n+    c.on_session_end(\"cron-session-1\", [])\n+\n+    assert c._previous_summary is None, (\n+        f\"_previous_summary must be None after on_session_end, got {c._previous_summary!r}\"\n+    )\n+    assert c._last_summary_error is None, (\n+        f\"_last_summary_error must be None after on_session_end, got {c._last_summary_error!r}\"\n+    )\n+    assert c._last_summary_dropped_count == 0, (\n+        f\"_last_summary_dropped_count must be 0, got {c._last_summary_dropped_count}\"\n+    )\n+    assert c._last_summary_fallback_used is False, (\n+        f\"_last_summary_fallback_used must be False, got {c._last_summary_fallback_used}\"\n+    )\n+    assert c._last_aux_model_failure_error is None, (\n+        f\"_last_aux_model_failure_error must be None, got {c._last_aux_model_failure_error!r}\"\n+    )\n+    assert c._last_aux_model_failure_model is None, (\n+        f\"_last_aux_model_failure_model must be None, got {c._last_aux_model_failure_model!r}\"\n+    )\n+    assert c._last_compression_savings_pct == 100.0, (\n+        f\"_last_compression_savings_pct must be 100.0, got {c._last_compression_savings_pct}\"\n+    )\n+    assert c._ineffective_compression_count == 0, (\n+        f\"_ineffective_compression_count must be 0, got {c._ineffective_compression_count}\"\n+    )\n+    assert c._summary_failure_cooldown_until == 0.0, (\n+        f\"_summary_failure_cooldown_until must be 0.0, got {c._summary_failure_cooldown_until}\"\n+    )\n+    assert c._last_compress_aborted is False, (\n+        f\"_last_compress_aborted must be False, got {c._last_compress_aborted}\"\n+    )\n+    assert c._context_probed is False, (\n+        f\"_context_probed must be False, got {c._context_probed}\"\n+    )\n+    assert c._context_probe_persistable is False, (\n+        f\"_context_probe_persistable must be False, got {c._context_probe_persistable}\"\n+    )\n+    assert c.last_real_prompt_tokens == 0, (\n+        f\"last_real_prompt_tokens must be 0, got {c.last_real_prompt_tokens}\"\n+    )\n+    assert c.last_compression_rough_tokens == 0, (\n+        f\"last_compression_rough_tokens must be 0, got {c.last_compression_rough_tokens}\"\n+    )\n+    assert c.last_rough_tokens_when_real_prompt_fit == 0, (\n+        f\"last_rough_tokens_when_real_prompt_fit must be 0, got {c.last_rough_tokens_when_real_prompt_fit}\"\n+    )\n+    assert c.awaiting_real_usage_after_compression is False, (\n+        f\"awaiting_real_usage_after_compression must be False, got {c.awaiting_real_usage_after_compression}\"\n+    )\n+\n+\n+def test_on_session_end_matches_on_session_reset_surface():\n+    \"\"\"Both on_session_end and on_session_reset must clear the same set of\n+    per-session variables. If one is updated and the other isn't, it's a\n+    cross-session contamination bug waiting to happen.\"\"\"\n+    c1 = _make_compressor()\n+    c2 = _make_compressor()\n+    _simulate_cron_session_state(c1)\n+    _simulate_cron_session_state(c2)\n+\n+    c1.on_session_end(\"session-1\", [])\n+    c2.on_session_reset()\n+\n+    per_session_attrs = [\n+        \"_previous_summary\",\n+        \"_last_summary_error\",\n+        \"_last_summary_dropped_count\",\n+        \"_last_summary_fallback_used\",\n+        \"_last_aux_model_failure_error\",\n+        \"_last_aux_model_failure_model\",\n+        \"_last_compression_savings_pct\",\n+        \"_ineffective_compression_count\",\n+        \"_summary_failure_cooldown_until\",\n+        \"_last_compress_aborted\",\n+        \"_context_probed\",\n+        \"_context_probe_persistable\",\n+        \"last_real_prompt_tokens\",\n+        \"last_compression_rough_tokens\",\n+        \"last_rough_tokens_when_real_prompt_fit\",\n+        \"awaiting_real_usage_after_compression\",\n+    ]\n+\n+    for attr in per_session_attrs:\n+        v_end = getattr(c1, attr)\n+        v_reset = getattr(c2, attr)\n+        assert v_end == v_reset, (\n+            f\"on_session_end and on_session_reset must produce the same \"\n+            f\"value for {attr}: on_session_end={v_end!r}, \"\n+            f\"on_session_reset={v_reset!r}\"\n+        )\n+\n+\n+def test_ineffective_compression_count_does_not_leak_across_sessions():\n+    \"\"\"A cron session that hit ineffective compression limits must not\n+    suppress compression in a subsequent live session.\"\"\"\n+    c = _make_compressor()\n+    c._ineffective_compression_count = 2  # hit the anti-thrashing limit\n+    c._last_compression_savings_pct = 3.0\n+\n+    c.on_session_end(\"cron-session\", [])\n+\n+    # After session end, the next session must start with a clean slate\n+    assert c._ineffective_compression_count == 0\n+    assert c._last_compression_savings_pct == 100.0\n+\n+\n+def test_summary_failure_cooldown_does_not_leak_across_sessions():\n+    \"\"\"A cron session's summary failure cooldown must not block summary\n+    generation in a subsequent live session.\"\"\"\n+    c = _make_compressor()\n+    c._summary_failure_cooldown_until = 9999999999.0\n+\n+    c.on_session_end(\"cron-session\", [])\n+\n+    assert c._summary_failure_cooldown_until == 0.0\n+\n+\n+def test_compress_aborted_flag_does_not_leak_across_sessions():\n+    \"\"\"A cron session's _last_compress_aborted flag must not make callers\n+    think compression is still aborted in a subsequent live session.\"\"\"\n+    c = _make_compressor()\n+    c._last_compress_aborted = True\n+\n+    c.on_session_end(\"cron-session\", [])\n+\n+    assert c._last_compress_aborted is False\n+\n+\n+def test_aux_model_failure_does_not_leak_across_sessions():\n+    \"\"\"Stale aux model failure info from a cron session must not produce\n+    misleading error warnings in a subsequent live session.\"\"\"\n+    c = _make_compressor()\n+    c._last_aux_model_failure_error = \"cron-model/v1 failed\"\n+    c._last_aux_model_failure_model = \"cron-model/v1\"\n+\n+    c.on_session_end(\"cron-session\", [])\n+\n+    assert c._last_aux_model_failure_error is None\n+    assert c._last_aux_model_failure_model is None\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}