From 635253b9185f1d65dae7df17421daf0dfbc0f576 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:21:29 -0700 Subject: [PATCH] feat(busy): add 'steer' as a third display.busy_input_mode option (#16279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enter while the agent is busy can now inject the typed text via /steer — arriving at the agent after the next tool call — instead of interrupting (current default) or queueing for the next turn. Changes: - cli.py: keybinding honors busy_input_mode='steer' by calling agent.steer(text) on the UI thread (thread-safe), with automatic fallback to 'queue' when the agent is missing, steer() is unavailable, images are attached, or steer() rejects the payload. /busy accepts 'steer' as a fourth argument alongside queue/interrupt/status. - gateway/run.py: busy-message handler and the PRIORITY running-agent path both route through running_agent.steer() when the mode is 'steer', with the same fallback-to-queue safety net. Ack wording tells users their message was steered into the current run. Restart-drain queueing now also activates for 'steer' so messages aren't lost across restarts. - agent/onboarding.py: first-touch hint has a steer branch for both CLI and gateway. - hermes_cli/commands.py: /busy args_hint updated to include steer, and 'steer' is registered as a subcommand (completions). - hermes_cli/web_server.py: dashboard select widget offers steer. - hermes_cli/config.py, cli-config.yaml.example, hermes_cli/tips.py: inline docs updated. - website/docs/user-guide/cli.md + messaging/index.md: documented. - Tests: steer set/status path for /busy; onboarding hints; _load_busy_input_mode accepts steer; busy-session ack exercises steer success + two fallback-to-queue branches. Requested on X by @CodingAcct. Default is unchanged (interrupt). --- agent/onboarding.py | 24 ++++-- cli-config.yaml.example | 6 +- cli.py | 61 ++++++++++++--- gateway/run.py | 86 +++++++++++++++++++--- hermes_cli/commands.py | 4 +- hermes_cli/config.py | 2 +- hermes_cli/tips.py | 2 +- hermes_cli/web_server.py | 2 +- tests/agent/test_onboarding.py | 14 ++++ tests/cli/test_busy_input_mode_command.py | 31 +++++++- tests/gateway/test_busy_session_ack.py | 85 +++++++++++++++++++++ tests/gateway/test_restart_drain.py | 12 +++ website/docs/user-guide/cli.md | 8 +- website/docs/user-guide/messaging/index.md | 9 ++- 14 files changed, 308 insertions(+), 38 deletions(-) diff --git a/agent/onboarding.py b/agent/onboarding.py index eed832ab90..1596f4ff92 100644 --- a/agent/onboarding.py +++ b/agent/onboarding.py @@ -43,10 +43,18 @@ def busy_input_hint_gateway(mode: str) -> str: "Send `/busy interrupt` to make new messages stop the current task " "immediately, or `/busy status` to check. This notice won't appear again." ) + if mode == "steer": + return ( + "💡 First-time tip — I steered your message into the current run; " + "it will arrive after the next tool call instead of interrupting. " + "Send `/busy interrupt` or `/busy queue` to change this, or " + "`/busy status` to check. This notice won't appear again." + ) return ( "💡 First-time tip — I just interrupted my current task to answer you. " "Send `/busy queue` to queue follow-ups for after the current task instead, " - "or `/busy status` to check. This notice won't appear again." + "`/busy steer` to inject them mid-run without interrupting, or " + "`/busy status` to check. This notice won't appear again." ) @@ -55,13 +63,19 @@ def busy_input_hint_cli(mode: str) -> str: if mode == "queue": return ( "(tip) Your message was queued for the next turn. " - "Use /busy interrupt to make Enter stop the current run instead. " - "This tip only shows once." + "Use /busy interrupt to make Enter stop the current run instead, " + "or /busy steer to inject mid-run. This tip only shows once." + ) + if mode == "steer": + return ( + "(tip) Your message was steered into the current run; it arrives " + "after the next tool call. Use /busy interrupt or /busy queue to " + "change this. This tip only shows once." ) return ( "(tip) Your message interrupted the current run. " - "Use /busy queue to queue messages for the next turn instead. " - "This tip only shows once." + "Use /busy queue to queue messages for the next turn instead, " + "or /busy steer to inject mid-run. This tip only shows once." ) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 56090dca8b..984a9bfe84 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -847,8 +847,12 @@ display: # What Enter does when Hermes is already busy (CLI and gateway platforms). # interrupt: Interrupt the current run and redirect Hermes (default) # queue: Queue your message for the next turn + # steer: Inject your message mid-run via /steer, arriving at the agent + # after the next tool call — no interrupt, no role violation. + # Falls back to 'queue' if the agent isn't running yet or if + # images are attached (steer only carries text). # Ctrl+C (or /stop in gateway) always interrupts regardless of this setting. - # Toggle at runtime with /busy_input_mode . + # Toggle at runtime with /busy . busy_input_mode: interrupt # Background process notifications (gateway/messaging only). diff --git a/cli.py b/cli.py index f8c785a4e4..ae87c15c51 100644 --- a/cli.py +++ b/cli.py @@ -1848,9 +1848,16 @@ class HermesCLI: self.bell_on_complete = CLI_CONFIG["display"].get("bell_on_complete", False) # show_reasoning: display model thinking/reasoning before the response self.show_reasoning = CLI_CONFIG["display"].get("show_reasoning", False) - # busy_input_mode: "interrupt" (Enter interrupts current run) or "queue" (Enter queues for next turn) - _bim = CLI_CONFIG["display"].get("busy_input_mode", "interrupt") - self.busy_input_mode = "queue" if str(_bim).strip().lower() == "queue" else "interrupt" + # busy_input_mode: "interrupt" (Enter interrupts current run), + # "queue" (Enter queues for next turn), or "steer" (Enter injects + # mid-run via /steer, arriving after the next tool call). + _bim = str(CLI_CONFIG["display"].get("busy_input_mode", "interrupt")).strip().lower() + if _bim == "queue": + self.busy_input_mode = "queue" + elif _bim == "steer": + self.busy_input_mode = "steer" + else: + self.busy_input_mode = "interrupt" self.verbose = verbose if verbose is not None else (self.tool_progress_mode == "verbose") @@ -6816,24 +6823,36 @@ class HermesCLI: /busy Show current busy input mode /busy status Show current busy input mode /busy queue Queue input for the next turn instead of interrupting + /busy steer Inject Enter mid-run via /steer (after next tool call) /busy interrupt Interrupt the current run on Enter (default) """ parts = cmd.strip().split(maxsplit=1) if len(parts) < 2 or parts[1].strip().lower() == "status": _cprint(f" {_ACCENT}Busy input mode: {self.busy_input_mode}{_RST}") - _cprint(f" {_DIM}Enter while busy: {'queues for next turn' if self.busy_input_mode == 'queue' else 'interrupts current run'}{_RST}") - _cprint(f" {_DIM}Usage: /busy [queue|interrupt|status]{_RST}") + if self.busy_input_mode == "queue": + _behavior = "queues for next turn" + elif self.busy_input_mode == "steer": + _behavior = "steers into current run (after next tool call)" + else: + _behavior = "interrupts current run" + _cprint(f" {_DIM}Enter while busy: {_behavior}{_RST}") + _cprint(f" {_DIM}Usage: /busy [queue|steer|interrupt|status]{_RST}") return arg = parts[1].strip().lower() - if arg not in {"queue", "interrupt"}: + if arg not in {"queue", "interrupt", "steer"}: _cprint(f" {_DIM}(._.) Unknown argument: {arg}{_RST}") - _cprint(f" {_DIM}Usage: /busy [queue|interrupt|status]{_RST}") + _cprint(f" {_DIM}Usage: /busy [queue|steer|interrupt|status]{_RST}") return self.busy_input_mode = arg if save_config_value("display.busy_input_mode", arg): - behavior = "Enter will queue follow-up input while Hermes is busy." if arg == "queue" else "Enter will interrupt the current run while Hermes is busy." + if arg == "queue": + behavior = "Enter will queue follow-up input while Hermes is busy." + elif arg == "steer": + behavior = "Enter will steer your message into the current run (after the next tool call)." + else: + behavior = "Enter will interrupt the current run while Hermes is busy." _cprint(f" {_ACCENT}✓ Busy input mode set to '{arg}' (saved to config){_RST}") _cprint(f" {_DIM}{behavior}{_RST}") else: @@ -9210,12 +9229,34 @@ class HermesCLI: # Bundle text + images as a tuple when images are present payload = (text, images) if images else text if self._agent_running and not (text and _looks_like_slash_command(text)): - if self.busy_input_mode == "queue": + _effective_mode = self.busy_input_mode + if _effective_mode == "steer": + # Route Enter through /steer — inject mid-run after the + # next tool call. Images can't ride along (steer only + # appends text), so fall back to queue when images are + # attached. If the agent lacks steer() or rejects the + # payload, also fall back to queue so nothing is lost. + if images or not text: + _effective_mode = "queue" + else: + accepted = False + try: + if self.agent is not None and hasattr(self.agent, "steer"): + accepted = bool(self.agent.steer(text)) + except Exception as exc: + _cprint(f" {_DIM}Steer failed ({exc}) — queued for next turn.{_RST}") + accepted = False + if accepted: + preview = text[:80] + ("..." if len(text) > 80 else "") + _cprint(f" {_ACCENT}⏩ Steered: '{preview}'{_RST}") + else: + _effective_mode = "queue" + if _effective_mode == "queue": # Queue for the next turn instead of interrupting self._pending_input.put(payload) preview = text if text else f"[{len(images)} image{'s' if len(images) != 1 else ''} attached]" _cprint(f" Queued for the next turn: {preview[:80]}{'...' if len(preview) > 80 else ''}") - else: + elif _effective_mode == "interrupt": self._interrupt_queue.put(payload) # Debug: log to file when message enters interrupt queue try: diff --git a/gateway/run.py b/gateway/run.py index d84ed65f7a..fcab91b443 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1212,7 +1212,10 @@ class GatewayRunner: return "restarting" if self._restart_requested else "shutting down" def _queue_during_drain_enabled(self) -> bool: - return self._restart_requested and self._busy_input_mode == "queue" + # Both "queue" and "steer" modes imply the user doesn't want messages + # to be lost during restart — queue them for the newly-spawned gateway + # process to pick up. "interrupt" mode drops them (current behaviour). + return self._restart_requested and self._busy_input_mode in ("queue", "steer") # -------- /queue FIFO helpers -------------------------------------- # /queue must produce one full agent turn per invocation, in FIFO @@ -1513,7 +1516,11 @@ class GatewayRunner: mode = str(cfg.get("display", {}).get("busy_input_mode", "") or "").strip().lower() except Exception: pass - return "queue" if mode == "queue" else "interrupt" + if mode == "queue": + return "queue" + if mode == "steer": + return "steer" + return "interrupt" @staticmethod def _load_restart_drain_timeout() -> float: @@ -1651,18 +1658,46 @@ class GatewayRunner: if not adapter: return False # let default path handle it + running_agent = self._running_agents.get(session_key) + + # Steer mode: inject mid-run via running_agent.steer() instead of + # queueing + interrupting. If the agent isn't running yet + # (sentinel) or lacks steer(), or the payload is empty, fall back + # to queue semantics so nothing is lost. + effective_mode = self._busy_input_mode + steered = False + if effective_mode == "steer": + steer_text = (event.text or "").strip() + can_steer = ( + steer_text + and running_agent is not None + and running_agent is not _AGENT_PENDING_SENTINEL + and hasattr(running_agent, "steer") + ) + if can_steer: + try: + steered = bool(running_agent.steer(steer_text)) + except Exception as exc: + logger.warning("Gateway steer failed for session %s: %s", session_key, exc) + steered = False + if not steered: + # Fall back to queue (merge into pending messages, no interrupt) + effective_mode = "queue" + # Store the message so it's processed as the next turn after the - # 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) + # current run finishes (or is interrupted). Skip this for a + # successful steer — the text already landed inside the run and + # must NOT also be replayed as a next-turn user message. + if not steered: + merge_pending_message_event(adapter._pending_messages, session_key, event) - is_queue_mode = self._busy_input_mode == "queue" + is_queue_mode = effective_mode == "queue" + is_steer_mode = effective_mode == "steer" - # If not in queue mode, interrupt the running agent immediately. + # If not in queue/steer 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 not is_queue_mode and running_agent and running_agent is not _AGENT_PENDING_SENTINEL: + if effective_mode == "interrupt" and running_agent and running_agent is not _AGENT_PENDING_SENTINEL: try: running_agent.interrupt(event.text) except Exception: @@ -1699,7 +1734,12 @@ class GatewayRunner: pass status_detail = f" ({', '.join(status_parts)})" if status_parts else "" - if is_queue_mode: + if is_steer_mode: + message = ( + f"⏩ Steered into current run{status_detail}. " + f"Your message arrives after the next tool call." + ) + elif is_queue_mode: message = ( f"⏳ Queued for the next turn{status_detail}. " f"I'll respond once the current task finishes." @@ -1723,9 +1763,15 @@ class GatewayRunner: ) _user_cfg = _load_gateway_config() if not is_seen(_user_cfg, BUSY_INPUT_FLAG): + if is_steer_mode: + _hint_mode = "steer" + elif is_queue_mode: + _hint_mode = "queue" + else: + _hint_mode = "interrupt" message = ( f"{message}\n\n" - f"{busy_input_hint_gateway('queue' if is_queue_mode else 'interrupt')}" + f"{busy_input_hint_gateway(_hint_mode)}" ) mark_seen(_hermes_home / "config.yaml", BUSY_INPUT_FLAG) except Exception as _onb_err: @@ -3711,6 +3757,24 @@ class GatewayRunner: logger.debug("PRIORITY queue follow-up for session %s", _quick_key) self._queue_or_replace_pending_event(_quick_key, event) return None + if self._busy_input_mode == "steer": + # Steer mode: inject text into the running agent mid-run via + # agent.steer(). Falls back to queue semantics if the payload + # is empty, the agent lacks steer(), or steer() rejects. + steer_text = (event.text or "").strip() + steered = False + if steer_text and hasattr(running_agent, "steer"): + try: + steered = bool(running_agent.steer(steer_text)) + except Exception as exc: + logger.warning("PRIORITY steer failed for session %s: %s", _quick_key, exc) + steered = False + if steered: + logger.debug("PRIORITY steer for session %s", _quick_key) + return None + logger.debug("PRIORITY steer-fallback-to-queue for session %s", _quick_key) + self._queue_or_replace_pending_event(_quick_key, event) + return None logger.debug("PRIORITY interrupt for session %s", _quick_key) running_agent.interrupt(event.text) if _quick_key in self._pending_messages: diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index d0eb74d872..103908399d 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -126,8 +126,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("voice", "Toggle voice mode", "Configuration", args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")), CommandDef("busy", "Control what Enter does while Hermes is working", "Configuration", - cli_only=True, args_hint="[queue|interrupt|status]", - subcommands=("queue", "interrupt", "status")), + cli_only=True, args_hint="[queue|steer|interrupt|status]", + subcommands=("queue", "steer", "interrupt", "status")), # Tools & Skills CommandDef("tools", "Manage tools: /tools [list|disable|enable] [name...]", "Tools & Skills", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 542b4d4fa4..b92d7a724d 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -627,7 +627,7 @@ DEFAULT_CONFIG = { "compact": False, "personality": "kawaii", "resume_display": "full", - "busy_input_mode": "interrupt", + "busy_input_mode": "interrupt", # interrupt | queue | steer "bell_on_complete": False, "show_reasoning": False, "streaming": False, diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py index a93a31db13..b22f457134 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -106,7 +106,7 @@ TIPS = [ "Set display.streaming: true to see tokens appear in real time as the model generates.", "Set display.show_reasoning: true to watch the model's chain-of-thought reasoning.", "Set display.compact: true to reduce whitespace in output for denser information.", - "Set display.busy_input_mode: queue to queue messages instead of interrupting the agent.", + "Set display.busy_input_mode: queue to queue messages instead of interrupting the agent, or steer to inject them mid-run via /steer.", "Set display.resume_display: minimal to skip the full conversation recap on session resume.", "Set compression.threshold: 0.50 to control when auto-compression fires (default: 50% of context).", "Set agent.max_turns: 200 to let the agent take more tool-calling steps per turn.", diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8c33a383e5..0159579628 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -287,7 +287,7 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = { "display.busy_input_mode": { "type": "select", "description": "Input behavior while agent is running", - "options": ["interrupt", "queue"], + "options": ["interrupt", "queue", "steer"], }, "memory.provider": { "type": "select", diff --git a/tests/agent/test_onboarding.py b/tests/agent/test_onboarding.py index a14c7d1797..4fe357f37d 100644 --- a/tests/agent/test_onboarding.py +++ b/tests/agent/test_onboarding.py @@ -117,6 +117,12 @@ class TestHintMessages: assert "/busy interrupt" in msg assert "queued" in msg.lower() + def test_busy_input_hint_gateway_steer(self): + msg = busy_input_hint_gateway("steer") + assert "/busy interrupt" in msg + assert "/busy queue" in msg + assert "steer" in msg.lower() + def test_busy_input_hint_cli_interrupt(self): msg = busy_input_hint_cli("interrupt") assert "/busy queue" in msg @@ -125,6 +131,12 @@ class TestHintMessages: msg = busy_input_hint_cli("queue") assert "/busy interrupt" in msg + def test_busy_input_hint_cli_steer(self): + msg = busy_input_hint_cli("steer") + assert "/busy interrupt" in msg + assert "/busy queue" in msg + assert "steer" in msg.lower() + def test_tool_progress_hints_mention_verbose(self): assert "/verbose" in tool_progress_hint_gateway() assert "/verbose" in tool_progress_hint_cli() @@ -133,8 +145,10 @@ class TestHintMessages: for hint in ( busy_input_hint_gateway("queue"), busy_input_hint_gateway("interrupt"), + busy_input_hint_gateway("steer"), busy_input_hint_cli("queue"), busy_input_hint_cli("interrupt"), + busy_input_hint_cli("steer"), tool_progress_hint_gateway(), tool_progress_hint_cli(), ): diff --git a/tests/cli/test_busy_input_mode_command.py b/tests/cli/test_busy_input_mode_command.py index 6dd0afbc78..f3f34efe4f 100644 --- a/tests/cli/test_busy_input_mode_command.py +++ b/tests/cli/test_busy_input_mode_command.py @@ -65,6 +65,35 @@ class TestHandleBusyCommand(unittest.TestCase): self.assertEqual(stub.busy_input_mode, "interrupt") mock_save.assert_called_once_with("display.busy_input_mode", "interrupt") + def test_steer_argument_sets_steer_mode_and_saves(self): + cli_mod = _import_cli() + stub = self._make_cli("interrupt") + with ( + patch.object(cli_mod, "_cprint") as mock_cprint, + patch.object(cli_mod, "save_config_value", return_value=True) as mock_save, + ): + cli_mod.HermesCLI._handle_busy_command(stub, "/busy steer") + + self.assertEqual(stub.busy_input_mode, "steer") + mock_save.assert_called_once_with("display.busy_input_mode", "steer") + printed = " ".join(str(c) for c in mock_cprint.call_args_list) + self.assertIn("steer", printed.lower()) + + def test_status_reports_steer_behavior(self): + cli_mod = _import_cli() + stub = self._make_cli("steer") + with ( + patch.object(cli_mod, "_cprint") as mock_cprint, + patch.object(cli_mod, "save_config_value") as mock_save, + ): + cli_mod.HermesCLI._handle_busy_command(stub, "/busy status") + + mock_save.assert_not_called() + printed = " ".join(str(c) for c in mock_cprint.call_args_list) + self.assertIn("steer", printed.lower()) + # The usage line should also advertise the steer option + self.assertIn("steer", printed) + def test_invalid_argument_prints_usage(self): cli_mod = _import_cli() stub = self._make_cli() @@ -90,5 +119,5 @@ class TestBusyCommandRegistry(unittest.TestCase): from hermes_cli.commands import COMMAND_REGISTRY busy = next(c for c in COMMAND_REGISTRY if c.name == "busy") - assert busy.args_hint == "[queue|interrupt|status]" + assert busy.args_hint == "[queue|steer|interrupt|status]" assert busy.category == "Configuration" diff --git a/tests/gateway/test_busy_session_ack.py b/tests/gateway/test_busy_session_ack.py index 2d5f30f6d3..b16e5ebb5f 100644 --- a/tests/gateway/test_busy_session_ack.py +++ b/tests/gateway/test_busy_session_ack.py @@ -186,6 +186,91 @@ class TestBusySessionAck: assert "respond once the current task finishes" in content assert "Interrupting" not in content + @pytest.mark.asyncio + async def test_steer_mode_calls_agent_steer_no_interrupt_no_queue(self): + """busy_input_mode='steer' injects via agent.steer() and skips queueing.""" + runner, sentinel = _make_runner() + runner._busy_input_mode = "steer" + adapter = _make_adapter() + + event = _make_event(text="also check the tests") + sk = build_session_key(event.source) + runner.adapters[event.source.platform] = adapter + + agent = MagicMock() + agent.steer = MagicMock(return_value=True) + runner._running_agents[sk] = agent + + with patch("gateway.run.merge_pending_message_event") as mock_merge: + await runner._handle_active_session_busy_message(event, sk) + + # VERIFY: Agent was steered, NOT interrupted + agent.steer.assert_called_once_with("also check the tests") + agent.interrupt.assert_not_called() + + # VERIFY: No queueing — successful steer must NOT replay as next turn + mock_merge.assert_not_called() + + # VERIFY: Ack mentions steer 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 "Steered" in content or "steer" in content.lower() + assert "Interrupting" not in content + + @pytest.mark.asyncio + async def test_steer_mode_falls_back_to_queue_when_agent_rejects(self): + """If agent.steer() returns False, fall back to queue behavior.""" + runner, sentinel = _make_runner() + runner._busy_input_mode = "steer" + adapter = _make_adapter() + + event = _make_event(text="empty or rejected") + sk = build_session_key(event.source) + runner.adapters[event.source.platform] = adapter + + agent = MagicMock() + agent.steer = MagicMock(return_value=False) # rejected + runner._running_agents[sk] = agent + + with patch("gateway.run.merge_pending_message_event") as mock_merge: + await runner._handle_active_session_busy_message(event, sk) + + agent.steer.assert_called_once() + agent.interrupt.assert_not_called() + # Fell back to queue semantics: event was merged into pending messages + mock_merge.assert_called_once() + + # Ack uses queue-mode wording (not steer, not interrupt) + 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 "Steered" not in content + + @pytest.mark.asyncio + async def test_steer_mode_falls_back_to_queue_when_agent_pending(self): + """If agent is still starting (sentinel), steer mode falls back to queue.""" + runner, sentinel = _make_runner() + runner._busy_input_mode = "steer" + adapter = _make_adapter() + + event = _make_event(text="arrived too early") + sk = build_session_key(event.source) + runner.adapters[event.source.platform] = adapter + + # Agent is still being set up — sentinel in place + runner._running_agents[sk] = sentinel + + with patch("gateway.run.merge_pending_message_event") as mock_merge: + await runner._handle_active_session_busy_message(event, sk) + + # Event was queued instead of steered + mock_merge.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 + @pytest.mark.asyncio async def test_debounce_suppresses_rapid_acks(self): """Second message within 30s should NOT send another ack.""" diff --git a/tests/gateway/test_restart_drain.py b/tests/gateway/test_restart_drain.py index d2977f757f..3aca6d6405 100644 --- a/tests/gateway/test_restart_drain.py +++ b/tests/gateway/test_restart_drain.py @@ -90,9 +90,21 @@ def test_load_busy_input_mode_prefers_env_then_config_then_default(tmp_path, mon ) assert gateway_run.GatewayRunner._load_busy_input_mode() == "queue" + (tmp_path / "config.yaml").write_text( + "display:\n busy_input_mode: steer\n", encoding="utf-8" + ) + assert gateway_run.GatewayRunner._load_busy_input_mode() == "steer" + monkeypatch.setenv("HERMES_GATEWAY_BUSY_INPUT_MODE", "interrupt") assert gateway_run.GatewayRunner._load_busy_input_mode() == "interrupt" + monkeypatch.setenv("HERMES_GATEWAY_BUSY_INPUT_MODE", "steer") + assert gateway_run.GatewayRunner._load_busy_input_mode() == "steer" + + # Unknown values fall through to the safe default + monkeypatch.setenv("HERMES_GATEWAY_BUSY_INPUT_MODE", "bogus") + assert gateway_run.GatewayRunner._load_busy_input_mode() == "interrupt" + def test_load_restart_drain_timeout_prefers_env_then_config_then_default( tmp_path, monkeypatch, caplog diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index 0ba7245958..3a8a8d7274 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -225,19 +225,23 @@ The `display.busy_input_mode` config key controls what happens when you press En |------|----------| | `"interrupt"` (default) | Your message interrupts the current operation and is processed immediately | | `"queue"` | Your message is silently queued and sent as the next turn after the agent finishes | +| `"steer"` | Your message is injected into the current run via `/steer`, arriving at the agent after the next tool call — no interrupt, no new turn | ```yaml # ~/.hermes/config.yaml display: - busy_input_mode: "queue" # or "interrupt" (default) + busy_input_mode: "steer" # or "queue" or "interrupt" (default) ``` -Queue mode is useful when you want to prepare follow-up messages without accidentally canceling in-flight work. Unknown values fall back to `"interrupt"`. +`"queue"` mode is useful when you want to prepare follow-up messages without accidentally canceling in-flight work. `"steer"` mode is useful when you want to redirect the agent mid-task without interrupting — e.g. "actually, also check the tests" while it's still editing code. Unknown values fall back to `"interrupt"`. + +`"steer"` has two automatic fallbacks: if the agent hasn't started yet, or if images are attached, the message falls back to `"queue"` behavior so nothing is lost. You can also change it inside the CLI: ```text /busy queue +/busy steer /busy interrupt /busy status ``` diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index 2e6fa4f212..859a4d04ab 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -219,13 +219,16 @@ Send any message while the agent is working to interrupt it. Key behaviors: - **Multiple messages are combined** — messages sent during interruption are joined into one prompt - **`/stop` command** — interrupts without queuing a follow-up message -### Queue vs interrupt (busy-input mode) +### Queue vs interrupt vs steer (busy-input mode) -By default, messaging a busy agent interrupts it. To switch the whole install so follow-ups queue behind the current task instead, set: +By default, messaging a busy agent interrupts it. Two other modes are available: + +- `queue` — follow-up messages wait and run as the next turn after the current task finishes. +- `steer` — follow-up messages are injected into the current run via `/steer`, arriving at the agent after the next tool call. No interrupt, no new turn. Falls back to `queue` behavior if the agent hasn't started yet. ```yaml display: - busy_input_mode: queue # default: interrupt + busy_input_mode: steer # or queue, or interrupt (default) ``` The first time you message a busy agent on any platform, Hermes appends a one-line reminder to the busy-ack explaining the knob (`"💡 First-time tip — …"`). The reminder fires once per install — a flag under `onboarding.seen.busy_input_prompt` latches it. Delete that key to see the tip again.