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:
Teknium
2026-04-26 18:21:29 -07:00
committed by GitHub
parent 87477756fd
commit 635253b918
14 changed files with 308 additions and 38 deletions

View File

@@ -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: