# NousResearch/hermes-agent · Gateway busy-mode interrupt handling fixes and regressions

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 5 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #40590. Verify it addresses your case.
- **Recommended:** read PR #40590 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-40590`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.34`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #38390: Regression: busy_input_mode=interrupt broken in v2026.5.29 (worked in v2026.5.16)
- #44094: Bug: \`busy_text_mode\` defaults to "queue", silently disabling interrupt for text messages

## Prior & competing PRs

- **#40590: fix(gateway): plain text while busy interrupts by default again**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `agent/conversation_loop.py`

    ```diff
    @@ -2720,6 +2720,61 @@ def _perform_api_call(next_api_kwargs):
                     # compress history and retry, not abort immediately.
                     status_code = getattr(api_error, "status_code", None)
     
    +                # ── Respect disabled auto-compaction on overflow ──────
    +                # Ported from anomalyco/opencode#30749.  When the user has
    +                # turned auto-compaction off (``compression.enabled: false``),
    +                # NO automatic compaction trigger may fire — including the
    +                # provider/request-size overflow recovery paths below
    +                # (long-context-tier 429, 413 payload-too-large, and
    +                # context-overflow).  Without this guard the proactive
    +                # threshold path correctly honours the setting (see the
    +                # preflight check and the post-response ``should_compress``
    +                # gate) but a provider overflow error would still silently
    +                # compress + rotate the session, bypassing the user's
    +                # explicit choice.  Surface a terminal error instead so the
    +                # user can compact manually (``/compress``), start fresh
    +                # (``/new``), switch to a larger-context model, or reduce
    +                # attachments.  Forced compaction via ``/compress``
    +                # (``force=True``) is unaffected — it never reaches this loop.
    +                _overflow_reasons = {
    +                    FailoverReason.long_context_tier,
    +                    FailoverReason.payload_too_large,
    +                    FailoverReason.context_overflow,
    +                }
    +                if (
    +                    classified.reason in _overflow_reasons
    +                    and not getattr(agent, "compression_enabled", True)
    +                ):
    +                    agent._flush_status_buffer()
    +                    agent._vprint(
    +                        f"{agent.log_prefix}❌ Context overflow, but auto-compaction is disabled "
    +                        f"(compression.enabled: false).",
    +                        force=True,
    +                    )
    +                    agent._vprint(
    +                        f"{agent.log_prefix}   💡 Run /compress to compact manually, /new to start fresh, "
    +                        f"switch to a larger-context model, or reduce attachments.",
    +                        force=True,
    +                    )
    +                    logger.error(
    +                        f"{agent.log_prefix}Context overflow ({classified.reason.value}) with "
    +                        f"auto-compaction disabled — not compressing."
    +                    )
    +                    agent._persist_session(messages, conversation_history)
    +                    return {
    +                        "messages": messages,
    +                        "completed": False,
    +                        "api_calls": api_call_count,
    +                        "error": (
    +                            "Context overflow and auto-compaction is disabled "
    +                            "(compression.enabled: false). Run /compress to compact manually, "
    +                            "/new to start fresh, or switch to a larger-context model."
    +                        ),
    +                        "partial": True,
    +                        "failed": True,
    +                        "compaction_disabled": True,
    +                    }
    +
                     # ── Anthropic Sonnet long-context tier gate ───────────
                     # Anthropic returns HTTP 429 "Extra usage is required for
                     # long context requests" when a Claude Max (or similar)
    ```
  - `gateway/platforms/base.py`

    ```diff
    @@ -1817,9 +1817,14 @@ def __init__(self, config: PlatformConfig, platform: Platform):
             self._active_sessions: Dict[str, asyncio.Event] = {}
             self._pending_messages: Dict[str, MessageEvent] = {}
             self._session_tasks: Dict[str, asyncio.Task] = {}
    +        # Legacy busy_text_mode env var; when unset the runner syncs the
    +        # resolved value (driven by busy_input_mode) onto the adapter after
    +        # construction (gateway/run.py). Default to "interrupt" so a stray
    +        # pre-sync read matches the single-knob default rather than silently
    +        # queueing.
             self._busy_text_mode: str = (
    -            os.environ.get("HERMES_GATEWAY_BUSY_TEXT_MODE", "queue").strip().lower()
    -            or "queue"
    +            os.environ.get("HERMES_GATEWAY_BUSY_TEXT_MODE", "interrupt").strip().lower()
    +            or "interrupt"
             )
             self._busy_text_debounce_seconds: float = _float_env(
                 "HERMES_GATEWAY_BUSY_TEXT_DEBOUNCE_SECONDS", 0.35
    ```

    ```diff
    @@ -3395,7 +3400,7 @@ def _text_debounce_store(self) -> dict[str, TextDebounceState]:
         def _is_queue_text_debounce_candidate(self, event: MessageEvent) -> bool:
             """Return True for normal text eligible for queue-mode debounce."""
             result = (
    -            getattr(self, "_busy_text_mode", "queue") == "queue"
    +            getattr(self, "_busy_text_mode", "interrupt") == "queue"
                 and event.message_type == MessageType.TEXT
                 and not getattr(event, "internal", False)
                 and not event.is_command()
    ```
  - `gateway/run.py`

    ```diff
    @@ -3195,14 +3195,27 @@ def _load_busy_input_mode() -> str:
     
         @staticmethod
         def _load_busy_text_mode() -> str:
    -        """Load normal busy TEXT follow-up behavior from config/env."""
    -        mode = os.getenv("HERMES_GATEWAY_BUSY_TEXT_MODE", "").strip().lower()
    -        if not mode:
    +        """Resolve normal busy TEXT follow-up behavior.
    +
    +        ``busy_input_mode`` is the single source of truth (default
    +        ``interrupt``). The legacy ``busy_text_mode`` knob is honored only
    +        when a user explicitly set it, so existing queue setups keep
    +        working; new installs follow ``busy_input_mode``. Returns one of
    +        ``interrupt`` | ``queue`` (``steer`` is handled upstream by
    +        ``busy_input_mode`` and maps to non-queue text handling here).
    +        """
    +        # Legacy explicit override wins for backward compat.
    +        legacy = os.getenv("HERMES_GATEWAY_BUSY_TEXT_MODE", "").strip().lower()
    +        if not legacy:
                 cfg = _load_gateway_runtime_config()
    -            mode = str(cfg_get(cfg, "display", "busy_text_mode", default="") or "").strip().lower()
    -        if mode == "interrupt":
    +            legacy = str(cfg_get(cfg, "display", "busy_text_mode", default="") or "").strip().lower()
    +        if legacy == "interrupt":
                 return "interrupt"
    -        return "queue"
    +        if legacy == "queue":
    +            return "queue"
    +        # No explicit legacy knob → follow busy_input_mode.
    +        input_mode = GatewayRunner._load_busy_input_mode()
    +        return "queue" if input_mode == "queue" else "interrupt"
     
         @staticmethod
         def _load_restart_drain_timeout() -> float:
    ```

    ```diff
    @@ -3390,7 +3403,7 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session
             running_agent = self._running_agents.get(session_key)
     
             effective_mode = self._busy_input_mode
    -        busy_text_mode = getattr(self, "_busy_text_mode", "queue")
    +        busy_text_mode = getattr(self, "_busy_text_mode", "interrupt")
             if (
                 event.message_type == MessageType.TEXT
                 and busy_text_mode == "queue"
    ```
  - `tests/gateway/test_active_session_text_merge.py`

    ```diff
    @@ -352,14 +352,15 @@ async def test_single_followup_is_stored_as_is():
         assert not adapter._active_sessions[session_key].is_set()
     
     
    -def test_adapter_defaults_to_queue_mode(monkeypatch):
    +def test_adapter_defaults_to_interrupt_mode(monkeypatch):
         monkeypatch.delenv("HERMES_GATEWAY_BUSY_TEXT_MODE", raising=False)
         adapter = _make_initialized_adapter()
    -    assert adapter._busy_text_mode == "queue"
    -    assert adapter._is_queue_text_debounce_candidate(_make_event("hello"))
    +    assert adapter._busy_text_mode == "interrupt"
    +    assert not adapter._is_queue_text_debounce_candidate(_make_event("hello"))
     
     
    -def test_adapter_is_queue_text_debounce_candidate_by_default():
    +def test_adapter_is_queue_text_debounce_candidate_when_queue_set():
    +    # _make_adapter() pins _busy_text_mode="queue" to exercise debounce.
         adapter = _make_adapter()
         assert adapter._is_queue_text_debounce_candidate(_make_event("hello world"))
     
    ```
  - `tests/gateway/test_restart_drain.py`

    ```diff
    @@ -116,22 +116,37 @@ def test_load_busy_input_mode_prefers_env_then_config_then_default(tmp_path, mon
         assert gateway_run.GatewayRunner._load_busy_input_mode() == "interrupt"
     
     
    -def test_load_busy_text_mode_defaults_to_queue_and_allows_interrupt(tmp_path, monkeypatch):
    +def test_load_busy_text_mode_follows_input_mode_and_honors_legacy(tmp_path, monkeypatch):
         monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
         monkeypatch.delenv("HERMES_GATEWAY_BUSY_TEXT_MODE", raising=False)
    +    monkeypatch.delenv("HERMES_GATEWAY_BUSY_INPUT_MODE", raising=False)
    +
    +    # No knobs set → follows busy_input_mode, which defaults to interrupt.
    +    assert gateway_run.GatewayRunner._load_busy_text_mode() == "interrupt"
     
    +    # busy_input_mode=queue propagates to text handling (single source of truth).
    +    (tmp_path / "config.yaml").write_text(
    +        "display:\n  busy_input_mode: queue\n", encoding="utf-8"
    +    )
         assert gateway_run.GatewayRunner._load_busy_text_mode() == "queue"
     
    +    # Legacy explicit busy_text_mode still wins for backward compat.
         (tmp_path / "config.yaml").write_text(
    -        "display:\n  busy_text_mode: interrupt\n", encoding="utf-8"
    +        "display:\n  busy_input_mode: interrupt\n  busy_text_mode: queue\n",
    +        encoding="utf-8",
         )
    -    assert gateway_run.GatewayRunner._load_busy_text_mode() == "interrupt"
    +    assert gateway_run.GatewayRunner._load_busy_text_mode() == "queue"
     
    +    # Legacy env override wins too.
    +    (tmp_path / "config.yaml").write_text(
    +        "display:\n  busy_input_mode: interrupt\n", encoding="utf-8"
    +    )
         monkeypatch.setenv("HERMES_GATEWAY_BUSY_TEXT_MODE", "queue")
         assert gateway_run.GatewayRunner._load_busy_text_mode() == "queue"
     
    +    # Bogus legacy value is ignored → falls through to busy_input_mode (interrupt).
         monkeypatch.setenv("HERMES_GATEWAY_BUSY_TEXT_MODE", "bogus")
    -    assert gateway_run.GatewayRunner._load_busy_text_mode() == "queue"
    +    assert gateway_run.GatewayRunner._load_busy_text_mode() == "interrupt"
     
     
     def test_load_restart_drain_timeout_prefers_env_then_config_then_default(
    ```
  - `tests/run_agent/test_413_compression.py`

    ```diff
    @@ -94,7 +94,11 @@ def agent():
             a._cached_system_prompt = "You are helpful."
             a._use_prompt_caching = False
             a.tool_delay = 0
    -        a.compression_enabled = False
    +        # Default matches production (`compression.enabled` defaults to True).
    +        # Overflow-recovery tests below verify that 413 / context-overflow
    +        # errors DO trigger compression; the disabled-path behavior is
    +        # covered explicitly by TestOverflowWithCompactionDisabled.
    +        a.compression_enabled = True
             a.save_trajectories = False
             return a
     
    ```

    ```diff
    @@ -415,6 +419,13 @@ class TestPreflightCompression:
     
         def test_compress_context_emits_lifecycle_status_before_work(self, agent):
             """Direct context compression should tell gateway users why the turn paused."""
    +        # This test calls _compress_context directly and asserts the FIRST
    +        # status event is the lifecycle "Compacting context" message. With
    +        # compaction enabled the lazy feasibility probe would emit an
    +        # aux-provider warning first (no aux key in the hermetic test env),
    +        # displacing events[0]. The flag value is irrelevant to what this
    +        # test asserts, so disable it to suppress the probe.
    +        agent.compression_enabled = False
             events = []
             agent.status_callback = lambda ev, msg: events.append((ev, msg))
     
    ```

    ```diff
    @@ -802,3 +813,95 @@ def test_anthropic_prompt_too_long_safety_net(self, agent):
     
             mock_compress.assert_called_once()
             assert result["completed"] is True
    +
    +
    +# ---------------------------------------------------------------------------
    +# Disabled auto-compaction on overflow (port of anomalyco/opencode#30749)
    +# ---------------------------------------------------------------------------
    +
    +class TestOverflowWithCompactionDisabled:
    +    """When ``compression.enabled`` is False, NO automatic compaction may
    +    fire — including the provider/request-size overflow recovery paths.
    +
    +    Ported from anomalyco/opencode#30749: the proactive token-threshold
    +    path already honoured the setting, but provider overflow errors
    +    (413 payload-too-large, context-overflow, long-context-tier 429) still
    +    silently compressed + rotated the session. The fix surfaces a terminal
    +    error so the user can compact manually, start fresh, or switch models.
    +    """
    +
    +    @staticmethod
    +    def _prefill():
    +        return [
    +            {"role": "user", "content": "previous question"},
    +            {"role": "assistant", "content": "previous answer"},
    +        ]
    +
    +    def test_413_does_not_compress_when_disabled(self, agent):
    +        """413 must NOT call _compress_context when compaction is disabled."""
    +        agent.compression_enabled = False
    +        err_413 = _make_413_error()
    +        # If the guard fails, a second (success) response would be consumed.
    +        agent.client.chat.completions.create.side_effect = [err_413, _mock_response()]
    +
    +        with (
    +            patch.object(agent, "_compress_context") as mock_compress,
    +            patch.object(agent, "_persist_session") as mock_persist,
    +            patch.object(agent, "_save_trajectory"),
    +            patch.object(agent, "_cleanup_task_resources"),
    +        ):
    +            result = agent.run_conversation("hello", conversation_history=self._prefill())
    +
    +        mock_compress.assert_not_called()
    +        mock_persist.assert_called()
    +        assert result.get("failed") is True
    +        assert result.get("compaction_disabled") is True
    +        assert "auto-compaction is disabled" in result["error"]
    +
    +    def test_context_overflow_does_not_compress_when_disabled(self, agent):
    +        """400 'prompt is too long' must NOT compress when compaction disabled."""
    +        agent.compression_enabled = False
    +        err_400 = Exception(
    +            "Error code: 400 - {'type': 'error', 'error': {'type': "
    +            "'invalid_request_error', 'message': 'prompt is too long: "
    +            "233153 tokens > 200000 maximum'}}"
    +        )
    +        err_400.status_code = 400
    +        agent.client.chat.completions.create.side_effect = [err_400, _mock_response()]
    +
    +        with (
    +            patch.object(agent, "_compress_context") as mock_compress,
    +            patch.object(agent, "_persist_session"),
    +            patch.object(agent, "_save_trajectory"),
    +            patch.object(agent, "_cleanup_task_resources"),
    +        ):
    +            result = agent.run_conversation("hello", conversation_history=self._prefill())
    +
    +        mock_compress.assert_not_called()
    +        assert result.get("compaction_disabled") is True
    +
    +    def test_413_still_compresses_when_enabled(self, agent):
    +        """Control: with compaction enabled, 413 still triggers compression.
    +
    +        Guards against the disabled-path guard accidentally swallowing the
    +        enabled path.
    +        """
    +        agent.compression_enabled = True
    +        err_413 = _make_413_error()
    +        ok_resp = _mock_response(content="Recovered", finish_reason="stop")
    +        agent.client.chat.completions.create.side_effect = [err_413, ok_resp]
    +
    +        with (
    +            patch.object(agent, "_compress_context") as mock_compress,
    +            patch.object(agent, "_persist_session"),
    +            patch.object(agent, "_save_trajectory"),
    +            patch.object(agent, "_cleanup_task_resources"),
    +        ):
    +            mock_compress.return_value = (
    +                [{"role": "user", "content": "hello"}], "compressed",
    +            )
    +            result = agent.run_conversation("hello", conversation_history=self._prefill())
    +
    +        mock_compress.assert_called_once()
    +        assert result["completed"] is True
    +        assert result.get("compaction_disabled") is not True
    ```
  - `tests/run_agent/test_run_agent.py`

    ```diff
    @@ -3903,6 +3903,7 @@ def test_context_compression_triggered(self, agent):
         def test_glm_prompt_exceeds_max_length_triggers_compression(self, agent):
             """GLM/Z.AI uses 'Prompt exceeds max length' for context overflow."""
             self._setup_agent(agent)
    +        agent.compression_enabled = True  # this test verifies overflow→compression fires
             err_400 = Exception(
                 "Error code: 400 - {'error': {'code': '1261', 'message': 'Prompt exceeds max length'}}"
             )
    ```

    ```diff
    @@ -3937,6 +3938,7 @@ def test_minimax_delta_overflow_keeps_known_context_length(self, agent):
             to the generic 128K fallback tier.
             """
             self._setup_agent(agent)
    +        agent.compression_enabled = True  # this test verifies overflow→compression fires
             agent.provider = "minimax"
             agent.model = "MiniMax-M2.7-highspeed"
             agent.base_url = "https://api.minimax.io/anthropic"
    ```

    ```diff
    @@ -3982,6 +3984,7 @@ def test_non_minimax_overflow_without_provider_limit_keeps_context(self, agent):
             rely on compression — see #33669 / PR #33826.
             """
             self._setup_agent(agent)
    +        agent.compression_enabled = True  # this test verifies overflow→compression fires
             agent.provider = "openrouter"
             agent.model = "some/unknown-model"
             agent.base_url = "https://openrouter.ai/api/v1"
    ```
- **#38399: fix(gateway): inherit busy_text_mode from busy_input_mode when not configured**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `54e7b74f7f476b7d0de31e5f3a97376647e68e64`, `#40590`, `#38390`
- **#38485: fix(gateway): inherit busy_text_mode from busy_input_mode when not configured**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `54e7b74f7f476b7d0de31e5f3a97376647e68e64`, `#40590`, `#38390`

## Fix status

- Merged to main in PR #40590 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-06`.
- Subsystem: `gateway`
