feat(busy): add 'steer' as a third display.busy_input_mode option (#16279)
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).
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user