Gateway busy-mode interrupt handling fixes and regressions
NousResearch/hermes-agent · cluster 38390 · gateway
Data as of
Nothing open on GitHub. Fix merged in #40590. Check it covers your case.
The short answer
- Known?
- Yes. 5 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #40590. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #40590. That is separate from the fix event. - Still open
- 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.
Open issues0
Open PRs0
Fixes on record
-
#40590 fix(gateway): plain text while busy interrupts by default again
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.pyShow the diff
-
agent/conversation_loop.py@@ -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@@ -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 @@ -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@@ -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: @@ -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@@ -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@@ -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@@ -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 @@ -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)) @@ -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@@ -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'}}" ) @@ -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" @@ -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"
-
2 PRs closed without merging
From the close comments: done elsewhere 1 · superseded by another PR 1
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): 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 item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #40590
- Closed issues
- 2
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 6 Jun 2026