{"repo":"NousResearch/hermes-agent","public_id":"38390","display_label":"Gateway busy-mode interrupt handling fixes and regressions","summary":"Several fixes and reported bugs around busy_input_mode and busy_text_mode configuration in the gateway and TUI, including inheritance behavior, default values silently disabling interrupts, and variable expansion in runtime config readers.","subsystem":"gateway","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":40590},"resolution":{"state":"partially_resolved","by":"pr-40590"},"freshness":{"state":"verified","last_verified_at":"2026-06-06T16:00:10+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33599999999999997},"fix_edges":[{"issue_number":35267,"pr_number":40590,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":38390,"pr_number":40590,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":38390,"title":"Regression: busy_input_mode=interrupt broken in v2026.5.29 (worked in v2026.5.16)","url":null,"state":"closed"},{"kind":"issue","number":44094,"title":"Bug: `busy_text_mode` defaults to \"queue\", silently disabling interrupt for text messages","url":null,"state":"closed"},{"kind":"pr","number":38399,"title":"fix(gateway): inherit busy_text_mode from busy_input_mode when not configured","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"54e7b74f7f476b7d0de31e5f3a97376647e68e64","number":0},{"kind":"pr","value":"#40590","number":40590},{"kind":"pr","value":"#38390","number":38390}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":38485,"title":"fix(gateway): inherit busy_text_mode from busy_input_mode when not configured","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"54e7b74f7f476b7d0de31e5f3a97376647e68e64","number":0},{"kind":"pr","value":"#40590","number":40590},{"kind":"pr","value":"#38390","number":38390}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":40590,"title":"fix(gateway): plain text while busy interrupts by default again","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/platforms/base.py","gateway/run.py","tests/gateway/test_active_session_text_merge.py","tests/gateway/test_restart_drain.py","tests/run_agent/test_413_compression.py","tests/run_agent/test_run_agent.py"],"fix_hunks":[{"file":"agent/conversation_loop.py","hunks":["@@ -2720,6 +2720,61 @@ def _perform_api_call(next_api_kwargs):\n                 # compress history and retry, not abort immediately.\n                 status_code = getattr(api_error, \"status_code\", None)\n \n+                # ── Respect disabled auto-compaction on overflow ──────\n+                # Ported from anomalyco/opencode#30749.  When the user has\n+                # turned auto-compaction off (``compression.enabled: false``),\n+                # NO automatic compaction trigger may fire — including the\n+                # provider/request-size overflow recovery paths below\n+                # (long-context-tier 429, 413 payload-too-large, and\n+                # context-overflow).  Without this guard the proactive\n+                # threshold path correctly honours the setting (see the\n+                # preflight check and the post-response ``should_compress``\n+                # gate) but a provider overflow error would still silently\n+                # compress + rotate the session, bypassing the user's\n+                # explicit choice.  Surface a terminal error instead so the\n+                # user can compact manually (``/compress``), start fresh\n+                # (``/new``), switch to a larger-context model, or reduce\n+                # attachments.  Forced compaction via ``/compress``\n+                # (``force=True``) is unaffected — it never reaches this loop.\n+                _overflow_reasons = {\n+                    FailoverReason.long_context_tier,\n+                    FailoverReason.payload_too_large,\n+                    FailoverReason.context_overflow,\n+                }\n+                if (\n+                    classified.reason in _overflow_reasons\n+                    and not getattr(agent, \"compression_enabled\", True)\n+                ):\n+                    agent._flush_status_buffer()\n+                    agent._vprint(\n+                        f\"{agent.log_prefix}❌ Context overflow, but auto-compaction is disabled \"\n+                        f\"(compression.enabled: false).\",\n+                        force=True,\n+                    )\n+                    agent._vprint(\n+                        f\"{agent.log_prefix}   💡 Run /compress to compact manually, /new to start fresh, \"\n+                        f\"switch to a larger-context model, or reduce attachments.\",\n+                        force=True,\n+                    )\n+                    logger.error(\n+                        f\"{agent.log_prefix}Context overflow ({classified.reason.value}) with \"\n+                        f\"auto-compaction disabled — not compressing.\"\n+                    )\n+                    agent._persist_session(messages, conversation_history)\n+                    return {\n+                        \"messages\": messages,\n+                        \"completed\": False,\n+                        \"api_calls\": api_call_count,\n+                        \"error\": (\n+                            \"Context overflow and auto-compaction is disabled \"\n+                            \"(compression.enabled: false). Run /compress to compact manually, \"\n+                            \"/new to start fresh, or switch to a larger-context model.\"\n+                        ),\n+                        \"partial\": True,\n+                        \"failed\": True,\n+                        \"compaction_disabled\": True,\n+                    }\n+\n                 # ── Anthropic Sonnet long-context tier gate ───────────\n                 # Anthropic returns HTTP 429 \"Extra usage is required for\n                 # long context requests\" when a Claude Max (or similar)"]},{"file":"gateway/platforms/base.py","hunks":["@@ -1817,9 +1817,14 @@ def __init__(self, config: PlatformConfig, platform: Platform):\n         self._active_sessions: Dict[str, asyncio.Event] = {}\n         self._pending_messages: Dict[str, MessageEvent] = {}\n         self._session_tasks: Dict[str, asyncio.Task] = {}\n+        # Legacy busy_text_mode env var; when unset the runner syncs the\n+        # resolved value (driven by busy_input_mode) onto the adapter after\n+        # construction (gateway/run.py). Default to \"interrupt\" so a stray\n+        # pre-sync read matches the single-knob default rather than silently\n+        # queueing.\n         self._busy_text_mode: str = (\n-            os.environ.get(\"HERMES_GATEWAY_BUSY_TEXT_MODE\", \"queue\").strip().lower()\n-            or \"queue\"\n+            os.environ.get(\"HERMES_GATEWAY_BUSY_TEXT_MODE\", \"interrupt\").strip().lower()\n+            or \"interrupt\"\n         )\n         self._busy_text_debounce_seconds: float = _float_env(\n             \"HERMES_GATEWAY_BUSY_TEXT_DEBOUNCE_SECONDS\", 0.35","@@ -3395,7 +3400,7 @@ def _text_debounce_store(self) -> dict[str, TextDebounceState]:\n     def _is_queue_text_debounce_candidate(self, event: MessageEvent) -> bool:\n         \"\"\"Return True for normal text eligible for queue-mode debounce.\"\"\"\n         result = (\n-            getattr(self, \"_busy_text_mode\", \"queue\") == \"queue\"\n+            getattr(self, \"_busy_text_mode\", \"interrupt\") == \"queue\"\n             and event.message_type == MessageType.TEXT\n             and not getattr(event, \"internal\", False)\n             and not event.is_command()"]},{"file":"gateway/run.py","hunks":["@@ -3195,14 +3195,27 @@ def _load_busy_input_mode() -> str:\n \n     @staticmethod\n     def _load_busy_text_mode() -> str:\n-        \"\"\"Load normal busy TEXT follow-up behavior from config/env.\"\"\"\n-        mode = os.getenv(\"HERMES_GATEWAY_BUSY_TEXT_MODE\", \"\").strip().lower()\n-        if not mode:\n+        \"\"\"Resolve normal busy TEXT follow-up behavior.\n+\n+        ``busy_input_mode`` is the single source of truth (default\n+        ``interrupt``). The legacy ``busy_text_mode`` knob is honored only\n+        when a user explicitly set it, so existing queue setups keep\n+        working; new installs follow ``busy_input_mode``. Returns one of\n+        ``interrupt`` | ``queue`` (``steer`` is handled upstream by\n+        ``busy_input_mode`` and maps to non-queue text handling here).\n+        \"\"\"\n+        # Legacy explicit override wins for backward compat.\n+        legacy = os.getenv(\"HERMES_GATEWAY_BUSY_TEXT_MODE\", \"\").strip().lower()\n+        if not legacy:\n             cfg = _load_gateway_runtime_config()\n-            mode = str(cfg_get(cfg, \"display\", \"busy_text_mode\", default=\"\") or \"\").strip().lower()\n-        if mode == \"interrupt\":\n+            legacy = str(cfg_get(cfg, \"display\", \"busy_text_mode\", default=\"\") or \"\").strip().lower()\n+        if legacy == \"interrupt\":\n             return \"interrupt\"\n-        return \"queue\"\n+        if legacy == \"queue\":\n+            return \"queue\"\n+        # No explicit legacy knob → follow busy_input_mode.\n+        input_mode = GatewayRunner._load_busy_input_mode()\n+        return \"queue\" if input_mode == \"queue\" else \"interrupt\"\n \n     @staticmethod\n     def _load_restart_drain_timeout() -> float:","@@ -3390,7 +3403,7 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session\n         running_agent = self._running_agents.get(session_key)\n \n         effective_mode = self._busy_input_mode\n-        busy_text_mode = getattr(self, \"_busy_text_mode\", \"queue\")\n+        busy_text_mode = getattr(self, \"_busy_text_mode\", \"interrupt\")\n         if (\n             event.message_type == MessageType.TEXT\n             and busy_text_mode == \"queue\""]},{"file":"tests/gateway/test_active_session_text_merge.py","hunks":["@@ -352,14 +352,15 @@ async def test_single_followup_is_stored_as_is():\n     assert not adapter._active_sessions[session_key].is_set()\n \n \n-def test_adapter_defaults_to_queue_mode(monkeypatch):\n+def test_adapter_defaults_to_interrupt_mode(monkeypatch):\n     monkeypatch.delenv(\"HERMES_GATEWAY_BUSY_TEXT_MODE\", raising=False)\n     adapter = _make_initialized_adapter()\n-    assert adapter._busy_text_mode == \"queue\"\n-    assert adapter._is_queue_text_debounce_candidate(_make_event(\"hello\"))\n+    assert adapter._busy_text_mode == \"interrupt\"\n+    assert not adapter._is_queue_text_debounce_candidate(_make_event(\"hello\"))\n \n \n-def test_adapter_is_queue_text_debounce_candidate_by_default():\n+def test_adapter_is_queue_text_debounce_candidate_when_queue_set():\n+    # _make_adapter() pins _busy_text_mode=\"queue\" to exercise debounce.\n     adapter = _make_adapter()\n     assert adapter._is_queue_text_debounce_candidate(_make_event(\"hello world\"))\n "]},{"file":"tests/gateway/test_restart_drain.py","hunks":["@@ -116,22 +116,37 @@ def test_load_busy_input_mode_prefers_env_then_config_then_default(tmp_path, mon\n     assert gateway_run.GatewayRunner._load_busy_input_mode() == \"interrupt\"\n \n \n-def test_load_busy_text_mode_defaults_to_queue_and_allows_interrupt(tmp_path, monkeypatch):\n+def test_load_busy_text_mode_follows_input_mode_and_honors_legacy(tmp_path, monkeypatch):\n     monkeypatch.setattr(gateway_run, \"_hermes_home\", tmp_path)\n     monkeypatch.delenv(\"HERMES_GATEWAY_BUSY_TEXT_MODE\", raising=False)\n+    monkeypatch.delenv(\"HERMES_GATEWAY_BUSY_INPUT_MODE\", raising=False)\n+\n+    # No knobs set → follows busy_input_mode, which defaults to interrupt.\n+    assert gateway_run.GatewayRunner._load_busy_text_mode() == \"interrupt\"\n \n+    # busy_input_mode=queue propagates to text handling (single source of truth).\n+    (tmp_path / \"config.yaml\").write_text(\n+        \"display:\\n  busy_input_mode: queue\\n\", encoding=\"utf-8\"\n+    )\n     assert gateway_run.GatewayRunner._load_busy_text_mode() == \"queue\"\n \n+    # Legacy explicit busy_text_mode still wins for backward compat.\n     (tmp_path / \"config.yaml\").write_text(\n-        \"display:\\n  busy_text_mode: interrupt\\n\", encoding=\"utf-8\"\n+        \"display:\\n  busy_input_mode: interrupt\\n  busy_text_mode: queue\\n\",\n+        encoding=\"utf-8\",\n     )\n-    assert gateway_run.GatewayRunner._load_busy_text_mode() == \"interrupt\"\n+    assert gateway_run.GatewayRunner._load_busy_text_mode() == \"queue\"\n \n+    # Legacy env override wins too.\n+    (tmp_path / \"config.yaml\").write_text(\n+        \"display:\\n  busy_input_mode: interrupt\\n\", encoding=\"utf-8\"\n+    )\n     monkeypatch.setenv(\"HERMES_GATEWAY_BUSY_TEXT_MODE\", \"queue\")\n     assert gateway_run.GatewayRunner._load_busy_text_mode() == \"queue\"\n \n+    # Bogus legacy value is ignored → falls through to busy_input_mode (interrupt).\n     monkeypatch.setenv(\"HERMES_GATEWAY_BUSY_TEXT_MODE\", \"bogus\")\n-    assert gateway_run.GatewayRunner._load_busy_text_mode() == \"queue\"\n+    assert gateway_run.GatewayRunner._load_busy_text_mode() == \"interrupt\"\n \n \n def test_load_restart_drain_timeout_prefers_env_then_config_then_default("]},{"file":"tests/run_agent/test_413_compression.py","hunks":["@@ -94,7 +94,11 @@ def agent():\n         a._cached_system_prompt = \"You are helpful.\"\n         a._use_prompt_caching = False\n         a.tool_delay = 0\n-        a.compression_enabled = False\n+        # Default matches production (`compression.enabled` defaults to True).\n+        # Overflow-recovery tests below verify that 413 / context-overflow\n+        # errors DO trigger compression; the disabled-path behavior is\n+        # covered explicitly by TestOverflowWithCompactionDisabled.\n+        a.compression_enabled = True\n         a.save_trajectories = False\n         return a\n ","@@ -415,6 +419,13 @@ class TestPreflightCompression:\n \n     def test_compress_context_emits_lifecycle_status_before_work(self, agent):\n         \"\"\"Direct context compression should tell gateway users why the turn paused.\"\"\"\n+        # This test calls _compress_context directly and asserts the FIRST\n+        # status event is the lifecycle \"Compacting context\" message. With\n+        # compaction enabled the lazy feasibility probe would emit an\n+        # aux-provider warning first (no aux key in the hermetic test env),\n+        # displacing events[0]. The flag value is irrelevant to what this\n+        # test asserts, so disable it to suppress the probe.\n+        agent.compression_enabled = False\n         events = []\n         agent.status_callback = lambda ev, msg: events.append((ev, msg))\n ","@@ -802,3 +813,95 @@ def test_anthropic_prompt_too_long_safety_net(self, agent):\n \n         mock_compress.assert_called_once()\n         assert result[\"completed\"] is True\n+\n+\n+# ---------------------------------------------------------------------------\n+# Disabled auto-compaction on overflow (port of anomalyco/opencode#30749)\n+# ---------------------------------------------------------------------------\n+\n+class TestOverflowWithCompactionDisabled:\n+    \"\"\"When ``compression.enabled`` is False, NO automatic compaction may\n+    fire — including the provider/request-size overflow recovery paths.\n+\n+    Ported from anomalyco/opencode#30749: the proactive token-threshold\n+    path already honoured the setting, but provider overflow errors\n+    (413 payload-too-large, context-overflow, long-context-tier 429) still\n+    silently compressed + rotated the session. The fix surfaces a terminal\n+    error so the user can compact manually, start fresh, or switch models.\n+    \"\"\"\n+\n+    @staticmethod\n+    def _prefill():\n+        return [\n+            {\"role\": \"user\", \"content\": \"previous question\"},\n+            {\"role\": \"assistant\", \"content\": \"previous answer\"},\n+        ]\n+\n+    def test_413_does_not_compress_when_disabled(self, agent):\n+        \"\"\"413 must NOT call _compress_context when compaction is disabled.\"\"\"\n+        agent.compression_enabled = False\n+        err_413 = _make_413_error()\n+        # If the guard fails, a second (success) response would be consumed.\n+        agent.client.chat.completions.create.side_effect = [err_413, _mock_response()]\n+\n+        with (\n+            patch.object(agent, \"_compress_context\") as mock_compress,\n+            patch.object(agent, \"_persist_session\") as mock_persist,\n+            patch.object(agent, \"_save_trajectory\"),\n+            patch.object(agent, \"_cleanup_task_resources\"),\n+        ):\n+            result = agent.run_conversation(\"hello\", conversation_history=self._prefill())\n+\n+        mock_compress.assert_not_called()\n+        mock_persist.assert_called()\n+        assert result.get(\"failed\") is True\n+        assert result.get(\"compaction_disabled\") is True\n+        assert \"auto-compaction is disabled\" in result[\"error\"]\n+\n+    def test_context_overflow_does_not_compress_when_disabled(self, agent):\n+        \"\"\"400 'prompt is too long' must NOT compress when compaction disabled.\"\"\"\n+        agent.compression_enabled = False\n+        err_400 = Exception(\n+            \"Error code: 400 - {'type': 'error', 'error': {'type': \"\n+            \"'invalid_request_error', 'message': 'prompt is too long: \"\n+            \"233153 tokens > 200000 maximum'}}\"\n+        )\n+        err_400.status_code = 400\n+        agent.client.chat.completions.create.side_effect = [err_400, _mock_response()]\n+\n+        with (\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=self._prefill())\n+\n+        mock_compress.assert_not_called()\n+        assert result.get(\"compaction_disabled\") is True\n+\n+    def test_413_still_compresses_when_enabled(self, agent):\n+        \"\"\"Control: with compaction enabled, 413 still triggers compression.\n+\n+        Guards against the disabled-path guard accidentally swallowing the\n+        enabled path.\n+        \"\"\"\n+        agent.compression_enabled = True\n+        err_413 = _make_413_error()\n+        ok_resp = _mock_response(content=\"Recovered\", finish_reason=\"stop\")\n+        agent.client.chat.completions.create.side_effect = [err_413, ok_resp]\n+\n+        with (\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+            mock_compress.return_value = (\n+                [{\"role\": \"user\", \"content\": \"hello\"}], \"compressed\",\n+            )\n+            result = agent.run_conversation(\"hello\", conversation_history=self._prefill())\n+\n+        mock_compress.assert_called_once()\n+        assert result[\"completed\"] is True\n+        assert result.get(\"compaction_disabled\") is not True"]},{"file":"tests/run_agent/test_run_agent.py","hunks":["@@ -3903,6 +3903,7 @@ def test_context_compression_triggered(self, agent):\n     def test_glm_prompt_exceeds_max_length_triggers_compression(self, agent):\n         \"\"\"GLM/Z.AI uses 'Prompt exceeds max length' for context overflow.\"\"\"\n         self._setup_agent(agent)\n+        agent.compression_enabled = True  # this test verifies overflow→compression fires\n         err_400 = Exception(\n             \"Error code: 400 - {'error': {'code': '1261', 'message': 'Prompt exceeds max length'}}\"\n         )","@@ -3937,6 +3938,7 @@ def test_minimax_delta_overflow_keeps_known_context_length(self, agent):\n         to the generic 128K fallback tier.\n         \"\"\"\n         self._setup_agent(agent)\n+        agent.compression_enabled = True  # this test verifies overflow→compression fires\n         agent.provider = \"minimax\"\n         agent.model = \"MiniMax-M2.7-highspeed\"\n         agent.base_url = \"https://api.minimax.io/anthropic\"","@@ -3982,6 +3984,7 @@ def test_non_minimax_overflow_without_provider_limit_keeps_context(self, agent):\n         rely on compression — see #33669 / PR #33826.\n         \"\"\"\n         self._setup_agent(agent)\n+        agent.compression_enabled = True  # this test verifies overflow→compression fires\n         agent.provider = \"openrouter\"\n         agent.model = \"some/unknown-model\"\n         agent.base_url = \"https://openrouter.ai/api/v1\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}