{"repo":"NousResearch/hermes-agent","public_id":"23975","display_label":"Protecting context compression from mid-flight interrupts","summary":"The three members all concern context compression being interrupted, one by gateway messages and others by session interrupts, which can lead to a fallback summary marker. One member reports the problem and two are fixes aimed at protecting the summary call from interruption; their outcomes are not stated in the titles.","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":49928,"merged_at":"2026-06-21T04:32:30+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-21T04:32:30+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":23975,"pr_number":49928,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-21T04:32:30+00:00"}],"members":[{"kind":"issue","number":23975,"title":"Context compression can be interrupted by gateway messages, causing fallback summary marker","url":null,"state":"closed"},{"kind":"pr","number":24001,"title":"fix(agent): protect context compression from session interrupts","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#49928","number":49928}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":49928,"title":"fix(compression): protect the summary call from mid-flight interrupts (#23975)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","agent/context_compressor.py","tests/agent/test_compression_interrupt_protection.py"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -40,6 +40,7 @@\n   their OpenRouter balance but has Codex OAuth or another provider available.\n \"\"\"\n \n+import contextlib\n import json\n import logging\n import os","@@ -107,6 +108,39 @@ def __repr__(self):\n logger = logging.getLogger(__name__)\n \n \n+# ── Interrupt protection for atomic auxiliary tasks ──────────────────────\n+# Some auxiliary tasks must NOT be aborted mid-flight by a gateway interrupt\n+# (e.g. an incoming user message while the agent is busy). Context\n+# compression is the prime case: if the summary LLM call is interrupted\n+# part-way, compression falls back to a static \"summary unavailable\" marker\n+# and the real handoff is lost (#23975). A thread-local flag lets such a\n+# task mark its in-flight LLM call as interrupt-protected; the Codex\n+# Responses stream's cancellation check honors it. TIMEOUTS still fire\n+# (a hung call must die), and all OTHER aux tasks (vision, web_extract,\n+# title_generation, …) remain freely interruptible.\n+_aux_interrupt_protection = threading.local()\n+\n+\n+def _aux_interrupt_protected() -> bool:\n+    return bool(getattr(_aux_interrupt_protection, \"active\", False))\n+\n+\n[redacted:email]\n+def aux_interrupt_protection(active: bool = True):\n+    \"\"\"Mark the current thread's auxiliary LLM call as interrupt-protected.\n+\n+    Used by atomic aux tasks (compression) so a mid-flight gateway interrupt\n+    doesn't abort the call and trigger a degraded fallback. Re-entrant-safe:\n+    restores the previous value on exit.\n+    \"\"\"\n+    prev = getattr(_aux_interrupt_protection, \"active\", False)\n+    _aux_interrupt_protection.active = active\n+    try:\n+        yield\n+    finally:\n+        _aux_interrupt_protection.active = prev\n+\n+\n def _safe_isinstance(obj: Any, maybe_type: Any) -> bool:\n     \"\"\"Return False instead of raising when a patched symbol is not a type.\"\"\"\n     try:","@@ -805,7 +839,11 @@ def _check_cancelled() -> None:\n                 raise TimeoutError(_timeout_message())\n             try:\n                 from tools.interrupt import is_interrupted\n-                if is_interrupted():\n+                # Honor interrupt protection for atomic aux tasks (compression):\n+                # a mid-flight gateway interrupt must NOT abort the summary call\n+                # and trigger a degraded fallback marker (#23975). Timeouts above\n+                # still fire; other aux tasks remain interruptible.\n+                if is_interrupted() and not _aux_interrupt_protected():\n                     raise InterruptedError(\"Codex auxiliary Responses stream interrupted\")\n             except InterruptedError:\n                 raise"]},{"file":"agent/context_compressor.py","hunks":["@@ -23,7 +23,7 @@\n import time\n from typing import Any, Dict, List, Optional\n \n-from agent.auxiliary_client import call_llm, _is_connection_error\n+from agent.auxiliary_client import call_llm, _is_connection_error, aux_interrupt_protection\n from agent.context_engine import ContextEngine\n from agent.model_metadata import (\n     MINIMUM_CONTEXT_LENGTH,","@@ -1519,7 +1519,13 @@ def _generate_summary(\n             }\n             if self.summary_model:\n                 call_kwargs[\"model\"] = self.summary_model\n-            response = call_llm(**call_kwargs)\n+            # Compression is atomic: protect the in-flight summary call from a\n+            # mid-turn gateway interrupt. Without this, an incoming user message\n+            # aborts the summary and compression falls back to a degraded static\n+            # marker, losing the real handoff (#23975). Re-entrant: a main-model\n+            # retry (_generate_summary recursion) re-enters harmlessly.\n+            with aux_interrupt_protection():\n+                response = call_llm(**call_kwargs)\n             content = response.choices[0].message.content\n             # Handle cases where content is not a string (e.g., dict from llama.cpp)\n             if not isinstance(content, str):"]},{"file":"tests/agent/test_compression_interrupt_protection.py","hunks":["@@ -0,0 +1,95 @@\n+\"\"\"Regression for #23975: context compression must survive a mid-flight\n+gateway interrupt.\n+\n+While the compression summary LLM call is in flight, an incoming gateway\n+message sets the thread interrupt flag. The Codex Responses aux stream polls\n+that flag and used to raise InterruptedError unconditionally — aborting the\n+summary, which then fell back to a degraded static \"summary unavailable\"\n+marker (losing the real handoff). Compression now runs its summary call\n+under aux_interrupt_protection(), so the interrupt poll is masked for the\n+compression task only (timeouts and other aux tasks stay interruptible).\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from unittest.mock import patch\n+\n+import agent.auxiliary_client as aux\n+\n+\n+class TestAuxInterruptProtection:\n+    def test_protected_flag_defaults_false(self):\n+        # Fresh thread-local state.\n+        assert aux._aux_interrupt_protected() is False\n+\n+    def test_context_manager_sets_and_restores(self):\n+        assert aux._aux_interrupt_protected() is False\n+        with aux.aux_interrupt_protection():\n+            assert aux._aux_interrupt_protected() is True\n+        assert aux._aux_interrupt_protected() is False\n+\n+    def test_context_manager_is_reentrant(self):\n+        with aux.aux_interrupt_protection():\n+            assert aux._aux_interrupt_protected() is True\n+            with aux.aux_interrupt_protection():\n+                assert aux._aux_interrupt_protected() is True\n+            # inner exit must NOT clear protection while still inside outer\n+            assert aux._aux_interrupt_protected() is True\n+        assert aux._aux_interrupt_protected() is False\n+\n+    def test_restores_on_exception(self):\n+        try:\n+            with aux.aux_interrupt_protection():\n+                raise ValueError(\"boom\")\n+        except ValueError:\n+            pass\n+        assert aux._aux_interrupt_protected() is False\n+\n+    def test_explicit_inactive_is_noop(self):\n+        with aux.aux_interrupt_protection(active=False):\n+            assert aux._aux_interrupt_protected() is False\n+\n+\n+class TestCompressionProtectsSummaryCall:\n+    \"\"\"The compressor must wrap its summary call_llm in aux_interrupt_protection\n+    so a mid-flight interrupt doesn't abort it (#23975).\"\"\"\n+\n+    def test_compressor_call_site_uses_protection(self):\n+        # The summary call must run inside aux_interrupt_protection. We assert\n+        # the protection flag is ACTIVE at the moment call_llm is invoked.\n+        from agent.context_compressor import ContextCompressor\n+\n+        seen = {}\n+\n+        class _Resp:\n+            class _Choice:\n+                class _Msg:\n+                    content = \"[CONTEXT SUMMARY]: ok\"\n+                message = _Msg()\n+            choices = [_Choice()]\n+\n+        def fake_call_llm(**kwargs):\n+            # Capture whether protection was active during the call.\n+            seen[\"protected\"] = aux._aux_interrupt_protected()\n+            seen[\"task\"] = kwargs.get(\"task\")\n+            return _Resp()\n+\n+        with patch(\"agent.context_compressor.get_model_context_length\", return_value=100000):\n+            c = ContextCompressor(model=\"test\", quiet_mode=True)\n+\n+        msgs = [\n+            {\"role\": \"user\", \"content\": \"do a thing\"},\n+            {\"role\": \"assistant\", \"content\": \"working\"},\n+            {\"role\": \"user\", \"content\": \"more\"},\n+            {\"role\": \"assistant\", \"content\": \"done\"},\n+        ]\n+        with patch(\"agent.context_compressor.call_llm\", side_effect=fake_call_llm):\n+            summary = c._generate_summary(msgs)\n+\n+        assert summary is not None\n+        assert seen.get(\"task\") == \"compression\"\n+        assert seen.get(\"protected\") is True, (\n+            \"compression summary call must run under aux_interrupt_protection\"\n+        )\n+        # Protection must be cleared after the call returns.\n+        assert aux._aux_interrupt_protected() is False\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}