mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
refactor(gateway): declarative busy_policy on CommandDef replaces hand-written mid-run command chain
This commit is contained in:
parent
ed33ebca1d
commit
2006cd5895
4 changed files with 445 additions and 306 deletions
|
|
@ -5437,14 +5437,18 @@ class BasePlatformAdapter(ABC):
|
|||
# session lifecycle and its cleanup races with the running task
|
||||
# (see PR #4926).
|
||||
cmd = event.get_command()
|
||||
from hermes_cli.commands import should_bypass_active_session
|
||||
from hermes_cli.commands import (
|
||||
is_interrupt_then_dispatch,
|
||||
should_bypass_active_session,
|
||||
)
|
||||
|
||||
if should_bypass_active_session(cmd):
|
||||
# /stop, /new, /reset must cancel the in-flight adapter task
|
||||
# and preserve ordering of queued follow-ups. Route those
|
||||
# through the dedicated handoff path that serializes
|
||||
# cancellation + runner response + pending drain.
|
||||
if cmd in {"stop", "new", "reset"}:
|
||||
# (Registry-derived: busy_policy == "interrupt_then_dispatch".)
|
||||
if cmd and is_interrupt_then_dispatch(cmd):
|
||||
self._discard_text_debounce(session_key)
|
||||
try:
|
||||
await self._dispatch_active_session_command(event, session_key, cmd)
|
||||
|
|
|
|||
521
gateway/run.py
521
gateway/run.py
|
|
@ -11155,6 +11155,252 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
)
|
||||
return switched
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Mid-run (busy-session) slash command dispatch — "Guard 2".
|
||||
#
|
||||
# Replaces the historical hand-written per-command if-chain: each
|
||||
# command's mid-run behavior is declared on its CommandDef
|
||||
# (busy_policy / busy_handler in hermes_cli/commands.py) and resolved
|
||||
# here through a single handler table. Reply strings are byte-identical
|
||||
# to the old chain.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Command-specific mid-run reject texts (busy_policy == "reject" with a
|
||||
# busy_handler naming an entry here). All other rejected commands get
|
||||
# the generic catch-all text in _dispatch_busy_slash_command.
|
||||
_BUSY_REJECT_TEXT: Dict[str, str] = {
|
||||
"model": "Agent is running — wait or /stop first, then switch models.",
|
||||
"codex-runtime": ("Agent is running — wait or /stop first, then "
|
||||
"change runtime."),
|
||||
"moa": "Agent is running — wait or /stop first, then run /moa.",
|
||||
}
|
||||
|
||||
async def _dispatch_busy_slash_command(
|
||||
self, event: MessageEvent, cmd_def, quick_key: str, source,
|
||||
):
|
||||
"""Dispatch a recognized slash command while an agent is running.
|
||||
|
||||
Resolution order:
|
||||
1. ``busy_handler`` — special mid-run variant (e.g. /goal's
|
||||
control-verb whitelist, /queue's FIFO enqueue, /model's
|
||||
custom reject text).
|
||||
2. ``busy_policy == "dispatch"`` — the command's normal handler.
|
||||
3. Catch-all busy-reject text. Rejecting is required rather than
|
||||
falling through to interrupt + discard: commands like /model,
|
||||
/reasoning, /voice, /insights, /title, /resume, /retry,
|
||||
/undo, /compress, /usage, /reload-mcp, /sethome, /reset (all
|
||||
registered as Discord slash commands) would interrupt the
|
||||
agent AND get silently discarded by the slash-command safety
|
||||
net, producing a zero-char response. See #5057, #6252, #10370.
|
||||
"""
|
||||
name = cmd_def.name
|
||||
policy = getattr(cmd_def, "busy_policy", "reject")
|
||||
handler_key = getattr(cmd_def, "busy_handler", None)
|
||||
|
||||
if handler_key:
|
||||
special = {
|
||||
"start": self._busy_start_command,
|
||||
"stop": self._busy_stop_command,
|
||||
"new": self._busy_new_command,
|
||||
"queue": self._busy_queue_command,
|
||||
"steer": self._busy_steer_command,
|
||||
"egress": self._busy_egress_command,
|
||||
"goal": self._busy_goal_command,
|
||||
}.get(handler_key)
|
||||
if special is not None:
|
||||
return await special(event, quick_key, source)
|
||||
reject_text = self._BUSY_REJECT_TEXT.get(handler_key)
|
||||
if reject_text is not None:
|
||||
return reject_text
|
||||
|
||||
if policy in ("dispatch", "interrupt_then_dispatch"):
|
||||
plain = {
|
||||
"status": self._handle_status_command,
|
||||
"context": self._handle_context_command,
|
||||
"restart": self._handle_restart_command,
|
||||
"approve": self._handle_approve_command,
|
||||
"deny": self._handle_deny_command,
|
||||
"agents": self._handle_agents_command,
|
||||
"background": self._handle_background_command,
|
||||
"kanban": self._handle_kanban_command,
|
||||
"subgoal": self._handle_subgoal_command,
|
||||
"yolo": self._handle_yolo_command,
|
||||
"verbose": self._handle_verbose_command,
|
||||
"footer": self._handle_footer_command,
|
||||
"help": self._handle_help_command,
|
||||
"commands": self._handle_commands_command,
|
||||
"profile": self._handle_profile_command,
|
||||
"update": self._handle_update_command,
|
||||
"version": self._handle_version_command,
|
||||
}.get(name)
|
||||
if plain is not None:
|
||||
return await plain(event)
|
||||
logger.warning(
|
||||
"busy_policy=%s for /%s has no mid-run handler — "
|
||||
"falling back to busy-reject", policy, name,
|
||||
)
|
||||
|
||||
# Catch-all: any other recognized slash command reached the
|
||||
# running-agent guard. Reject gracefully rather than falling
|
||||
# through to interrupt + discard.
|
||||
return (
|
||||
f"⏳ Agent is running — `/{name}` can't run "
|
||||
f"mid-turn. Wait for the current response or `/stop` first."
|
||||
)
|
||||
|
||||
async def _busy_start_command(self, event: MessageEvent, quick_key: str, source):
|
||||
# Telegram sends /start for bot launches/deep-links. Treat it as a
|
||||
# platform ping, not a user command: no help dump, no agent
|
||||
# interrupt, no queued text.
|
||||
logger.info("Ignoring /start platform ping for active session %s", quick_key)
|
||||
return ""
|
||||
|
||||
async def _busy_egress_command(self, event: MessageEvent, quick_key: str, source):
|
||||
from hermes_cli.proxy_cli import format_status_text
|
||||
|
||||
return format_status_text()
|
||||
|
||||
async def _busy_stop_command(self, event: MessageEvent, quick_key: str, source):
|
||||
# /stop must hard-kill the session when an agent is running.
|
||||
# A soft interrupt (agent.interrupt()) doesn't help when the agent
|
||||
# is truly hung — the executor thread is blocked and never checks
|
||||
# _interrupt_requested. Force-clean _running_agents so the session
|
||||
# is unlocked and subsequent messages are processed normally.
|
||||
await self._interrupt_and_clear_session(
|
||||
quick_key,
|
||||
source,
|
||||
interrupt_reason=_INTERRUPT_REASON_STOP,
|
||||
invalidation_reason="stop_command",
|
||||
)
|
||||
logger.info("STOP for session %s — agent interrupted, session lock released", quick_key)
|
||||
return EphemeralReply(t("gateway.stop.stopped"))
|
||||
|
||||
async def _busy_new_command(self, event: MessageEvent, quick_key: str, source):
|
||||
# /reset and /new must bypass the running-agent guard so they
|
||||
# actually dispatch as commands instead of being queued as user
|
||||
# text (which would be fed back to the agent with the same
|
||||
# broken history — #2170). Interrupt the agent first, then
|
||||
# clear the adapter's pending queue so the stale "/reset" text
|
||||
# doesn't get re-processed as a user message after the
|
||||
# interrupt completes.
|
||||
# Clear any pending messages so the old text doesn't replay
|
||||
await self._interrupt_and_clear_session(
|
||||
quick_key,
|
||||
source,
|
||||
interrupt_reason=_INTERRUPT_REASON_RESET,
|
||||
invalidation_reason="new_command",
|
||||
)
|
||||
# Clean up the running agent entry so the reset handler
|
||||
# doesn't think an agent is still active.
|
||||
return await self._handle_reset_command(event)
|
||||
|
||||
async def _busy_queue_command(self, event: MessageEvent, quick_key: str, source):
|
||||
# /queue <prompt> — queue without interrupting.
|
||||
# Semantics: each /queue invocation produces its own full agent
|
||||
# turn, processed in FIFO order after the current run (and any
|
||||
# earlier /queue items) finishes. Messages are NOT merged.
|
||||
queued_text = event.get_command_args().strip()
|
||||
# Preserve media/reply payloads: a /queue carrying a photo,
|
||||
# document, or reply context is valid even with no prompt text
|
||||
# (e.g. "/queue" as the caption of an image). Dropping these
|
||||
# fields silently lost the attachment when the queued turn ran.
|
||||
has_media = bool(getattr(event, "media_urls", None))
|
||||
if not queued_text and not has_media:
|
||||
return "Usage: /queue <prompt>"
|
||||
adapter = self._adapter_for_source(source)
|
||||
if adapter:
|
||||
queued_event = MessageEvent(
|
||||
text=queued_text,
|
||||
message_type=event.message_type if has_media else MessageType.TEXT,
|
||||
source=event.source,
|
||||
raw_message=event.raw_message,
|
||||
message_id=event.message_id,
|
||||
media_urls=list(getattr(event, "media_urls", []) or []),
|
||||
media_types=list(getattr(event, "media_types", []) or []),
|
||||
reply_to_message_id=event.reply_to_message_id,
|
||||
reply_to_text=event.reply_to_text,
|
||||
reply_to_author_id=event.reply_to_author_id,
|
||||
reply_to_author_name=event.reply_to_author_name,
|
||||
reply_to_is_own_message=event.reply_to_is_own_message,
|
||||
auto_skill=event.auto_skill,
|
||||
channel_prompt=event.channel_prompt,
|
||||
channel_context=event.channel_context,
|
||||
internal=event.internal,
|
||||
timestamp=event.timestamp,
|
||||
)
|
||||
self._enqueue_fifo(quick_key, queued_event, adapter)
|
||||
depth = self._queue_depth(quick_key, adapter=self._adapter_for_source(source))
|
||||
if depth <= 1:
|
||||
return "Queued for the next turn."
|
||||
return f"Queued for the next turn. ({depth} queued)"
|
||||
|
||||
async def _busy_steer_command(self, event: MessageEvent, quick_key: str, source):
|
||||
# /steer <prompt> — inject mid-run after the next tool call.
|
||||
# Unlike /queue (turn boundary), /steer lands BETWEEN tool-call
|
||||
# iterations inside the same agent run, by appending to the
|
||||
# last tool result's content. No interrupt, no new user turn,
|
||||
# no role-alternation violation.
|
||||
steer_text = event.get_command_args().strip()
|
||||
if not steer_text:
|
||||
return "Usage: /steer <prompt>"
|
||||
running_agent = self._running_agents.get(quick_key)
|
||||
if running_agent is _AGENT_PENDING_SENTINEL:
|
||||
# Agent hasn't started yet — queue as turn-boundary fallback.
|
||||
adapter = self._adapter_for_source(source)
|
||||
if adapter:
|
||||
queued_event = MessageEvent(
|
||||
text=steer_text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=event.source,
|
||||
message_id=event.message_id,
|
||||
channel_prompt=event.channel_prompt,
|
||||
channel_context=event.channel_context,
|
||||
)
|
||||
adapter._pending_messages[quick_key] = queued_event
|
||||
return "Agent still starting — /steer queued for the next turn."
|
||||
if running_agent and hasattr(running_agent, "steer"):
|
||||
try:
|
||||
accepted = running_agent.steer(steer_text)
|
||||
except Exception as exc:
|
||||
logger.warning("Steer failed for session %s: %s", quick_key, exc)
|
||||
return f"⚠️ Steer failed: {exc}"
|
||||
if accepted:
|
||||
preview = steer_text[:60] + ("..." if len(steer_text) > 60 else "")
|
||||
return f"⏩ Steer queued — arrives after the next tool call: '{preview}'"
|
||||
return "Steer rejected (empty payload)."
|
||||
# Running agent is missing or lacks steer() — fall back to queue.
|
||||
adapter = self._adapter_for_source(source)
|
||||
if adapter:
|
||||
queued_event = MessageEvent(
|
||||
text=steer_text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=event.source,
|
||||
message_id=event.message_id,
|
||||
channel_prompt=event.channel_prompt,
|
||||
channel_context=event.channel_context,
|
||||
)
|
||||
adapter._pending_messages[quick_key] = queued_event
|
||||
return "No active agent — /steer queued for the next turn."
|
||||
|
||||
async def _busy_goal_command(self, event: MessageEvent, quick_key: str, source):
|
||||
# /goal is safe mid-run for status/pause/clear/wait (inspection
|
||||
# and control-plane only — doesn't interrupt the running turn).
|
||||
# Setting a new goal text mid-run is rejected with the same
|
||||
# "wait or /stop" message as /model so we don't race a second
|
||||
# continuation prompt against the current turn.
|
||||
_goal_arg = (event.get_command_args() or "").strip().lower()
|
||||
_goal_verb = _goal_arg.split(None, 1)[0] if _goal_arg else ""
|
||||
# Exact-match control verbs (unchanged semantics), plus the
|
||||
# wait/unwait barrier verbs which take a pid argument.
|
||||
_is_control = (
|
||||
not _goal_arg
|
||||
or _goal_arg in {"status", "pause", "resume", "clear", "stop", "done", "unwait"}
|
||||
or _goal_verb == "wait"
|
||||
)
|
||||
if _is_control:
|
||||
return await self._handle_goal_command(event)
|
||||
return "Agent is running — use /goal status / pause / clear / wait mid-run, or /stop before setting a new goal."
|
||||
|
||||
async def _handle_message(self, event: MessageEvent) -> Optional[str]:
|
||||
"""
|
||||
Handle an incoming message from any platform.
|
||||
|
|
@ -11568,20 +11814,22 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
self._release_running_agent_state(_quick_key)
|
||||
|
||||
if _quick_key in self._running_agents:
|
||||
if event.get_command() == "status":
|
||||
return await self._handle_status_command(event)
|
||||
|
||||
if event.get_command() in {"context", "ctx"}:
|
||||
return await self._handle_context_command(event)
|
||||
|
||||
# Resolve the command once for all early-intercept checks below.
|
||||
from hermes_cli.commands import (
|
||||
ACTIVE_SESSION_BYPASS_COMMANDS as _DEDICATED_HANDLERS,
|
||||
resolve_command as _resolve_cmd_inner,
|
||||
)
|
||||
# Resolve the command once; every command's mid-run behavior is
|
||||
# declared on its CommandDef (busy_policy / busy_handler in
|
||||
# hermes_cli/commands.py) and dispatched through the single
|
||||
# resolver _dispatch_busy_slash_command below — no per-command
|
||||
# if-chain here.
|
||||
from hermes_cli.commands import resolve_command as _resolve_cmd_inner
|
||||
_evt_cmd = event.get_command()
|
||||
_cmd_def_inner = _resolve_cmd_inner(_evt_cmd) if _evt_cmd else None
|
||||
|
||||
# /status and /context are intentionally pre-gate so users
|
||||
# always see session state.
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "status":
|
||||
return await self._handle_status_command(event)
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "context":
|
||||
return await self._handle_context_command(event)
|
||||
|
||||
# Slash command access control on the running-agent fast-path.
|
||||
# Mirrors the cold-path gate further below so non-admin users
|
||||
# can't bypass gating just because an agent happens to be busy.
|
||||
|
|
@ -11593,252 +11841,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
if _denied is not None:
|
||||
return _denied
|
||||
|
||||
# Telegram sends /start for bot launches/deep-links. Treat it as a
|
||||
# platform ping, not a user command: no help dump, no agent
|
||||
# interrupt, no queued text.
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "start":
|
||||
logger.info("Ignoring /start platform ping for active session %s", _quick_key)
|
||||
return ""
|
||||
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "restart":
|
||||
return await self._handle_restart_command(event)
|
||||
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "egress":
|
||||
from hermes_cli.proxy_cli import format_status_text
|
||||
|
||||
return format_status_text()
|
||||
|
||||
# /stop must hard-kill the session when an agent is running.
|
||||
# A soft interrupt (agent.interrupt()) doesn't help when the agent
|
||||
# is truly hung — the executor thread is blocked and never checks
|
||||
# _interrupt_requested. Force-clean _running_agents so the session
|
||||
# is unlocked and subsequent messages are processed normally.
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "stop":
|
||||
await self._interrupt_and_clear_session(
|
||||
_quick_key,
|
||||
source,
|
||||
interrupt_reason=_INTERRUPT_REASON_STOP,
|
||||
invalidation_reason="stop_command",
|
||||
)
|
||||
logger.info("STOP for session %s — agent interrupted, session lock released", _quick_key)
|
||||
return EphemeralReply(t("gateway.stop.stopped"))
|
||||
|
||||
# /reset and /new must bypass the running-agent guard so they
|
||||
# actually dispatch as commands instead of being queued as user
|
||||
# text (which would be fed back to the agent with the same
|
||||
# broken history — #2170). Interrupt the agent first, then
|
||||
# clear the adapter's pending queue so the stale "/reset" text
|
||||
# doesn't get re-processed as a user message after the
|
||||
# interrupt completes.
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "new":
|
||||
# Clear any pending messages so the old text doesn't replay
|
||||
await self._interrupt_and_clear_session(
|
||||
_quick_key,
|
||||
source,
|
||||
interrupt_reason=_INTERRUPT_REASON_RESET,
|
||||
invalidation_reason="new_command",
|
||||
)
|
||||
# Clean up the running agent entry so the reset handler
|
||||
# doesn't think an agent is still active.
|
||||
return await self._handle_reset_command(event)
|
||||
|
||||
# /queue <prompt> — queue without interrupting.
|
||||
# Semantics: each /queue invocation produces its own full agent
|
||||
# turn, processed in FIFO order after the current run (and any
|
||||
# earlier /queue items) finishes. Messages are NOT merged.
|
||||
if event.get_command() in {"queue", "q"}:
|
||||
queued_text = event.get_command_args().strip()
|
||||
# Preserve media/reply payloads: a /queue carrying a photo,
|
||||
# document, or reply context is valid even with no prompt text
|
||||
# (e.g. "/queue" as the caption of an image). Dropping these
|
||||
# fields silently lost the attachment when the queued turn ran.
|
||||
has_media = bool(getattr(event, "media_urls", None))
|
||||
if not queued_text and not has_media:
|
||||
return "Usage: /queue <prompt>"
|
||||
adapter = self._adapter_for_source(source)
|
||||
if adapter:
|
||||
queued_event = MessageEvent(
|
||||
text=queued_text,
|
||||
message_type=event.message_type if has_media else MessageType.TEXT,
|
||||
source=event.source,
|
||||
raw_message=event.raw_message,
|
||||
message_id=event.message_id,
|
||||
media_urls=list(getattr(event, "media_urls", []) or []),
|
||||
media_types=list(getattr(event, "media_types", []) or []),
|
||||
reply_to_message_id=event.reply_to_message_id,
|
||||
reply_to_text=event.reply_to_text,
|
||||
reply_to_author_id=event.reply_to_author_id,
|
||||
reply_to_author_name=event.reply_to_author_name,
|
||||
reply_to_is_own_message=event.reply_to_is_own_message,
|
||||
auto_skill=event.auto_skill,
|
||||
channel_prompt=event.channel_prompt,
|
||||
channel_context=event.channel_context,
|
||||
internal=event.internal,
|
||||
timestamp=event.timestamp,
|
||||
)
|
||||
self._enqueue_fifo(_quick_key, queued_event, adapter)
|
||||
depth = self._queue_depth(_quick_key, adapter=self._adapter_for_source(source))
|
||||
if depth <= 1:
|
||||
return "Queued for the next turn."
|
||||
return f"Queued for the next turn. ({depth} queued)"
|
||||
|
||||
# /steer <prompt> — inject mid-run after the next tool call.
|
||||
# Unlike /queue (turn boundary), /steer lands BETWEEN tool-call
|
||||
# iterations inside the same agent run, by appending to the
|
||||
# last tool result's content. No interrupt, no new user turn,
|
||||
# no role-alternation violation.
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "steer":
|
||||
steer_text = event.get_command_args().strip()
|
||||
if not steer_text:
|
||||
return "Usage: /steer <prompt>"
|
||||
running_agent = self._running_agents.get(_quick_key)
|
||||
if running_agent is _AGENT_PENDING_SENTINEL:
|
||||
# Agent hasn't started yet — queue as turn-boundary fallback.
|
||||
adapter = self._adapter_for_source(source)
|
||||
if adapter:
|
||||
queued_event = MessageEvent(
|
||||
text=steer_text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=event.source,
|
||||
message_id=event.message_id,
|
||||
channel_prompt=event.channel_prompt,
|
||||
channel_context=event.channel_context,
|
||||
)
|
||||
adapter._pending_messages[_quick_key] = queued_event
|
||||
return "Agent still starting — /steer queued for the next turn."
|
||||
if running_agent and hasattr(running_agent, "steer"):
|
||||
try:
|
||||
accepted = running_agent.steer(steer_text)
|
||||
except Exception as exc:
|
||||
logger.warning("Steer failed for session %s: %s", _quick_key, exc)
|
||||
return f"⚠️ Steer failed: {exc}"
|
||||
if accepted:
|
||||
preview = steer_text[:60] + ("..." if len(steer_text) > 60 else "")
|
||||
return f"⏩ Steer queued — arrives after the next tool call: '{preview}'"
|
||||
return "Steer rejected (empty payload)."
|
||||
# Running agent is missing or lacks steer() — fall back to queue.
|
||||
adapter = self._adapter_for_source(source)
|
||||
if adapter:
|
||||
queued_event = MessageEvent(
|
||||
text=steer_text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=event.source,
|
||||
message_id=event.message_id,
|
||||
channel_prompt=event.channel_prompt,
|
||||
channel_context=event.channel_context,
|
||||
)
|
||||
adapter._pending_messages[_quick_key] = queued_event
|
||||
return "No active agent — /steer queued for the next turn."
|
||||
|
||||
# /model must not be used while the agent is running.
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "model":
|
||||
return "Agent is running — wait or /stop first, then switch models."
|
||||
|
||||
# /codex-runtime must not be used while the agent is running.
|
||||
# Switching mid-turn would split a turn across two transports.
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "codex-runtime":
|
||||
return ("Agent is running — wait or /stop first, then "
|
||||
"change runtime.")
|
||||
|
||||
# /approve and /deny must bypass the running-agent interrupt path.
|
||||
# The agent thread is blocked on a threading.Event inside
|
||||
# tools/approval.py — sending an interrupt won't unblock it.
|
||||
# Route directly to the approval handler so the event is signalled.
|
||||
if _cmd_def_inner and _cmd_def_inner.name in {"approve", "deny"}:
|
||||
if _cmd_def_inner.name == "approve":
|
||||
return await self._handle_approve_command(event)
|
||||
return await self._handle_deny_command(event)
|
||||
|
||||
# /agents (/tasks alias) should be query-only and never interrupt.
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "agents":
|
||||
return await self._handle_agents_command(event)
|
||||
|
||||
# /background must bypass the running-agent guard — it starts a
|
||||
# parallel task and must never interrupt the active conversation.
|
||||
# /btw is an alias of /background and resolves to the same canonical
|
||||
# name, so this branch handles both commands.
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "background":
|
||||
return await self._handle_background_command(event)
|
||||
|
||||
# /kanban must bypass the guard. It writes to a profile-agnostic
|
||||
# DB (kanban.db), not to the running agent's state. In fact
|
||||
# /kanban unblock is often the only way to free a worker that
|
||||
# has blocked waiting for a peer — letting that be dispatched
|
||||
# mid-run is the whole point of the board.
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "kanban":
|
||||
return await self._handle_kanban_command(event)
|
||||
|
||||
# /goal is safe mid-run for status/pause/clear/wait (inspection
|
||||
# and control-plane only — doesn't interrupt the running turn).
|
||||
# Setting a new goal text mid-run is rejected with the same
|
||||
# "wait or /stop" message as /model so we don't race a second
|
||||
# continuation prompt against the current turn.
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "goal":
|
||||
_goal_arg = (event.get_command_args() or "").strip().lower()
|
||||
_goal_verb = _goal_arg.split(None, 1)[0] if _goal_arg else ""
|
||||
# Exact-match control verbs (unchanged semantics), plus the
|
||||
# wait/unwait barrier verbs which take a pid argument.
|
||||
_is_control = (
|
||||
not _goal_arg
|
||||
or _goal_arg in {"status", "pause", "resume", "clear", "stop", "done", "unwait"}
|
||||
or _goal_verb == "wait"
|
||||
)
|
||||
if _is_control:
|
||||
return await self._handle_goal_command(event)
|
||||
return "Agent is running — use /goal status / pause / clear / wait mid-run, or /stop before setting a new goal."
|
||||
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "moa":
|
||||
return "Agent is running — wait or /stop first, then run /moa."
|
||||
|
||||
# /subgoal is safe mid-run — it only modifies the goal's
|
||||
# subgoals list, which the judge reads at the next turn
|
||||
# boundary. No race with the running turn.
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "subgoal":
|
||||
return await self._handle_subgoal_command(event)
|
||||
|
||||
# Session-level toggles that are safe to run mid-agent —
|
||||
# /yolo can unblock a pending approval prompt, /verbose cycles
|
||||
# the tool-progress display mode for the ongoing stream.
|
||||
# Both modify session state without needing agent interaction
|
||||
# and must not be queued (the safety net would discard them).
|
||||
# /fast and /reasoning are config-only and take effect next
|
||||
# message, so they fall through to the catch-all busy response
|
||||
# below — users should wait and set them between turns.
|
||||
if _cmd_def_inner and _cmd_def_inner.name in {"yolo", "verbose", "footer"}:
|
||||
if _cmd_def_inner.name == "yolo":
|
||||
return await self._handle_yolo_command(event)
|
||||
if _cmd_def_inner.name == "verbose":
|
||||
return await self._handle_verbose_command(event)
|
||||
if _cmd_def_inner.name == "footer":
|
||||
return await self._handle_footer_command(event)
|
||||
|
||||
# Gateway-handled info/control commands with dedicated
|
||||
# running-agent handlers.
|
||||
if _cmd_def_inner and _cmd_def_inner.name in _DEDICATED_HANDLERS:
|
||||
if _cmd_def_inner.name == "help":
|
||||
return await self._handle_help_command(event)
|
||||
if _cmd_def_inner.name == "commands":
|
||||
return await self._handle_commands_command(event)
|
||||
if _cmd_def_inner.name == "profile":
|
||||
return await self._handle_profile_command(event)
|
||||
if _cmd_def_inner.name == "update":
|
||||
return await self._handle_update_command(event)
|
||||
if _cmd_def_inner.name == "version":
|
||||
return await self._handle_version_command(event)
|
||||
|
||||
# Catch-all: any other recognized slash command reached the
|
||||
# running-agent guard. Reject gracefully rather than falling
|
||||
# through to interrupt + discard. Without this, commands
|
||||
# like /model, /reasoning, /voice, /insights, /title,
|
||||
# /resume, /retry, /undo, /compress, /usage,
|
||||
# /reload-mcp, /sethome, /reset (all registered as Discord
|
||||
# slash commands) would interrupt the agent AND get
|
||||
# silently discarded by the slash-command safety net,
|
||||
# producing a zero-char response. See #5057, #6252, #10370.
|
||||
# Any recognized slash command: dispatch according to its
|
||||
# declared busy_policy (dispatch / interrupt_then_dispatch /
|
||||
# reject). Unrecognized commands and plain text fall through
|
||||
# to the interrupt/queue logic below.
|
||||
if _cmd_def_inner:
|
||||
return (
|
||||
f"⏳ Agent is running — `/{_cmd_def_inner.name}` can't run "
|
||||
f"mid-turn. Wait for the current response or `/stop` first."
|
||||
return await self._dispatch_busy_slash_command(
|
||||
event, _cmd_def_inner, _quick_key, source,
|
||||
)
|
||||
|
||||
if event.message_type == MessageType.PHOTO:
|
||||
|
|
|
|||
|
|
@ -55,6 +55,34 @@ class CommandDef:
|
|||
cli_only: bool = False # only available in CLI
|
||||
gateway_only: bool = False # only available in gateway/messaging
|
||||
gateway_config_gate: str | None = None # config dotpath; when truthy, overrides cli_only for gateway
|
||||
# Mid-run (agent busy) gateway behavior. Drives the Guard-2 dispatcher
|
||||
# in gateway/run.py (_dispatch_busy_slash_command) instead of a
|
||||
# hand-written per-command if-chain. Values:
|
||||
# "dispatch" — run the command while the agent is busy
|
||||
# (via its normal handler, or the mid-run
|
||||
# variant named by ``busy_handler``).
|
||||
# "reject" — refuse mid-run. Without ``busy_handler``
|
||||
# the generic "Agent is running — `/<cmd>`
|
||||
# can't run mid-turn" catch-all is returned;
|
||||
# with ``busy_handler`` a command-specific
|
||||
# reject message is used.
|
||||
# "interrupt_then_dispatch" — interrupt/kill the running agent first,
|
||||
# then dispatch (the /stop, /new, /reset
|
||||
# class). Guard 1 (platforms/base.py)
|
||||
# routes these through the cancel-handoff
|
||||
# path via is_interrupt_then_dispatch().
|
||||
busy_policy: str = "reject"
|
||||
# Optional key of a special mid-run handler in the Guard-2 handler table
|
||||
# (gateway/run.py) for commands whose busy behavior differs from their
|
||||
# normal handler (e.g. /goal's control-verb whitelist, /queue's FIFO
|
||||
# enqueue, /model's custom busy-reject text).
|
||||
busy_handler: str | None = None
|
||||
|
||||
|
||||
# Valid values for CommandDef.busy_policy (see field docs above).
|
||||
VALID_BUSY_POLICIES: frozenset[str] = frozenset(
|
||||
{"dispatch", "reject", "interrupt_then_dispatch"}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -64,9 +92,10 @@ class CommandDef:
|
|||
COMMAND_REGISTRY: list[CommandDef] = [
|
||||
# Session
|
||||
CommandDef("start", "Acknowledge platform start pings without a reply", "Session",
|
||||
gateway_only=True),
|
||||
gateway_only=True, busy_policy="dispatch", busy_handler="start"),
|
||||
CommandDef("new", "Start a new session (fresh session ID + history)", "Session",
|
||||
aliases=("reset",), args_hint="[name]"),
|
||||
aliases=("reset",), args_hint="[name]",
|
||||
busy_policy="interrupt_then_dispatch", busy_handler="new"),
|
||||
CommandDef("topic", "Enable or inspect Telegram DM topic sessions", "Session",
|
||||
gateway_only=True, args_hint="[off|help|session-id]"),
|
||||
CommandDef("clear", "Clear screen and start a new session", "Session",
|
||||
|
|
@ -94,36 +123,43 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
args_hint="[number]"),
|
||||
CommandDef("snapshot", "Create or restore state snapshots of Hermes config/state", "Session",
|
||||
cli_only=True, aliases=("snap",), args_hint="[create|restore <id>|prune]"),
|
||||
CommandDef("stop", "Kill all running background processes", "Session"),
|
||||
CommandDef("stop", "Kill all running background processes", "Session",
|
||||
busy_policy="interrupt_then_dispatch", busy_handler="stop"),
|
||||
CommandDef("approve", "Approve a pending dangerous command", "Session",
|
||||
gateway_only=True, args_hint="[session|always]"),
|
||||
gateway_only=True, args_hint="[session|always]", busy_policy="dispatch"),
|
||||
CommandDef("deny", "Deny a pending dangerous command (optionally with a reason)", "Session",
|
||||
gateway_only=True, args_hint="[all] [reason]"),
|
||||
gateway_only=True, args_hint="[all] [reason]", busy_policy="dispatch"),
|
||||
CommandDef("background", "Run a prompt in the background", "Session",
|
||||
aliases=("bg", "btw"), args_hint="<prompt>"),
|
||||
aliases=("bg", "btw"), args_hint="<prompt>", busy_policy="dispatch"),
|
||||
CommandDef("agents", "Show active agents and running tasks", "Session",
|
||||
aliases=("tasks",)),
|
||||
aliases=("tasks",), busy_policy="dispatch"),
|
||||
CommandDef("journey", "Open the learning journey timeline",
|
||||
"Session", aliases=("learning", "memory-graph"), cli_only=True,
|
||||
args_hint="[list|delete <id>|edit <id>]",
|
||||
subcommands=("list", "delete", "edit")),
|
||||
CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session",
|
||||
aliases=("q",), args_hint="<prompt>"),
|
||||
aliases=("q",), args_hint="<prompt>",
|
||||
busy_policy="dispatch", busy_handler="queue"),
|
||||
CommandDef("steer", "Inject a message after the next tool call without interrupting", "Session",
|
||||
args_hint="<prompt>"),
|
||||
args_hint="<prompt>", busy_policy="dispatch", busy_handler="steer"),
|
||||
CommandDef("goal", "Set a standing goal Hermes works on across turns until achieved", "Session",
|
||||
args_hint="[text | draft <text> | show | pause | resume | clear | status | wait <pid> | unwait]"),
|
||||
args_hint="[text | draft <text> | show | pause | resume | clear | status | wait <pid> | unwait]",
|
||||
busy_policy="dispatch", busy_handler="goal"),
|
||||
CommandDef("moa", "Run one prompt through the default Mixture of Agents preset, then restore your model", "Session",
|
||||
args_hint="<prompt>"),
|
||||
args_hint="<prompt>", busy_policy="reject", busy_handler="moa"),
|
||||
CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session",
|
||||
args_hint="[text | remove N | clear]"),
|
||||
CommandDef("status", "Show session, model, token, and context info", "Session"),
|
||||
args_hint="[text | remove N | clear]", busy_policy="dispatch"),
|
||||
CommandDef("status", "Show session, model, token, and context info", "Session",
|
||||
busy_policy="dispatch"),
|
||||
CommandDef("egress", "Show Docker egress proxy status", "Session",
|
||||
args_hint="[status]", subcommands=("status",)),
|
||||
args_hint="[status]", subcommands=("status",),
|
||||
busy_policy="dispatch", busy_handler="egress"),
|
||||
CommandDef("context", "Show detailed context window view with usage gauge, category breakdown, compression stats, and throughput", "Session",
|
||||
aliases=("ctx",), args_hint="[all]", subcommands=("all",)),
|
||||
aliases=("ctx",), args_hint="[all]", subcommands=("all",),
|
||||
busy_policy="dispatch"),
|
||||
CommandDef("whoami", "Show your slash command access (admin / user)", "Info"),
|
||||
CommandDef("profile", "Show active profile name and home directory", "Info"),
|
||||
CommandDef("profile", "Show active profile name and home directory", "Info",
|
||||
busy_policy="dispatch"),
|
||||
CommandDef("sethome", "Set this chat as the home channel", "Session",
|
||||
gateway_only=True, aliases=("set-home",)),
|
||||
CommandDef("resume", "Resume a previously-named session", "Session",
|
||||
|
|
@ -136,10 +172,12 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
CommandDef("config", "Show current configuration", "Configuration",
|
||||
cli_only=True),
|
||||
CommandDef("model", "Switch model (session-scoped; --global to persist)", "Configuration",
|
||||
args_hint="[model] [--provider name] [--global|--session] [--refresh]"),
|
||||
args_hint="[model] [--provider name] [--global|--session] [--refresh]",
|
||||
busy_policy="reject", busy_handler="model"),
|
||||
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
|
||||
"Configuration", aliases=("codex_runtime",),
|
||||
args_hint="[auto|codex_app_server]"),
|
||||
args_hint="[auto|codex_app_server]",
|
||||
busy_policy="reject", busy_handler="codex-runtime"),
|
||||
|
||||
CommandDef("personality", "Set a predefined personality", "Configuration",
|
||||
args_hint="[name]"),
|
||||
|
|
@ -156,15 +194,16 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
subcommands=("staged", "all", "session")),
|
||||
CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose -> log",
|
||||
"Configuration", cli_only=True,
|
||||
gateway_config_gate="display.tool_progress_command"),
|
||||
gateway_config_gate="display.tool_progress_command",
|
||||
busy_policy="dispatch"),
|
||||
CommandDef("focus", "Toggle focus view — show only your prompt and the final response",
|
||||
"Configuration", cli_only=True, args_hint="[on|off|status]",
|
||||
subcommands=("on", "off", "status")),
|
||||
CommandDef("footer", "Toggle gateway runtime-metadata footer on final replies",
|
||||
"Configuration", args_hint="[on|off|status]",
|
||||
subcommands=("on", "off", "status")),
|
||||
subcommands=("on", "off", "status"), busy_policy="dispatch"),
|
||||
CommandDef("yolo", "Toggle YOLO mode (skip all dangerous command approvals)",
|
||||
"Configuration"),
|
||||
"Configuration", busy_policy="dispatch"),
|
||||
CommandDef("approvals", "Show or set the persistent dangerous-command approval mode",
|
||||
"Configuration", args_hint="[manual|smart|off]",
|
||||
subcommands=("manual", "smart", "off")),
|
||||
|
|
@ -230,7 +269,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
"claim", "comment", "complete", "edit", "block", "unblock",
|
||||
"archive", "tail", "dispatch", "stats", "notify-subscribe",
|
||||
"notify-list", "notify-unsubscribe", "log", "runs",
|
||||
"heartbeat", "assignees", "context", "specify", "gc")),
|
||||
"heartbeat", "assignees", "context", "specify", "gc"),
|
||||
busy_policy="dispatch"),
|
||||
CommandDef("reload", "Reload .env variables into the running session", "Tools & Skills",
|
||||
cli_only=True),
|
||||
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
|
||||
|
|
@ -245,10 +285,10 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
|
||||
# Info
|
||||
CommandDef("commands", "Browse all commands and skills (paginated)", "Info",
|
||||
gateway_only=True, args_hint="[page]"),
|
||||
CommandDef("help", "Show available commands", "Info"),
|
||||
gateway_only=True, args_hint="[page]", busy_policy="dispatch"),
|
||||
CommandDef("help", "Show available commands", "Info", busy_policy="dispatch"),
|
||||
CommandDef("restart", "Gracefully restart the gateway after draining active runs", "Session",
|
||||
gateway_only=True),
|
||||
gateway_only=True, busy_policy="dispatch"),
|
||||
CommandDef("usage", "Show token usage and rate limits; `reset` redeems a banked Codex limit reset", "Info",
|
||||
args_hint="[reset [--force]]"),
|
||||
CommandDef("subscription", "View your Nous plan and change it in the browser", "Info",
|
||||
|
|
@ -266,8 +306,10 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
cli_only=True),
|
||||
CommandDef("image", "Attach a local image file for your next prompt", "Info",
|
||||
cli_only=True, args_hint="<path>"),
|
||||
CommandDef("update", "Update Hermes Agent to the latest version", "Info"),
|
||||
CommandDef("version", "Show Hermes Agent version", "Info", aliases=("v",)),
|
||||
CommandDef("update", "Update Hermes Agent to the latest version", "Info",
|
||||
busy_policy="dispatch"),
|
||||
CommandDef("version", "Show Hermes Agent version", "Info", aliases=("v",),
|
||||
busy_policy="dispatch"),
|
||||
CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info",
|
||||
args_hint="[nous|local]"),
|
||||
|
||||
|
|
@ -382,31 +424,32 @@ def is_gateway_known_command(name: str | None) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
# Commands with explicit Level-2 running-agent handlers in gateway/run.py.
|
||||
# Listed here for introspection / tests; semantically a subset of
|
||||
# "all resolvable commands" — which is the real bypass set (see
|
||||
# should_bypass_active_session below).
|
||||
# Commands with explicit mid-run (running-agent) behavior in gateway/run.py.
|
||||
# DERIVED from the registry: every command whose ``busy_policy`` is not
|
||||
# "reject" either dispatches while the agent is busy or interrupts it first.
|
||||
# Kept under its historical public name for introspection / tests;
|
||||
# semantically a subset of "all resolvable commands" — which is the real
|
||||
# bypass set (see should_bypass_active_session below).
|
||||
ACTIVE_SESSION_BYPASS_COMMANDS: frozenset[str] = frozenset(
|
||||
{
|
||||
"agents",
|
||||
"approve",
|
||||
"background",
|
||||
"commands",
|
||||
"deny",
|
||||
"help",
|
||||
"new",
|
||||
"profile",
|
||||
"queue",
|
||||
"restart",
|
||||
"status",
|
||||
"steer",
|
||||
"stop",
|
||||
"update",
|
||||
"version",
|
||||
}
|
||||
cmd.name for cmd in COMMAND_REGISTRY if cmd.busy_policy != "reject"
|
||||
)
|
||||
|
||||
|
||||
def is_interrupt_then_dispatch(command_name: str | None) -> bool:
|
||||
"""Return True when *command_name* must interrupt a running agent first.
|
||||
|
||||
Derived from the registry: commands whose ``busy_policy`` is
|
||||
"interrupt_then_dispatch" (the /stop, /new, /reset class). Guard 1
|
||||
(gateway/platforms/base.py) routes these through the cancel-handoff
|
||||
path that serializes cancellation + runner response + pending drain.
|
||||
Accepts aliases (e.g. "reset" resolves to "new").
|
||||
"""
|
||||
if not command_name:
|
||||
return False
|
||||
cmd = resolve_command(command_name)
|
||||
return cmd is not None and cmd.busy_policy == "interrupt_then_dispatch"
|
||||
|
||||
|
||||
def should_bypass_active_session(command_name: str | None) -> bool:
|
||||
"""Return True for any resolvable slash command.
|
||||
|
||||
|
|
|
|||
83
tests/hermes_cli/test_busy_policy_invariants.py
Normal file
83
tests/hermes_cli/test_busy_policy_invariants.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Invariant tests for the declarative busy_policy on CommandDef.
|
||||
|
||||
Guards the contract introduced by the Guard-2 refactor (gateway/run.py):
|
||||
every command's mid-run behavior is declared on its CommandDef via
|
||||
``busy_policy`` / ``busy_handler`` and the historical
|
||||
``ACTIVE_SESSION_BYPASS_COMMANDS`` frozenset is DERIVED from the registry
|
||||
rather than hand-maintained.
|
||||
"""
|
||||
|
||||
from hermes_cli.commands import (
|
||||
ACTIVE_SESSION_BYPASS_COMMANDS,
|
||||
COMMAND_REGISTRY,
|
||||
VALID_BUSY_POLICIES,
|
||||
is_interrupt_then_dispatch,
|
||||
should_bypass_active_session,
|
||||
)
|
||||
|
||||
# The hand-written frozenset as it existed before the busy_policy refactor.
|
||||
# This is a behavior contract, not a snapshot: the derived set must remain a
|
||||
# SUPERSET of these names (each had an explicit mid-run handler in the old
|
||||
# Guard-2 if-chain and must keep bypassing the busy-reject catch-all).
|
||||
_HISTORICAL_BYPASS_NAMES = frozenset(
|
||||
{
|
||||
"agents",
|
||||
"approve",
|
||||
"background",
|
||||
"commands",
|
||||
"deny",
|
||||
"help",
|
||||
"new",
|
||||
"profile",
|
||||
"queue",
|
||||
"restart",
|
||||
"status",
|
||||
"steer",
|
||||
"stop",
|
||||
"update",
|
||||
"version",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_every_command_has_valid_busy_policy():
|
||||
bad = [
|
||||
(cmd.name, cmd.busy_policy)
|
||||
for cmd in COMMAND_REGISTRY
|
||||
if cmd.busy_policy not in VALID_BUSY_POLICIES
|
||||
]
|
||||
assert not bad, f"Commands with invalid busy_policy: {bad}"
|
||||
|
||||
|
||||
def test_derived_bypass_set_covers_historical_names():
|
||||
missing = _HISTORICAL_BYPASS_NAMES - ACTIVE_SESSION_BYPASS_COMMANDS
|
||||
assert not missing, (
|
||||
"Commands lost their mid-run bypass (busy_policy regressed to "
|
||||
f"'reject'): {sorted(missing)}"
|
||||
)
|
||||
|
||||
|
||||
def test_bypass_set_is_derived_from_registry():
|
||||
expected = frozenset(
|
||||
cmd.name for cmd in COMMAND_REGISTRY if cmd.busy_policy != "reject"
|
||||
)
|
||||
assert ACTIVE_SESSION_BYPASS_COMMANDS == expected
|
||||
|
||||
|
||||
def test_interrupt_then_dispatch_class():
|
||||
# The cancel-handoff class (Guard 1, gateway/platforms/base.py) must
|
||||
# contain exactly the /stop and /new (alias /reset) commands today.
|
||||
assert is_interrupt_then_dispatch("stop")
|
||||
assert is_interrupt_then_dispatch("new")
|
||||
assert is_interrupt_then_dispatch("reset") # alias of /new
|
||||
assert not is_interrupt_then_dispatch("model")
|
||||
assert not is_interrupt_then_dispatch("status")
|
||||
assert not is_interrupt_then_dispatch(None)
|
||||
assert not is_interrupt_then_dispatch("not-a-command")
|
||||
|
||||
|
||||
def test_bypass_names_resolve_and_bypass_guard1():
|
||||
# Every derived bypass name must be a resolvable command (Guard 1's
|
||||
# should_bypass_active_session admits all resolvable commands).
|
||||
for name in ACTIVE_SESSION_BYPASS_COMMANDS:
|
||||
assert should_bypass_active_session(name), name
|
||||
Loading…
Add table
Add a link
Reference in a new issue