From 2006cd58955e8e9b53b7ad792589af9b85fb3df4 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:05:13 -0700 Subject: [PATCH] refactor(gateway): declarative busy_policy on CommandDef replaces hand-written mid-run command chain --- gateway/platforms/base.py | 8 +- gateway/run.py | 521 +++++++++--------- hermes_cli/commands.py | 139 +++-- .../hermes_cli/test_busy_policy_invariants.py | 83 +++ 4 files changed, 445 insertions(+), 306 deletions(-) create mode 100644 tests/hermes_cli/test_busy_policy_invariants.py diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 2d7163a3881..761d997aadc 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -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) diff --git a/gateway/run.py b/gateway/run.py index 0c5f57d5f42..ed7825f898c 100644 --- a/gateway/run.py +++ b/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 — 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 " + 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 — 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 " + 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 — 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 " - 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 — 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 " - 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: diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 939c407370d..9e7fd98c512 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -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 — `/` + # 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 |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=""), + aliases=("bg", "btw"), args_hint="", 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 |edit ]", subcommands=("list", "delete", "edit")), CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session", - aliases=("q",), args_hint=""), + aliases=("q",), args_hint="", + busy_policy="dispatch", busy_handler="queue"), CommandDef("steer", "Inject a message after the next tool call without interrupting", "Session", - args_hint=""), + args_hint="", busy_policy="dispatch", busy_handler="steer"), CommandDef("goal", "Set a standing goal Hermes works on across turns until achieved", "Session", - args_hint="[text | draft | show | pause | resume | clear | status | wait | unwait]"), + args_hint="[text | draft | show | pause | resume | clear | status | wait | 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=""), + args_hint="", 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=""), - 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. diff --git a/tests/hermes_cli/test_busy_policy_invariants.py b/tests/hermes_cli/test_busy_policy_invariants.py new file mode 100644 index 00000000000..f99ba4bb873 --- /dev/null +++ b/tests/hermes_cli/test_busy_policy_invariants.py @@ -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