{"repo":"NousResearch/hermes-agent","public_id":"29335","display_label":"Context compression session sync bug and fixes","summary":"A bug where mid-run context compression creates a new session that the gateway never receives, causing an infinite compression loop. Multiple fix attempts address persisting compressed context and syncing session state between the agent and gateway.","subsystem":"agent","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":31930},"resolution":{"state":"partially_resolved","by":"pr-31930"},"freshness":{"state":"verified","last_verified_at":"2026-06-13T11:52:00+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.22476190476190475},"fix_edges":[{"issue_number":29335,"pr_number":31930,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":29335,"pr_number":45529,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":44769,"pr_number":45529,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":29335,"title":"Bug: Context compression creates new session but gateway never sees it — infinite compression loop","url":null,"state":"closed"},{"kind":"issue","number":29926,"title":"[Bug] CLI: context compression runs but result discarded on next turn — session rotation not synced to conversation_history","url":null,"state":"closed"},{"kind":"issue","number":50037,"title":"[Bug] Context compression not persisted + preflight re-compresses on every session reload → context lost","url":null,"state":"closed"},{"kind":"pr","number":29505,"title":"fix(agent+gateway): break infinite context compression loop by persis…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#45529","number":45529}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":31730,"title":"fix(compression): 3-line fix for infinite compression loop (#29335)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3914089d5","number":0},{"kind":"commit","value":"3914089d5","number":0},{"kind":"commit","value":"11c40d6a4","number":0},{"kind":"commit","value":"11c40d6a4","number":0},{"kind":"pr","value":"#31930","number":31930},{"kind":"pr","value":"#29335","number":29335},{"kind":"pr","value":"#29335","number":29335}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":31930,"title":"fix(compression): break the infinite compression loop (salvage of #31730)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/conversation_loop.py","gateway/run.py","scripts/release.py","tests/gateway/test_compression_session_id_persistence.py","tests/run_agent/test_413_compression.py"],"fix_hunks":[{"file":"agent/conversation_loop.py","hunks":["@@ -484,7 +484,7 @@ def run_conversation(\n             tools=agent.tools or None,\n         )\n \n-        if _preflight_tokens >= agent.context_compressor.threshold_tokens:\n+        if agent.context_compressor.should_compress(_preflight_tokens):\n             logger.info(\n                 \"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)\",\n                 f\"{_preflight_tokens:,}\",","@@ -4180,6 +4180,7 @@ def _stop_spinner():\n         \"estimated_cost_usd\": agent.session_estimated_cost_usd,\n         \"cost_status\": agent.session_cost_status,\n         \"cost_source\": agent.session_cost_source,\n+        \"session_id\": agent.session_id,\n     }\n     if agent._tool_guardrail_halt_decision is not None:\n         result[\"guardrail\"] = agent._tool_guardrail_halt_decision.to_metadata()"]},{"file":"gateway/run.py","hunks":["@@ -8692,6 +8692,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n             # session_entry so transcript writes below go to the right session.\n             if agent_result.get(\"session_id\") and agent_result[\"session_id\"] != session_entry.session_id:\n                 session_entry.session_id = agent_result[\"session_id\"]\n+                self.session_store._save()\n \n             # Prepend reasoning/thinking if display is enabled (per-platform)\n             try:"]},{"file":"scripts/release.py","hunks":["@@ -56,6 +56,7 @@\n     \"[redacted:email]\": \"WorldWriter\",\n     \"[redacted:email]\": \"WorldWriter\",\n     \"[redacted:email]\": \"Strontvod\",\n+    \"[redacted:email]\": \"someaka\",\n     \"[redacted:email]\": \"Jaggia\",\n     \"[redacted:email]\": \"simpolism\",\n     \"[redacted:email]\": \"simpolism\","]},{"file":"tests/gateway/test_compression_session_id_persistence.py","hunks":["@@ -0,0 +1,111 @@\n+\"\"\"Regression tests for #29335 — gateway must persist ``session_entry.session_id``\n+after the agent's compression path mutates it.\n+\n+When ``_compress_context()`` rolls the agent forward into a new session, the\n+agent now returns the new ``session_id`` in its result dict. The gateway\n+updates ``session_entry.session_id`` in memory AND must call\n+``session_store._save()`` so the new mapping survives a gateway restart.\n+Without ``_save()``, the next turn loads the OLD session's transcript and\n+re-triggers compression forever.\n+\n+Three sites in ``gateway/run.py`` mutate ``session_entry.session_id`` after\n+a compression-induced session split. All three MUST be followed by a\n+``_save()`` call. This test pins that invariant.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import ast\n+import inspect\n+import textwrap\n+\n+from gateway import run as gateway_run\n+\n+\n+def _session_id_assignments_followed_by_save(source: str) -> list[tuple[int, bool]]:\n+    \"\"\"For each ``session_entry.session_id = ...`` assignment in *source*,\n+    return ``(lineno, saved_within_5_stmts)`` — True iff a\n+    ``self.session_store._save()`` call appears in the same block within the\n+    next 5 statements (covers normal control flow without false-flagging\n+    cleanup that lives 200 lines away).\n+    \"\"\"\n+    tree = ast.parse(textwrap.dedent(source))\n+    results: list[tuple[int, bool]] = []\n+\n+    class _Visitor(ast.NodeVisitor):\n+        def _is_session_id_assign(self, node: ast.AST) -> bool:\n+            if not isinstance(node, ast.Assign):\n+                return False\n+            for target in node.targets:\n+                if (\n+                    isinstance(target, ast.Attribute)\n+                    and target.attr == \"session_id\"\n+                    and isinstance(target.value, ast.Name)\n+                    and target.value.id == \"session_entry\"\n+                ):\n+                    return True\n+            return False\n+\n+        def _block_has_save_after(self, body: list[ast.stmt], idx: int) -> bool:\n+            for stmt in body[idx : idx + 6]:\n+                for sub in ast.walk(stmt):\n+                    if (\n+                        isinstance(sub, ast.Call)\n+                        and isinstance(sub.func, ast.Attribute)\n+                        and sub.func.attr == \"_save\"\n+                    ):\n+                        return True\n+            return False\n+\n+        def _walk_body(self, body: list[ast.stmt]) -> None:\n+            for i, stmt in enumerate(body):\n+                if self._is_session_id_assign(stmt):\n+                    results.append((stmt.lineno, self._block_has_save_after(body, i)))\n+                for child in ast.iter_child_nodes(stmt):\n+                    if isinstance(child, (ast.If, ast.For, ast.While, ast.With,\n+                                          ast.Try, ast.AsyncWith, ast.AsyncFor)):\n+                        self._walk_node(child)\n+\n+        def _walk_node(self, node: ast.AST) -> None:\n+            for attr in (\"body\", \"orelse\", \"finalbody\"):\n+                inner = getattr(node, attr, None)\n+                if isinstance(inner, list):\n+                    self._walk_body(inner)\n+            if hasattr(node, \"handlers\"):\n+                for handler in node.handlers:\n+                    self._walk_body(handler.body)\n+\n+        def visit(self, node: ast.AST) -> None:\n+            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):\n+                self._walk_body(node.body)\n+            for child in ast.iter_child_nodes(node):\n+                self.visit(child)\n+\n+    _Visitor().visit(tree)\n+    return results\n+\n+\n+def test_every_post_compression_session_id_assignment_persists():\n+    \"\"\"Every ``session_entry.session_id = ...`` in gateway/run.py must be\n+    followed by a ``session_store._save()`` call within the same block.\n+\n+    Regression for #29335 — the assignment at the end of\n+    ``_handle_message_with_agent`` used to skip ``_save()`` while two sibling\n+    sites (hygiene rewrite, manual /compress) already persisted. The agent\n+    would compress correctly, the gateway would update its in-memory\n+    session_id, then drop it on next gateway restart.\n+    \"\"\"\n+    source = inspect.getsource(gateway_run)\n+    assignments = _session_id_assignments_followed_by_save(source)\n+    assert assignments, (\n+        \"No ``session_entry.session_id = ...`` assignments found in gateway/run.py — \"\n+        \"either the structure changed or the AST walker is broken.\"\n+    )\n+    missing = [lineno for lineno, saved in assignments if not saved]\n+    assert not missing, (\n+        f\"{len(missing)} ``session_entry.session_id = ...`` site(s) in gateway/run.py \"\n+        f\"are not followed by ``session_store._save()`` within the same block \"\n+        f\"(lines: {missing}). Every post-compression session_id update must persist \"\n+        f\"or the next turn loads the pre-compression transcript and triggers an \"\n+        f\"infinite compression loop. See issue #29335.\"\n+    )"]},{"file":"tests/run_agent/test_413_compression.py","hunks":["@@ -543,6 +543,40 @@ def test_no_preflight_when_compression_disabled(self, agent):\n \n         mock_compress.assert_not_called()\n \n+    def test_preflight_respects_anti_thrash(self, agent):\n+        \"\"\"Preflight must call ``should_compress()`` so anti-thrash applies.\n+\n+        Regression for #29335 — preflight used to bypass ``should_compress()``\n+        and re-trigger every turn even when the prior two passes each saved\n+        <10% (the canonical infinite-compression-loop signal).\n+        \"\"\"\n+        agent.compression_enabled = True\n+        agent.context_compressor.context_length = 2000\n+        agent.context_compressor.threshold_tokens = 200\n+\n+        big_history = []\n+        for i in range(20):\n+            big_history.append({\"role\": \"user\", \"content\": f\"Message {i} padded\"})\n+            big_history.append({\"role\": \"assistant\", \"content\": f\"Response {i} padded\"})\n+\n+        ok_resp = _mock_response(content=\"No preflight\", finish_reason=\"stop\")\n+        agent.client.chat.completions.create.side_effect = [ok_resp]\n+\n+        with (\n+            patch.object(agent.context_compressor, \"should_compress\", return_value=False) as mock_should,\n+            patch.object(agent, \"_compress_context\") as mock_compress,\n+            patch.object(agent, \"_persist_session\"),\n+            patch.object(agent, \"_save_trajectory\"),\n+            patch.object(agent, \"_cleanup_task_resources\"),\n+        ):\n+            result = agent.run_conversation(\"hello\", conversation_history=big_history)\n+\n+        # The gate consulted should_compress — anti-thrash had a chance to vote.\n+        mock_should.assert_called()\n+        # And vetoed: even though tokens >= threshold, no compression ran.\n+        mock_compress.assert_not_called()\n+        assert result[\"completed\"] is True\n+\n \n class TestToolResultPreflightCompression:\n     \"\"\"Compression should trigger when tool results push context past the threshold.\"\"\"\n"]}]},{"kind":"pr","number":45529,"title":"fix(gateway): sync compression splits before failed turns","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","scripts/release.py","tests/gateway/test_compression_failure_session_sync.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}