{"repo":"NousResearch/hermes-agent","public_id":"4772","display_label":"Gateway message queuing instead of interrupting active tasks","summary":"These issues and PRs concern a feature/bug around a 'busy_input_mode=queue' option for gateways (WhatsApp, Telegram, and others), where incoming messages should be queued rather than interrupting a currently running agent task. Members include bug reports, feature requests, and fix/feat PRs addressing this behavior.","subsystem":"gateway","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":5,"merged_prs":1,"attempt_prs":0},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":14762},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-23T22:12:43+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2346666666666666},"fix_edges":[{"issue_number":11118,"pr_number":14762,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":13403,"pr_number":14762,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":4772,"title":"Feature request: Message queue for gateway (don't interrupt running agent)","url":null,"state":"closed"},{"kind":"issue","number":11633,"title":"[Feature] Option to queue incoming messages instead of interrupting running tasks","url":null,"state":"closed"},{"kind":"issue","number":14905,"title":"[Bug] busy_input_mode: queue only works during gateway drain, not normal task execution","url":null,"state":"closed"},{"kind":"issue","number":17393,"title":"WhatsApp gateway should queue messages instead of interrupting active tasks","url":null,"state":"closed"},{"kind":"issue","number":58780,"title":"Telegram: out-of-band messages (text and voice) interrupt current task instead of being queued","url":null,"state":"closed"},{"kind":"pr","number":14762,"title":"fix(gateway): respect busy_input_mode=queue during normal agent runs","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","scripts/release.py","tests/gateway/test_busy_session_ack.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -1551,27 +1551,23 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session\n             )\n             return True\n \n-        # --- Normal busy case (agent actively running a task) ---\n-        # The user sent a message while the agent is working.  Interrupt the\n-        # agent immediately so it stops the current tool-calling loop and\n-        # processes the new message.  The pending message is stored in the\n-        # adapter so the base adapter picks it up once the interrupted run\n-        # returns.  A brief ack tells the user what's happening (debounced\n-        # to avoid spam when they fire multiple messages quickly).\n-\n+        # Normal busy case (agent actively running a task)\n         adapter = self.adapters.get(event.source.platform)\n         if not adapter:\n             return False  # let default path handle it\n \n         # Store the message so it's processed as the next turn after the\n-        # interrupt causes the current run to exit.\n+        # current run finishes (or is interrupted).\n         from gateway.platforms.base import merge_pending_message_event\n         merge_pending_message_event(adapter._pending_messages, session_key, event)\n \n-        # Interrupt the running agent — this aborts in-flight tool calls and\n-        # causes the agent loop to exit at the next check point.\n+        is_queue_mode = self._busy_input_mode == \"queue\"\n+\n+        # If not in queue mode, interrupt the running agent immediately.\n+        # This aborts in-flight tool calls and causes the agent loop to exit\n+        # at the next check point.\n         running_agent = self._running_agents.get(session_key)\n-        if running_agent and running_agent is not _AGENT_PENDING_SENTINEL:\n+        if not is_queue_mode and running_agent and running_agent is not _AGENT_PENDING_SENTINEL:\n             try:\n                 running_agent.interrupt(event.text)\n             except Exception:","@@ -1583,7 +1579,7 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session\n         now = time.time()\n         last_ack = self._busy_ack_ts.get(session_key, 0)\n         if now - last_ack < _BUSY_ACK_COOLDOWN:\n-            return True  # interrupt sent, ack already delivered recently\n+            return True  # interrupt sent (if not queue), ack already delivered recently\n \n         self._busy_ack_ts[session_key] = now\n ","@@ -1608,10 +1604,16 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session\n                 pass\n \n         status_detail = f\" ({', '.join(status_parts)})\" if status_parts else \"\"\n-        message = (\n-            f\"⚡ Interrupting current task{status_detail}. \"\n-            f\"I'll respond to your message shortly.\"\n-        )\n+        if is_queue_mode:\n+            message = (\n+                f\"⏳ Queued for the next turn{status_detail}. \"\n+                f\"I'll respond once the current task finishes.\"\n+            )\n+        else:\n+            message = (\n+                f\"⚡ Interrupting current task{status_detail}. \"\n+                f\"I'll respond to your message shortly.\"\n+            )\n \n         thread_meta = {\"thread_id\": event.source.thread_id} if event.source.thread_id else None\n         try:"]},{"file":"scripts/release.py","hunks":["@@ -165,6 +165,7 @@\n     \"[redacted:email]\": \"Salt-555\",\n     \"[redacted:email]\": \"satelerd\",\n     \"[redacted:email]\": \"nummanali\",\n+    \"[redacted:email]\": \"whitehatjr1001\",\n     \"[redacted:email]\": \"0xNyk\",\n     \"[redacted:email]\": \"0xNyk\",\n     \"[redacted:email]\": \"buray\","]},{"file":"tests/gateway/test_busy_session_ack.py","hunks":["@@ -95,6 +95,7 @@ class TestBusySessionAck:\n     async def test_sends_ack_when_agent_running(self):\n         \"\"\"First message during busy session should get a status ack.\"\"\"\n         runner, sentinel = _make_runner()\n+        runner._busy_input_mode = \"interrupt\"\n         adapter = _make_adapter()\n \n         event = _make_event(text=\"Are you working?\")","@@ -127,16 +128,42 @@ async def test_sends_ack_when_agent_running(self):\n         assert \"Interrupting\" in content or \"respond\" in content\n         assert \"/stop\" not in content  # no need — we ARE interrupting\n \n-        # Verify message was queued in adapter pending\n-        assert sk in adapter._pending_messages\n-\n         # Verify agent interrupt was called\n         agent.interrupt.assert_called_once_with(\"Are you working?\")\n \n+    @pytest.mark.asyncio\n+    async def test_queue_mode_suppresses_interrupt_and_updates_ack(self):\n+        \"\"\"When busy_input_mode is 'queue', message is queued WITHOUT interrupt.\"\"\"\n+        runner, sentinel = _make_runner()\n+        runner._busy_input_mode = \"queue\"\n+        adapter = _make_adapter()\n+\n+        event = _make_event(text=\"Add this to queue\")\n+        sk = build_session_key(event.source)\n+        runner.adapters[event.source.platform] = adapter\n+\n+        agent = MagicMock()\n+        runner._running_agents[sk] = agent\n+\n+        with patch(\"gateway.run.merge_pending_message_event\"):\n+            await runner._handle_active_session_busy_message(event, sk)\n+\n+        # VERIFY: Agent was NOT interrupted\n+        agent.interrupt.assert_not_called()\n+\n+        # VERIFY: Ack sent with queue-specific wording\n+        adapter._send_with_retry.assert_called_once()\n+        call_kwargs = adapter._send_with_retry.call_args\n+        content = call_kwargs.kwargs.get(\"content\") or call_kwargs[1].get(\"content\", \"\")\n+        assert \"Queued for the next turn\" in content\n+        assert \"respond once the current task finishes\" in content\n+        assert \"Interrupting\" not in content\n+\n     @pytest.mark.asyncio\n     async def test_debounce_suppresses_rapid_acks(self):\n         \"\"\"Second message within 30s should NOT send another ack.\"\"\"\n         runner, sentinel = _make_runner()\n+        runner._busy_input_mode = \"interrupt\"\n         adapter = _make_adapter()\n \n         event1 = _make_event(text=\"hello?\")","@@ -172,13 +199,14 @@ async def test_debounce_suppresses_rapid_acks(self):\n         assert result2 is True\n         assert adapter._send_with_retry.call_count == 1  # still 1, no new ack\n \n-        # But interrupt should still be called for both\n+        # But interrupt should still be called for both (since we are in interrupt mode)\n         assert agent.interrupt.call_count == 2\n \n     @pytest.mark.asyncio\n     async def test_ack_after_cooldown_expires(self):\n         \"\"\"After 30s cooldown, a new message should send a fresh ack.\"\"\"\n         runner, sentinel = _make_runner()\n+        runner._busy_input_mode = \"interrupt\"\n         adapter = _make_adapter()\n \n         event = _make_event(text=\"hello?\")","@@ -212,6 +240,7 @@ async def test_ack_after_cooldown_expires(self):\n     async def test_includes_status_detail(self):\n         \"\"\"Ack message should include iteration and tool info when available.\"\"\"\n         runner, sentinel = _make_runner()\n+        runner._busy_input_mode = \"interrupt\"\n         adapter = _make_adapter()\n \n         event = _make_event(text=\"yo\")","@@ -243,6 +272,7 @@ async def test_draining_still_works(self):\n         \"\"\"Draining case should still produce the drain-specific message.\"\"\"\n         runner, sentinel = _make_runner()\n         runner._draining = True\n+        runner._busy_input_mode = \"interrupt\"\n         adapter = _make_adapter()\n \n         event = _make_event(text=\"hello\")","@@ -264,6 +294,7 @@ async def test_draining_still_works(self):\n     async def test_pending_sentinel_no_interrupt(self):\n         \"\"\"When agent is PENDING_SENTINEL, don't call interrupt (it has no method).\"\"\"\n         runner, sentinel = _make_runner()\n+        runner._busy_input_mode = \"interrupt\"\n         adapter = _make_adapter()\n \n         event = _make_event(text=\"hey\")\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}