refactor(gateway): share plain command dispatch
This commit is contained in:
+30
-84
@@ -17153,6 +17153,32 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
"moa": "Agent is running — wait or /stop first, then run /moa.",
|
||||
}
|
||||
|
||||
def _gateway_plain_command_handlers(self):
|
||||
"""Return ordinary slash handlers shared by idle and busy dispatch."""
|
||||
return {
|
||||
"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,
|
||||
"pause": self._handle_pause_command,
|
||||
"agents": self._handle_agents_command,
|
||||
"bg": self._handle_background_command,
|
||||
"btw": self._handle_btw_command,
|
||||
"kanban": self._handle_kanban_command,
|
||||
"subgoal": self._handle_subgoal_command,
|
||||
"heartbeat": self._handle_heartbeat_command,
|
||||
"busy": self._handle_busy_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,
|
||||
}
|
||||
|
||||
async def _dispatch_busy_slash_command(
|
||||
self, event: MessageEvent, cmd_def, quick_key: str, source,
|
||||
):
|
||||
@@ -17193,29 +17219,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
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,
|
||||
"pause": self._handle_pause_command,
|
||||
"agents": self._handle_agents_command,
|
||||
"bg": self._handle_background_command,
|
||||
"btw": self._handle_btw_command,
|
||||
"kanban": self._handle_kanban_command,
|
||||
"subgoal": self._handle_subgoal_command,
|
||||
"heartbeat": self._handle_heartbeat_command,
|
||||
"busy": self._handle_busy_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)
|
||||
plain = self._gateway_plain_command_handlers().get(name)
|
||||
if plain is not None:
|
||||
return await plain(event)
|
||||
logger.warning(
|
||||
@@ -18319,8 +18323,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
canonical = _cmd_def.name if _cmd_def else command
|
||||
break
|
||||
|
||||
if canonical == "pause":
|
||||
return await self._handle_pause_command(event)
|
||||
plain_handler = self._gateway_plain_command_handlers().get(canonical)
|
||||
if plain_handler is not None:
|
||||
return await plain_handler(event)
|
||||
|
||||
if canonical == "new":
|
||||
if await asyncio.to_thread(self._is_telegram_topic_root_lobby, source):
|
||||
@@ -18341,42 +18346,21 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if canonical == "topic":
|
||||
return await self._handle_topic_command(event)
|
||||
|
||||
if canonical == "help":
|
||||
return await self._handle_help_command(event)
|
||||
|
||||
if canonical == "start":
|
||||
logger.info("Ignoring /start platform ping for session %s", _quick_key)
|
||||
return ""
|
||||
|
||||
if canonical == "commands":
|
||||
return await self._handle_commands_command(event)
|
||||
|
||||
if canonical == "profile":
|
||||
return await self._handle_profile_command(event)
|
||||
|
||||
if canonical == "whoami":
|
||||
return await self._handle_whoami_command(event)
|
||||
|
||||
if canonical == "status":
|
||||
return await self._handle_status_command(event)
|
||||
|
||||
if canonical == "egress":
|
||||
from hermes_cli.proxy_cli import format_status_text
|
||||
|
||||
return format_status_text()
|
||||
|
||||
if canonical == "context":
|
||||
return await self._handle_context_command(event)
|
||||
|
||||
if canonical == "agents":
|
||||
return await self._handle_agents_command(event)
|
||||
|
||||
if canonical == "platform":
|
||||
return await self._handle_platform_command(event)
|
||||
|
||||
if canonical == "restart":
|
||||
return await self._handle_restart_command(event)
|
||||
|
||||
if canonical == "stop":
|
||||
return await self._handle_stop_command(event)
|
||||
|
||||
@@ -18448,21 +18432,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if canonical == "fast":
|
||||
return await self._handle_fast_command(event)
|
||||
|
||||
if canonical == "verbose":
|
||||
return await self._handle_verbose_command(event)
|
||||
|
||||
if canonical == "footer":
|
||||
return await self._handle_footer_command(event)
|
||||
|
||||
if canonical == "yolo":
|
||||
return await self._handle_yolo_command(event)
|
||||
|
||||
if canonical == "approvals":
|
||||
return await self._handle_approvals_command(event)
|
||||
|
||||
if canonical == "busy":
|
||||
return await self._handle_busy_command(event)
|
||||
|
||||
if canonical == "model":
|
||||
return await self._handle_model_command(event)
|
||||
|
||||
@@ -18472,9 +18444,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if canonical == "personality":
|
||||
return await self._handle_personality_command(event)
|
||||
|
||||
if canonical == "kanban":
|
||||
return await self._handle_kanban_command(event)
|
||||
|
||||
if canonical == "suggestions":
|
||||
return await self._handle_suggestions_command(event)
|
||||
|
||||
@@ -18559,18 +18528,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if canonical == "bundles":
|
||||
return await self._handle_bundles_command(event)
|
||||
|
||||
if canonical == "approve":
|
||||
return await self._handle_approve_command(event)
|
||||
|
||||
if canonical == "deny":
|
||||
return await self._handle_deny_command(event)
|
||||
|
||||
if canonical == "update":
|
||||
return await self._handle_update_command(event)
|
||||
|
||||
if canonical == "version":
|
||||
return await self._handle_version_command(event)
|
||||
|
||||
if canonical == "debug":
|
||||
return await self._handle_debug_command(event)
|
||||
|
||||
@@ -18592,12 +18549,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if canonical == "diff":
|
||||
return await self._handle_diff_command(event)
|
||||
|
||||
if canonical == "bg":
|
||||
return await self._handle_background_command(event)
|
||||
|
||||
if canonical == "btw":
|
||||
return await self._handle_btw_command(event)
|
||||
|
||||
if canonical == "queue":
|
||||
queue_payload = event.get_command_args().strip()
|
||||
if not queue_payload:
|
||||
@@ -18628,8 +18579,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if canonical == "loop":
|
||||
return await self._handle_loop_command(event)
|
||||
|
||||
if canonical == "heartbeat":
|
||||
return await self._handle_heartbeat_command(event)
|
||||
if canonical == "refine":
|
||||
return await self._handle_refine_command(event)
|
||||
if canonical == "review":
|
||||
@@ -18671,9 +18620,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
except Exception:
|
||||
return "Failed to prepare MoA turn."
|
||||
|
||||
if canonical == "subgoal":
|
||||
return await self._handle_subgoal_command(event)
|
||||
|
||||
if canonical == "voice":
|
||||
return await self._handle_voice_command(event)
|
||||
|
||||
|
||||
@@ -129,3 +129,26 @@ async def test_idle_queue_sends_payload_as_next_turn(command_text):
|
||||
assert runner._running_agents == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plain_handler_map_drives_idle_and_busy_dispatch():
|
||||
"""Ordinary handlers have one lookup seam for idle and busy sessions."""
|
||||
from hermes_cli.commands import resolve_command
|
||||
|
||||
runner, _adapter = _make_runner()
|
||||
handler = AsyncMock(side_effect=["idle", "busy"])
|
||||
runner._gateway_plain_command_handlers = lambda: {"busy": handler}
|
||||
idle_event = _make_event("/busy status")
|
||||
|
||||
assert await runner._handle_message(idle_event) == "idle"
|
||||
|
||||
busy_event = _make_event("/busy status")
|
||||
cmd_def = resolve_command("busy")
|
||||
assert cmd_def is not None
|
||||
assert await runner._dispatch_busy_slash_command(
|
||||
busy_event,
|
||||
cmd_def,
|
||||
build_session_key(busy_event.source),
|
||||
busy_event.source,
|
||||
) == "busy"
|
||||
assert handler.await_count == 2
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
|
||||
| `/yolo` | Toggle YOLO mode — skip all dangerous command approval prompts. |
|
||||
| `/approvals [manual\|smart\|off]` | Show or set the persistent dangerous-command approval mode. |
|
||||
| `/footer [on\|off\|status]` | Toggle the gateway runtime-metadata footer on final replies (shows model, context %, and cwd). |
|
||||
| `/busy [queue\|steer\|interrupt\|status]` | CLI-only: control what pressing Enter does while Hermes is working — queue the new message, steer mid-turn, or interrupt immediately. |
|
||||
| `/busy [queue\|steer\|interrupt\|status]` | Control what happens when you message while Hermes is working — queue the new message, steer mid-turn, or interrupt immediately. Works in the CLI and messaging gateway. |
|
||||
| `/indicator [kaomoji\|emoji\|unicode\|ascii]` | CLI-only: pick the TUI busy-indicator style. |
|
||||
| `/timestamps [on\|off\|status]` | CLI-only: toggle `[HH:MM]` timestamps on messages and in `/history`. |
|
||||
| `/wake [on\|off\|status]` | CLI-only: toggle the "Hey Hermes" wake word listener. |
|
||||
@@ -292,12 +292,12 @@ The messaging gateway supports the following built-in commands inside Telegram,
|
||||
|
||||
## Notes
|
||||
|
||||
- `/skin`, `/snapshot`, `/export`, `/import`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/battery`, `/focus`, `/plugins`, `/busy`, `/indicator`, `/wake`, `/journey`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, `/prompt`, `/pet`, `/hatch`, `/timestamps`, `/subscription`, and `/quit` are **CLI-only** commands.
|
||||
- `/skin`, `/snapshot`, `/export`, `/import`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/battery`, `/focus`, `/plugins`, `/indicator`, `/wake`, `/journey`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, `/prompt`, `/pet`, `/hatch`, `/timestamps`, `/subscription`, and `/quit` are **CLI-only** commands.
|
||||
- `/skills` is **CLI-only for search/browse/install**; its write-approval review subcommands (`pending`, `approve`, `reject`, `diff`, `approval`) also work on messaging platforms when `skills.write_approval` is on. `/memory` works on **both** surfaces.
|
||||
- `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config.
|
||||
- `/focus` and `/verbose` share one suppression path (`display.tool_progress`), so they can never contradict each other: `/focus on` pins tool progress to `off` and stashes your mode under `display.focus_saved_tool_progress`; `/focus off` restores it; cycling `/verbose` while focus is on takes the mode back and clears the focus badge. Focus view is display-only — it never changes conversation history, the system prompt, or anything sent to the model, so it has zero prompt-cache impact.
|
||||
- `/sethome`, `/restart`, `/approve`, `/deny`, `/topic`, `/platform`, and `/commands` are **messaging-only** commands.
|
||||
- `/status`, `/egress`, `/version`, `/whoami`, `/bg`, `/btw`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/diff`, `/debug`, `/fast`, `/approvals`, `/footer`, `/curator`, `/kanban`, `/topup`, `/suggestions`, `/blueprint`, `/learn`, `/init`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway.
|
||||
- `/status`, `/egress`, `/version`, `/whoami`, `/bg`, `/btw`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/diff`, `/debug`, `/fast`, `/approvals`, `/busy`, `/footer`, `/curator`, `/kanban`, `/topup`, `/suggestions`, `/blueprint`, `/learn`, `/init`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway.
|
||||
- `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord.
|
||||
- In the TUI, `/sessions` shows live sessions in the current TUI process. Use `/resume [name]` or `hermes --tui --resume <id-or-title>` for saved or closed transcripts.
|
||||
|
||||
|
||||
+4
-4
@@ -77,7 +77,7 @@ Hermes 有两个斜杠命令入口,均由 `hermes_cli/commands.py` 中的中
|
||||
| `/voice [on\|off\|tts\|status]` | 切换 CLI 语音模式和语音播放。录音使用 `voice.record_key`(默认:`Ctrl+B`)。 |
|
||||
| `/yolo` | 切换 YOLO 模式——跳过所有危险命令审批提示。 |
|
||||
| `/footer [on\|off\|status]` | 切换最终回复中的 gateway 运行时元数据页脚(显示模型、工具调用次数、耗时)。 |
|
||||
| `/busy [queue\|steer\|interrupt\|status]` | 仅限 CLI:控制 Hermes 工作时按下 Enter 的行为——将新消息加入队列、中途引导,或立即中断。 |
|
||||
| `/busy [queue\|steer\|interrupt\|status]` | 控制 Hermes 工作时发送消息的行为——将新消息加入队列、中途引导,或立即中断。支持 CLI 和消息 gateway。 |
|
||||
| `/indicator [kaomoji\|emoji\|unicode\|ascii]` | 仅限 CLI:选择 TUI 忙碌指示器样式。 |
|
||||
|
||||
### 工具与 Skill
|
||||
@@ -247,11 +247,11 @@ hermes config set model.aliases.grok x-ai/grok-4
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `/skin`、`/snapshot`、`/reload`、`/tools`、`/toolsets`、`/browser`、`/config`、`/cron`、`/platforms`、`/paste`、`/image`、`/statusbar`、`/plugins`、`/busy`、`/indicator`、`/redraw`、`/clear`、`/history`、`/save`、`/copy`、`/handoff`、`/billing` 和 `/quit` 是**仅限 CLI** 的命令。
|
||||
- `/skin`、`/snapshot`、`/reload`、`/tools`、`/toolsets`、`/browser`、`/config`、`/cron`、`/platforms`、`/paste`、`/image`、`/statusbar`、`/plugins`、`/indicator`、`/redraw`、`/clear`、`/history`、`/save`、`/copy`、`/handoff`、`/billing` 和 `/quit` 是**仅限 CLI** 的命令。
|
||||
- `/skills` **仅在搜索/浏览/安装时属于 CLI-only**;其写入审批子命令(`pending`、`approve`、`reject`、`diff`、`approval`)在 `skills.write_approval` 开启时也可在消息平台使用。`/memory` 可在**两个表面**使用。
|
||||
- `/verbose` **默认仅限 CLI**,但可通过在 `config.yaml` 中设置 `display.tool_progress_command: true` 为消息平台启用。启用后,它会循环切换 `display.tool_progress` 模式并保存到配置。
|
||||
- `/sethome`、`/update`、`/restart`、`/approve`、`/deny`、`/topic`、`/platform` 和 `/commands` 是**仅限消息平台**的命令。
|
||||
- `/status`、`/version`、`/bg`、`/btw`、`/queue`、`/steer`、`/voice`、`/reload-mcp`、`/reload-skills`、`/rollback`、`/debug`、`/fast`、`/footer`、`/curator`、`/kanban`、`/credits`、`/suggestions`、`/blueprint`、`/sessions` 和 `/yolo` 在 **CLI 和消息 gateway 中均可使用**。
|
||||
- `/status`、`/version`、`/bg`、`/btw`、`/queue`、`/steer`、`/voice`、`/reload-mcp`、`/reload-skills`、`/rollback`、`/debug`、`/fast`、`/busy`、`/footer`、`/curator`、`/kanban`、`/credits`、`/suggestions`、`/blueprint`、`/sessions` 和 `/yolo` 在 **CLI 和消息 gateway 中均可使用**。
|
||||
- `/voice join`、`/voice channel` 和 `/voice leave` 仅在 Discord 上有意义。
|
||||
|
||||
## 破坏性命令的确认提示
|
||||
@@ -269,4 +269,4 @@ CLI 在执行会丢弃未保存会话状态的斜杠命令前会提示确认。
|
||||
|
||||
**内联跳过:** 追加 `now`、`--yes` 或 `-y` 可为单次调用绕过弹窗——例如 `/reset now`、`/new --yes my-session`、`/clear -y`、`/undo -y`。适用于弹窗在你的终端无法正常渲染的情况(见 [issue #30768](https://github.com/NousResearch/hermes-agent/issues/30768),原生 Windows PowerShell)或对 CLI 进行脚本化操作时。
|
||||
|
||||
在 `~/.hermes/config.yaml` 中设置 `approvals.destructive_slash_confirm: false` 可全局禁用提示;设置回 `true` 可重新启用。背景说明见 [安全——破坏性斜杠命令确认](../user-guide/security.md#dangerous-command-approval)。
|
||||
在 `~/.hermes/config.yaml` 中设置 `approvals.destructive_slash_confirm: false` 可全局禁用提示;设置回 `true` 可重新启用。背景说明见 [安全——破坏性斜杠命令确认](../user-guide/security.md#dangerous-command-approval)。
|
||||
|
||||
Reference in New Issue
Block a user