{"repo":"NousResearch/hermes-agent","public_id":"2153","display_label":"Gateway compression exhaustion handling and auto-reset","summary":"These issues and pull requests address a recurring problem where context compression exhaustion in long-running gateway sessions causes unresponsiveness or infinite loops. Work spans bug reports, fixes for the compression cycle, and features to trigger automatic session handoff when compression limits are reached.","subsystem":"agent","counts":{"members":10,"open_issues":2,"open_issues_distinct":2,"open_prs":1,"closed_issues":2,"merged_prs":3,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":4750},"resolution":{"state":"partially_resolved","by":"pr-4750,pr-10063"},"freshness":{"state":"verified","last_verified_at":"2026-06-13T13:38:29+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.15733333333333333},"fix_edges":[{"issue_number":2153,"pr_number":4750,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":9893,"pr_number":10063,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":9893,"pr_number":45613,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":35809,"pr_number":45613,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":2153,"title":"Bug: Context compression fails to trigger on API disconnect, causing death spiral in gateway sessions","url":null,"state":"closed"},{"kind":"issue","number":9893,"title":"Gateway becomes unresponsive when context compression is exhausted in long-running group chat sessions","url":null,"state":"closed"},{"kind":"issue","number":20372,"title":"Feature: automatic fresh-session handoff when compression becomes risky","url":null,"state":"open"},{"kind":"issue","number":54102,"title":"[Feature]: context-capacity trigger for session-handoff","url":null,"state":"open"},{"kind":"pr","number":2157,"title":"fix(gateway): fix compression threshold direction and add hard message limit","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#4750","number":4750}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":4750,"title":"fix: prevent compression death spiral from API disconnects (#2153)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","run_agent.py"]},{"kind":"pr","number":10063,"title":"fix(gateway): break compression-exhaustion infinite loop and auto-reset session","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","run_agent.py","tests/run_agent/test_1630_context_overflow_loop.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -3898,14 +3898,11 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):\n             # intermediate reasoning) so sessions can be resumed with full context\n             # and transcripts are useful for debugging and training data.\n             #\n-            # IMPORTANT: When the agent failed before producing any response\n-            # (e.g. context-overflow 400), do NOT persist the user's message.\n+            # IMPORTANT: When the agent failed (e.g. context-overflow 400,\n+            # compression exhausted), do NOT persist the user's message.\n             # Persisting it would make the session even larger, causing the\n-            # same failure on the next attempt — an infinite loop. (#1630)\n-            agent_failed_early = (\n-                agent_result.get(\"failed\")\n-                and not agent_result.get(\"final_response\")\n-            )\n+            # same failure on the next attempt — an infinite loop. (#1630, #9893)\n+            agent_failed_early = bool(agent_result.get(\"failed\"))\n             if agent_failed_early:\n                 logger.info(\n                     \"Skipping transcript persistence for failed request in \"","@@ -3913,6 +3910,24 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):\n                     session_entry.session_id,\n                 )\n \n+            # When compression is exhausted, the session is permanently too\n+            # large to process.  Auto-reset it so the next message starts\n+            # fresh instead of replaying the same oversized context in an\n+            # infinite fail loop.  (#9893)\n+            if agent_result.get(\"compression_exhausted\") and session_entry and session_key:\n+                logger.info(\n+                    \"Auto-resetting session %s after compression exhaustion.\",\n+                    session_entry.session_id,\n+                )\n+                self.session_store.reset_session(session_key)\n+                self._evict_cached_agent(session_key)\n+                self._session_model_overrides.pop(session_key, None)\n+                response = (response or \"\") + (\n+                    \"\\n\\n🔄 Session auto-reset — the conversation exceeded the \"\n+                    \"maximum context size and could not be compressed further. \"\n+                    \"Your next message will start a fresh session.\"\n+                )\n+\n             ts = datetime.now().isoformat()\n             \n             # If this is a fresh session (no history), write the full tool","@@ -8624,6 +8639,8 @@ def _approval_notify_sync(approval_data: dict) -> None:\n                     \"final_response\": error_msg,\n                     \"messages\": result.get(\"messages\", []),\n                     \"api_calls\": result.get(\"api_calls\", 0),\n+                    \"failed\": result.get(\"failed\", False),\n+                    \"compression_exhausted\": result.get(\"compression_exhausted\", False),\n                     \"tools\": tools_holder[0] or [],\n                     \"history_offset\": len(agent_history),\n                     \"last_prompt_tokens\": _last_prompt_toks,"]},{"file":"run_agent.py","hunks":["@@ -9312,7 +9312,9 @@ def _stop_spinner():\n                                 \"completed\": False,\n                                 \"api_calls\": api_call_count,\n                                 \"error\": f\"Request payload too large: max compression attempts ({max_compression_attempts}) reached.\",\n-                                \"partial\": True\n+                                \"partial\": True,\n+                                \"failed\": True,\n+                                \"compression_exhausted\": True,\n                             }\n                         self._emit_status(f\"⚠️  Request payload too large (413) — compression attempt {compression_attempts}/{max_compression_attempts}...\")\n ","@@ -9341,7 +9343,9 @@ def _stop_spinner():\n                                 \"completed\": False,\n                                 \"api_calls\": api_call_count,\n                                 \"error\": \"Request payload too large (413). Cannot compress further.\",\n-                                \"partial\": True\n+                                \"partial\": True,\n+                                \"failed\": True,\n+                                \"compression_exhausted\": True,\n                             }\n \n                     # Check for context-length errors BEFORE generic 4xx handler.","@@ -9392,7 +9396,9 @@ def _stop_spinner():\n                                     \"completed\": False,\n                                     \"api_calls\": api_call_count,\n                                     \"error\": f\"Context length exceeded: max compression attempts ({max_compression_attempts}) reached.\",\n-                                    \"partial\": True\n+                                    \"partial\": True,\n+                                    \"failed\": True,\n+                                    \"compression_exhausted\": True,\n                                 }\n                             restart_with_compressed_messages = True\n                             break","@@ -9442,7 +9448,9 @@ def _stop_spinner():\n                                 \"completed\": False,\n                                 \"api_calls\": api_call_count,\n                                 \"error\": f\"Context length exceeded: max compression attempts ({max_compression_attempts}) reached.\",\n-                                \"partial\": True\n+                                \"partial\": True,\n+                                \"failed\": True,\n+                                \"compression_exhausted\": True,\n                             }\n                         self._emit_status(f\"🗜️ Context too large (~{approx_tokens:,} tokens) — compressing ({compression_attempts}/{max_compression_attempts})...\")\n ","@@ -9473,7 +9481,9 @@ def _stop_spinner():\n                                 \"completed\": False,\n                                 \"api_calls\": api_call_count,\n                                 \"error\": f\"Context length exceeded ({approx_tokens:,} tokens). Cannot compress further.\",\n-                                \"partial\": True\n+                                \"partial\": True,\n+                                \"failed\": True,\n+                                \"compression_exhausted\": True,\n                             }\n \n                     # Check for non-retryable client errors.  The classifier"]},{"file":"tests/run_agent/test_1630_context_overflow_loop.py","hunks":["@@ -136,33 +136,29 @@ class TestGatewaySkipsPersistenceOnFailure:\n     the gateway should NOT persist messages to the transcript.\"\"\"\n \n     def test_agent_failed_early_detected(self):\n-        \"\"\"The agent_failed_early flag is True when failed=True and\n-        no final_response.\"\"\"\n+        \"\"\"The agent_failed_early flag is True when failed=True,\n+        regardless of final_response.\"\"\"\n         agent_result = {\n             \"failed\": True,\n             \"final_response\": None,\n             \"messages\": [],\n             \"error\": \"Non-retryable client error\",\n         }\n-        agent_failed_early = (\n-            agent_result.get(\"failed\")\n-            and not agent_result.get(\"final_response\")\n-        )\n+        agent_failed_early = bool(agent_result.get(\"failed\"))\n         assert agent_failed_early\n \n-    def test_agent_with_response_not_failed_early(self):\n-        \"\"\"When the agent has a final_response, it's not a failed-early\n-        scenario even if failed=True.\"\"\"\n+    def test_agent_failed_with_error_response_still_detected(self):\n+        \"\"\"When _run_agent_blocking converts an error to final_response,\n+        the failed flag should still trigger agent_failed_early.  This\n+        was the core bug in #9893 — the old guard checked\n+        ``not final_response`` which was always truthy after conversion.\"\"\"\n         agent_result = {\n             \"failed\": True,\n-            \"final_response\": \"Here is a partial response\",\n+            \"final_response\": \"⚠️ Request payload too large: max compression attempts reached.\",\n             \"messages\": [],\n         }\n-        agent_failed_early = (\n-            agent_result.get(\"failed\")\n-            and not agent_result.get(\"final_response\")\n-        )\n-        assert not agent_failed_early\n+        agent_failed_early = bool(agent_result.get(\"failed\"))\n+        assert agent_failed_early\n \n     def test_successful_agent_not_failed_early(self):\n         \"\"\"A successful agent result should not trigger skip.\"\"\"","@@ -170,13 +166,41 @@ def test_successful_agent_not_failed_early(self):\n             \"final_response\": \"Hello!\",\n             \"messages\": [{\"role\": \"assistant\", \"content\": \"Hello!\"}],\n         }\n-        agent_failed_early = (\n-            agent_result.get(\"failed\")\n-            and not agent_result.get(\"final_response\")\n-        )\n+        agent_failed_early = bool(agent_result.get(\"failed\"))\n         assert not agent_failed_early\n \n \n+class TestCompressionExhaustedFlag:\n+    \"\"\"When compression is exhausted, the agent should set both\n+    failed=True and compression_exhausted=True so the gateway can\n+    auto-reset the session.  (#9893)\"\"\"\n+\n+    def test_compression_exhausted_returns_carry_flag(self):\n+        \"\"\"Simulate the return dict from a compression-exhausted agent.\"\"\"\n+        agent_result = {\n+            \"messages\": [],\n+            \"completed\": False,\n+            \"api_calls\": 3,\n+            \"error\": \"Request payload too large: max compression attempts (3) reached.\",\n+            \"partial\": True,\n+            \"failed\": True,\n+            \"compression_exhausted\": True,\n+        }\n+        assert agent_result.get(\"failed\")\n+        assert agent_result.get(\"compression_exhausted\")\n+\n+    def test_normal_failure_not_compression_exhausted(self):\n+        \"\"\"Non-compression failures should not have compression_exhausted.\"\"\"\n+        agent_result = {\n+            \"messages\": [],\n+            \"completed\": False,\n+            \"failed\": True,\n+            \"error\": \"Invalid API response after 3 retries\",\n+        }\n+        assert agent_result.get(\"failed\")\n+        assert not agent_result.get(\"compression_exhausted\")\n+\n+\n # ---------------------------------------------------------------------------\n # Test 3: Context-overflow error messages\n # ---------------------------------------------------------------------------\n"]}]},{"kind":"pr","number":29293,"title":"feat: add bounded compression handoff","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#29293","number":29293}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"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","implication":"superseded"},"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}}