{"repo":"NousResearch/hermes-agent","public_id":"35809","display_label":"Compression exhaustion loop and session reset fixes","summary":"A bug where compression exhaustion triggers an infinite loop via auto-reset reloading bloated context, along with several related fixes addressing session ID sync, binding cleanup, and topic rebinding after compression auto-reset.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":45613,"merged_at":"2026-06-13T13:38:29+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-13T13:38:29+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":9893,"pr_number":45613,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-13T13:38:29+00:00"},{"issue_number":35809,"pr_number":45613,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-13T13:38:29+00:00"}],"members":[{"kind":"issue","number":35809,"title":"[Bug]: Compression exhaustion loop — session auto-reset re-loads bloated compressed context, causing immediate re-failure","url":null,"state":"closed"},{"kind":"pr","number":35888,"title":"fix(gateway): clear bloated compression binding on compression-exhaustion auto-reset","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#45613","number":45613}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":45613,"title":"fix(gateway): rebind topic after compression auto-reset","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","tests/gateway/test_35809_auto_reset_clean_context.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -8815,12 +8815,29 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                     \"Auto-resetting session %s after compression exhaustion.\",\n                     session_entry.session_id,\n                 )\n-                self.session_store.reset_session(session_key)\n+                new_entry = self.session_store.reset_session(session_key)\n                 self._evict_cached_agent(session_key)\n                 self._session_model_overrides.pop(session_key, None)\n                 self._set_session_reasoning_override(session_key, None)\n                 if hasattr(self, \"_pending_model_notes\"):\n                     self._pending_model_notes.pop(session_key, None)\n+                if new_entry is not None:\n+                    # Drop the stale reference to the bloated compressed child and\n+                    # re-point the Telegram topic binding at the fresh session.\n+                    # Compression rotated session_entry.session_id to the oversized\n+                    # compressed child earlier this turn (the agent-result sync\n+                    # above), and that _sync also rewrote the (chat_id, thread_id)\n+                    # -> bloated-child binding. reset_session swaps in a clean,\n+                    # parentless session, but without re-syncing the binding the\n+                    # next inbound message in this topic gets switch_session'd back\n+                    # onto the bloated child by the binding-heal walk, reloads the\n+                    # oversized transcript, and re-triggers compression exhaustion\n+                    # forever (#35809 — regression of the #9893/#10063 auto-reset).\n+                    # No-op on non-topic lanes.\n+                    session_entry = new_entry\n+                    self._sync_telegram_topic_binding(\n+                        source, session_entry, reason=\"compression-exhausted-reset\",\n+                    )\n                 response = (response or \"\") + (\n                     \"\\n\\n🔄 Session auto-reset — the conversation exceeded the \"\n                     \"maximum context size and could not be compressed further. \""]},{"file":"tests/gateway/test_35809_auto_reset_clean_context.py","hunks":["@@ -0,0 +1,196 @@\n+\"\"\"Regression tests for #35809 — compression-exhaustion auto-reset loop.\n+\n+After compression is exhausted the gateway auto-resets the session so the\n+next message starts on a fresh, empty conversation (#9893 / #10063). That\n+guarantee regressed once the Telegram topic-binding heal landed\n+(#20470 / #29712 / #33414):\n+\n+    1. Compression rotates ``session_entry.session_id`` to an oversized\n+       compressed *child* session mid-turn and the agent-result sync rewrites\n+       the ``(chat_id, thread_id) -> child`` topic binding.\n+    2. ``reset_session`` swaps in a clean, parentless session — but its return\n+       value was discarded and the topic binding was left pointing at the\n+       bloated child.\n+    3. On the next inbound message in that topic, the binding-heal walk\n+       ``switch_session``'d the freshly-reset lane *back* onto the bloated\n+       child, ``load_transcript`` reloaded the oversized transcript, and\n+       compression exhaustion re-fired — a new session id every loop.\n+\n+The fix captures the fresh entry from ``reset_session`` and re-syncs the\n+topic binding to it (a no-op on non-topic lanes).\n+\n+Two tests:\n+\n+* ``TestAutoResetBlockReSyncsBinding`` — an AST invariant on\n+  ``gateway/run.py`` (mirrors ``test_compression_session_id_persistence.py``):\n+  the compression-exhausted auto-reset block must capture\n+  ``reset_session(...)`` and call ``_sync_telegram_topic_binding`` afterward.\n+  This is the load-bearing regression pin.\n+* ``TestAutoResetLoadsCleanContext`` — a behavioral contract on the real\n+  ``SessionStore``: after ``reset_session`` the next turn loads an EMPTY\n+  transcript for the new session_id, never the bloated child's transcript.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import ast\n+import inspect\n+\n+from gateway import run as gateway_run\n+from gateway.config import GatewayConfig, Platform\n+from gateway.session import SessionSource, SessionStore\n+from hermes_state import SessionDB\n+\n+\n+# ---------------------------------------------------------------------------\n+# AST invariant: the auto-reset block re-syncs the topic binding\n+# ---------------------------------------------------------------------------\n+def _find_compression_exhausted_reset_block() -> ast.If:\n+    \"\"\"Return the ``if agent_result.get('compression_exhausted') ...`` block.\"\"\"\n+    tree = ast.parse(inspect.getsource(gateway_run))\n+\n+    for node in ast.walk(tree):\n+        if not isinstance(node, ast.If):\n+            continue\n+        consts = [\n+            n.value\n+            for n in ast.walk(node.test)\n+            if isinstance(n, ast.Constant) and isinstance(n.value, str)\n+        ]\n+        # Identify the auto-reset branch by the literal passed to .get(...).\n+        if \"compression_exhausted\" in consts:\n+            # Only the branch that actually performs the reset, not the\n+            # earlier classifier that merely reads the flag into a bool.\n+            calls = {\n+                sub.func.attr\n+                for sub in ast.walk(node)\n+                if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute)\n+            }\n+            if \"reset_session\" in calls:\n+                return node\n+    raise AssertionError(\n+        \"Could not locate the compression-exhausted auto-reset block \"\n+        \"(if agent_result.get('compression_exhausted') ... reset_session) \"\n+        \"in gateway/run.py — the structure changed or the AST walker is stale.\"\n+    )\n+\n+\n+class TestAutoResetBlockReSyncsBinding:\n+    def test_reset_session_return_is_captured(self):\n+        \"\"\"``reset_session`` must be assigned, not called-and-discarded —\n+        the fresh entry is needed to re-point the binding and drop the stale\n+        reference to the bloated compressed child (#35809).\"\"\"\n+        block = _find_compression_exhausted_reset_block()\n+        captured = False\n+        for stmt in ast.walk(block):\n+            if isinstance(stmt, ast.Assign):\n+                val = stmt.value\n+                if (\n+                    isinstance(val, ast.Call)\n+                    and isinstance(val.func, ast.Attribute)\n+                    and val.func.attr == \"reset_session\"\n+                ):\n+                    captured = True\n+        assert captured, (\n+            \"gateway/run.py auto-reset block calls reset_session() but discards \"\n+            \"its return value. The fresh SessionEntry must be captured so the \"\n+            \"topic binding can be re-pointed at it; otherwise the next message \"\n+            \"resolves back to the bloated compressed child (#35809).\"\n+        )\n+\n+    def test_topic_binding_is_resynced_after_reset(self):\n+        \"\"\"The block must re-sync the topic binding so the next inbound message\n+        cannot ``switch_session`` back onto the bloated compressed child.\"\"\"\n+        block = _find_compression_exhausted_reset_block()\n+        sync_calls = [\n+            sub\n+            for sub in ast.walk(block)\n+            if isinstance(sub, ast.Call)\n+            and isinstance(sub.func, ast.Attribute)\n+            and sub.func.attr == \"_sync_telegram_topic_binding\"\n+        ]\n+        assert sync_calls, (\n+            \"gateway/run.py auto-reset block does not call \"\n+            \"_sync_telegram_topic_binding after reset_session. Without it the \"\n+            \"(chat_id, thread_id) -> bloated-child binding survives the reset \"\n+            \"and the binding-heal walk re-anchors the fresh lane onto the \"\n+            \"oversized compressed transcript, re-triggering the loop (#35809).\"\n+        )\n+\n+\n+# ---------------------------------------------------------------------------\n+# Behavioral contract: reset yields a clean next-turn transcript\n+# ---------------------------------------------------------------------------\n+def _make_store(tmp_path):\n+    store = SessionStore(sessions_dir=tmp_path, config=GatewayConfig())\n+    # Isolate the SQLite transcript store so we exercise per-session_id\n+    # transcripts without touching the developer's real state.db.\n+    store._db = SessionDB(db_path=tmp_path / \"state.db\")\n+    return store\n+\n+\n+def _make_source():\n+    return SessionSource(platform=Platform.TELEGRAM, chat_id=\"123\", user_id=\"u1\")\n+\n+\n+def _bloat(n):\n+    # Stand-in for the oversized, post-compression \"child\" transcript that\n+    # could not be compressed any further (#35809).\n+    return [{\"role\": \"user\", \"content\": \"x\" * 2000} for _ in range(n)]\n+\n+\n+class TestAutoResetLoadsCleanContext:\n+    \"\"\"#35809: after the gateway auto-resets a session because compression\n+    was exhausted, the NEXT turn must load an EMPTY transcript for the new\n+    session_id — never the bloated compressed-child transcript.\"\"\"\n+\n+    def test_next_turn_transcript_is_empty_after_auto_reset(self, tmp_path):\n+        store = _make_store(tmp_path)\n+        source = _make_source()\n+\n+        entry = store.get_or_create_session(source)\n+        session_key = entry.session_key\n+        bloated_sid = entry.session_id\n+        store._db.create_session(\n+            session_id=bloated_sid, source=\"telegram\", user_id=\"u1\"\n+        )\n+        store._db.replace_messages(bloated_sid, _bloat(120))\n+        assert len(store.load_transcript(bloated_sid)) == 120  # precondition\n+\n+        new_entry = store.reset_session(session_key)\n+        assert new_entry is not None\n+        assert new_entry.session_id != bloated_sid\n+\n+        resolved = store.get_or_create_session(source)\n+        assert resolved.session_id == new_entry.session_id\n+        loaded = store.load_transcript(resolved.session_id)\n+\n+        assert loaded == [], (\n+            f\"Auto-reset must yield an empty context, got {len(loaded)} \"\n+            f\"messages — the bloated compressed child leaked into the new session.\"\n+        )\n+        # The old transcript is still searchable, not destroyed.\n+        assert len(store.load_transcript(bloated_sid)) == 120\n+\n+    def test_clean_context_survives_gateway_restart(self, tmp_path):\n+        \"\"\"The fresh, empty session must still be the one loaded after a\n+        gateway restart (sessions.json + state.db round-trip).\"\"\"\n+        store = _make_store(tmp_path)\n+        source = _make_source()\n+        entry = store.get_or_create_session(source)\n+        bloated_sid = entry.session_id\n+        store._db.create_session(\n+            session_id=bloated_sid, source=\"telegram\", user_id=\"u1\"\n+        )\n+        store._db.replace_messages(bloated_sid, _bloat(120))\n+\n+        new_entry = store.reset_session(entry.session_key)\n+        new_sid = new_entry.session_id\n+\n+        # Simulate restart: drop in-memory index, reload from disk.\n+        store._loaded = False\n+        store._entries.clear()\n+\n+        reloaded = store.get_or_create_session(source)\n+        assert reloaded.session_id == new_sid\n+        assert store.load_transcript(reloaded.session_id) == []\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}