# NousResearch/hermes-agent · Gateway busy-session input handling: queue vs interrupt modes

## Open now

- 0 issues, 1 PR open on GitHub.
- PR #21080: feat(gateway): per-message busy-session controls — inline buttons + multilingual halt heuristic

These items all concern how the gateway handles messages that arrive while an agent session is busy. They cover respecting the queue setting during normal runs, distinguishing queue and interrupt acknowledgements, and adding per-message controls with a halt heuristic.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 1 open PR(s), 1 merged PR(s), 0 PR(s) closed without merging.
- A fix was merged to main in PR #14762. Verify it addresses your case.
- **Recommended:** read PR #14762 (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: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: 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

- #11118: \[Feature\]: Distinguish queue-mode vs interrupt-mode busy-session acknowledgements in the gateway

## Prior & competing PRs

- **#14762: fix(gateway): respect busy_input_mode=queue during normal agent runs**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `gateway/run.py`, `scripts/release.py`, `tests/gateway/test_busy_session_ack.py`
  - fix hunks:
  - `gateway/run.py`

    ```diff
    @@ -1551,27 +1551,23 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session
                 )
                 return True
     
    -        # --- Normal busy case (agent actively running a task) ---
    -        # The user sent a message while the agent is working.  Interrupt the
    -        # agent immediately so it stops the current tool-calling loop and
    -        # processes the new message.  The pending message is stored in the
    -        # adapter so the base adapter picks it up once the interrupted run
    -        # returns.  A brief ack tells the user what's happening (debounced
    -        # to avoid spam when they fire multiple messages quickly).
    -
    +        # Normal busy case (agent actively running a task)
             adapter = self.adapters.get(event.source.platform)
             if not adapter:
                 return False  # let default path handle it
     
             # Store the message so it's processed as the next turn after the
    -        # interrupt causes the current run to exit.
    +        # current run finishes (or is interrupted).
             from gateway.platforms.base import merge_pending_message_event
             merge_pending_message_event(adapter._pending_messages, session_key, event)
     
    -        # Interrupt the running agent — this aborts in-flight tool calls and
    -        # causes the agent loop to exit at the next check point.
    +        is_queue_mode = self._busy_input_mode == "queue"
    +
    +        # If not in queue mode, interrupt the running agent immediately.
    +        # This aborts in-flight tool calls and causes the agent loop to exit
    +        # at the next check point.
             running_agent = self._running_agents.get(session_key)
    -        if running_agent and running_agent is not _AGENT_PENDING_SENTINEL:
    +        if not is_queue_mode and running_agent and running_agent is not _AGENT_PENDING_SENTINEL:
                 try:
                     running_agent.interrupt(event.text)
                 except Exception:
    ```

    ```diff
    @@ -1583,7 +1579,7 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session
             now = time.time()
             last_ack = self._busy_ack_ts.get(session_key, 0)
             if now - last_ack < _BUSY_ACK_COOLDOWN:
    -            return True  # interrupt sent, ack already delivered recently
    +            return True  # interrupt sent (if not queue), ack already delivered recently
     
             self._busy_ack_ts[session_key] = now
     
    ```

    ```diff
    @@ -1608,10 +1604,16 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session
                     pass
     
             status_detail = f" ({', '.join(status_parts)})" if status_parts else ""
    -        message = (
    -            f"⚡ Interrupting current task{status_detail}. "
    -            f"I'll respond to your message shortly."
    -        )
    +        if is_queue_mode:
    +            message = (
    +                f"⏳ Queued for the next turn{status_detail}. "
    +                f"I'll respond once the current task finishes."
    +            )
    +        else:
    +            message = (
    +                f"⚡ Interrupting current task{status_detail}. "
    +                f"I'll respond to your message shortly."
    +            )
     
             thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None
             try:
    ```
  - `scripts/release.py`

    ```diff
    @@ -165,6 +165,7 @@
         "[redacted:email]": "Salt-555",
         "[redacted:email]": "satelerd",
         "[redacted:email]": "nummanali",
    +    "[redacted:email]": "whitehatjr1001",
         "0xNyk@users.noreply.github.com": "0xNyk",
         "[redacted:email]": "0xNyk",
         "[redacted:email]": "buray",
    ```
  - `tests/gateway/test_busy_session_ack.py`

    ```diff
    @@ -95,6 +95,7 @@ class TestBusySessionAck:
         async def test_sends_ack_when_agent_running(self):
             """First message during busy session should get a status ack."""
             runner, sentinel = _make_runner()
    +        runner._busy_input_mode = "interrupt"
             adapter = _make_adapter()
     
             event = _make_event(text="Are you working?")
    ```

    ```diff
    @@ -127,16 +128,42 @@ async def test_sends_ack_when_agent_running(self):
             assert "Interrupting" in content or "respond" in content
             assert "/stop" not in content  # no need — we ARE interrupting
     
    -        # Verify message was queued in adapter pending
    -        assert sk in adapter._pending_messages
    -
             # Verify agent interrupt was called
             agent.interrupt.assert_called_once_with("Are you working?")
     
    +    @pytest.mark.asyncio
    +    async def test_queue_mode_suppresses_interrupt_and_updates_ack(self):
    +        """When busy_input_mode is 'queue', message is queued WITHOUT interrupt."""
    +        runner, sentinel = _make_runner()
    +        runner._busy_input_mode = "queue"
    +        adapter = _make_adapter()
    +
    +        event = _make_event(text="Add this to queue")
    +        sk = build_session_key(event.source)
    +        runner.adapters[event.source.platform] = adapter
    +
    +        agent = MagicMock()
    +        runner._running_agents[sk] = agent
    +
    +        with patch("gateway.run.merge_pending_message_event"):
    +            await runner._handle_active_session_busy_message(event, sk)
    +
    +        # VERIFY: Agent was NOT interrupted
    +        agent.interrupt.assert_not_called()
    +
    +        # VERIFY: Ack sent with queue-specific wording
    +        adapter._send_with_retry.assert_called_once()
    +        call_kwargs = adapter._send_with_retry.call_args
    +        content = call_kwargs.kwargs.get("content") or call_kwargs[1].get("content", "")
    +        assert "Queued for the next turn" in content
    +        assert "respond once the current task finishes" in content
    +        assert "Interrupting" not in content
    +
         @pytest.mark.asyncio
         async def test_debounce_suppresses_rapid_acks(self):
             """Second message within 30s should NOT send another ack."""
             runner, sentinel = _make_runner()
    +        runner._busy_input_mode = "interrupt"
             adapter = _make_adapter()
     
             event1 = _make_event(text="hello?")
    ```

    ```diff
    @@ -172,13 +199,14 @@ async def test_debounce_suppresses_rapid_acks(self):
             assert result2 is True
             assert adapter._send_with_retry.call_count == 1  # still 1, no new ack
     
    -        # But interrupt should still be called for both
    +        # But interrupt should still be called for both (since we are in interrupt mode)
             assert agent.interrupt.call_count == 2
     
         @pytest.mark.asyncio
         async def test_ack_after_cooldown_expires(self):
             """After 30s cooldown, a new message should send a fresh ack."""
             runner, sentinel = _make_runner()
    +        runner._busy_input_mode = "interrupt"
             adapter = _make_adapter()
     
             event = _make_event(text="hello?")
    ```

    ```diff
    @@ -212,6 +240,7 @@ async def test_ack_after_cooldown_expires(self):
         async def test_includes_status_detail(self):
             """Ack message should include iteration and tool info when available."""
             runner, sentinel = _make_runner()
    +        runner._busy_input_mode = "interrupt"
             adapter = _make_adapter()
     
             event = _make_event(text="yo")
    ```

    ```diff
    @@ -243,6 +272,7 @@ async def test_draining_still_works(self):
             """Draining case should still produce the drain-specific message."""
             runner, sentinel = _make_runner()
             runner._draining = True
    +        runner._busy_input_mode = "interrupt"
             adapter = _make_adapter()
     
             event = _make_event(text="hello")
    ```

    ```diff
    @@ -264,6 +294,7 @@ async def test_draining_still_works(self):
         async def test_pending_sentinel_no_interrupt(self):
             """When agent is PENDING_SENTINEL, don't call interrupt (it has no method)."""
             runner, sentinel = _make_runner()
    +        runner._busy_input_mode = "interrupt"
             adapter = _make_adapter()
     
             event = _make_event(text="hey")
    ```
- **#21080: feat(gateway): per-message busy-session controls — inline buttons + multilingual halt heuristic**. open
  - maintainer direction: category `merged_via`; implication `superseded`
  - files: `cli-config.yaml.example`, `cli.py`, `gateway/busy_session_buttons.py`, `gateway/platforms/base.py`, `gateway/platforms/slack.py`, `gateway/platforms/telegram.py`, `gateway/run.py`, `gateway/stop_phrases.py`, `hermes_cli/config.py`, `plugins/platforms/discord/adapter.py`, `plugins/platforms/simplex/adapter.py`, `tests/cli/test_cli_init.py`, `tests/gateway/test_busy_session_buttons.py`, `tests/gateway/test_busy_session_runner.py`, `tests/gateway/test_restart_drain.py`, `tests/gateway/test_running_agent_session_toggles.py`, `tests/gateway/test_stop_phrase_matcher.py`, `tests/gateway/test_subagent_protection_30170.py`, `tests/gateway/test_telegram_busy_controls.py`, `tests/hermes_cli/test_update_hangup_protection.py` (+1 more)

## Fix status

- Merged to main in PR #14762 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-23`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `gateway`
