CLI / Gateway / Profile 架构
一个 Agent 核心,20 个外面世界的出口。Profile 隔离的"导入前" 技巧、中心化 Slash Command Registry、Platform Adapter 模式。
到目前为止我们看的是 Agent 的"里面"——循环、工具、记忆。这一章我们看 "外面"——同一个核心怎么同时服务 CLI、Telegram、Discord、Slack、WhatsApp、 Signal、Email、Feishu、DingTalk、QQ、Weixin、Mattermost、Matrix、Home Assistant 等。 Hermes 一共 20+ 个 platform adapter,全部走同一份核心代码。
11.1三个出口
Hermes 的部署形态有三大类:
CLI
本地终端聊天,hermes 命令。最常见的开发者模式。
Gateway
跑在 VPS 上的 daemon。同时连 Telegram bot、Discord bot 等多个平台。用户通过手机消息和 Agent 对话。
ACP
跑在 IDE(VS Code / Zed / JetBrains)里。Agent Communication Protocol。给程序员当 in-editor copilot。
它们共用 AIAgent 类。变化的只是"输入怎么进来、输出怎么出去"。
11.2Profile:多实例隔离
你想同时有几个 Hermes:一个跑工作(连公司 Slack、用公司 API key),一个跑个人 (连个人 Telegram、用个人 API key)。各自独立的 config、memory、sessions、skills。 Hermes 的 Profile 机制实现这个:
~/.hermes/ # 默认 profile
├── config.yaml
├── .env
├── sessions.db
├── skills/
└── ...
~/.hermes/profiles/
├── work/
│ ├── config.yaml # 工作用配置
│ ├── .env
│ ├── sessions.db
│ ├── skills/
│ └── ...
└── personal/
├── config.yaml
└── ...
# 用法
hermes # 默认 profile
hermes -p work # 切到 work profile
hermes --profile personal # 切到 personal profile
实现:HERMES_HOME 环境变量
整个 Hermes 代码里所有"用户数据路径"都来自 get_hermes_home(),
它读环境变量 HERMES_HOME。默认 ~/.hermes,profile 时改为 ~/.hermes/profiles/work。
但有一个关键陷阱。看 Python 模块顶层:
# 假设 hermes_state.py 的顶层有这一行
SESSION_DB_PATH = get_hermes_home() / "sessions.db"
这行在 hermes_state 被 import 时执行一次。结果固定。
如果你在 import 之后才改 HERMES_HOME,SESSION_DB_PATH 已经定死指向默认路径。
Profile 切换失败。
解法:导入前注入
看 hermes_cli/main.py 的前 80 行:
hermes_cli/main.py:184-269
def _apply_profile_override() -> None:
"""Pre-parse --profile/-p and set HERMES_HOME before module imports."""
argv = sys.argv[1:]
profile_name = None
consume = 0
# 1. 显式 -p / --profile flag
for i, arg in enumerate(argv):
if arg in {"--profile", "-p"} and i + 1 < len(argv):
profile_name = argv[i + 1]
consume = 2
break
elif arg.startswith("--profile="):
profile_name = arg.split("=", 1)[1]
consume = 1
break
# 2. fallback: ~/.hermes/active_profile 文件
if profile_name is None:
active_file = Path.home() / ".hermes" / "active_profile"
if active_file.is_file():
profile_name = active_file.read_text(encoding="utf-8").strip()
# 3. 解析并设 env var
if profile_name is not None:
from hermes_cli.profiles import resolve_profile_env
hermes_home = resolve_profile_env(profile_name)
os.environ["HERMES_HOME"] = hermes_home
# 把 --profile 从 sys.argv 抹掉,免得 argparse 报错
if consume > 0:
for i, arg in enumerate(argv):
if arg in {"--profile", "-p"}:
sys.argv = sys.argv[:i+1] + sys.argv[i+1+consume:]
break
# 关键:在所有 hermes import 之前调用
_apply_profile_override()
from hermes_cli._parser import ...
from hermes_constants import ...
这个模式是"配置必须在代码读它之前注入"的解决方案。三步:
- 在 import hermes 模块之前,手动 parse argv 找
--profile。 - 设置
HERMES_HOMEenv var。 - 从 argv 删除 profile 参数(argparse 不认识)。
11.3Profile-Safe 代码规则
Hermes AGENTS.md 里有六条 profile-safe 规则。所有贡献者都遵守:
-
用
get_hermes_home(),不 hardcode~/.hermes。# GOOD from hermes_constants import get_hermes_home config_path = get_hermes_home() / "config.yaml" # BAD — 破 profile config_path = Path.home() / ".hermes" / "config.yaml" -
用户消息用
display_hermes_home()。# GOOD print(f"Saved to {display_hermes_home()}/config.yaml") # 输出 ~/.hermes/config.yaml 或 ~/.hermes/profiles/work/config.yaml -
模块级常量也用
get_hermes_home()—— 它在 import 时调用,但_apply_profile_override()在更早执行。 -
测试要同时 mock
Path.home()和HERMES_HOME。with patch.object(Path, "home", return_value=tmp_path), \ patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}): ... -
Platform adapter 用 token lock。如果两个 profile 都用同一个 Telegram bot token
连上 Telegram,会冲突。通过
acquire_scoped_lock()锁。 -
Profile 列表操作 anchor 在
~/.hermes/profiles/, 不是get_hermes_home() / "profiles"。这让hermes -p coder profile list能看到所有 profile,不只是当前那个。
11.4Slash Command Registry:单一来源
Hermes 有 CLI、Telegram、Discord、Slack 多个界面,每个都支持 slash 命令
(/new、/model、/tools...)。如果每个界面各自定义命令,
开发噩梦——加一个命令要改 5 个地方。
Hermes 的解法:所有 slash 命令在一个文件里定义。
hermes_cli/commands.py:
hermes_cli/commands.py:45-120
@dataclass(frozen=True)
class CommandDef:
"""Definition of a single slash command."""
name: str # "background"
description: str # human-readable
category: str # "Session" / "Configuration" / ...
aliases: tuple[str, ...] = () # ("bg",)
args_hint: str = "" # "<prompt>", "[name]"
subcommands: tuple[str, ...] = () # tab-补全用
cli_only: bool = False # 只在 CLI
gateway_only: bool = False # 只在 gateway
gateway_config_gate: str | None = None # 条件解锁
COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("new", "Start a new session", "Session",
aliases=("reset",)),
CommandDef("topic", "Telegram DM topic sessions", "Session",
gateway_only=True, args_hint="[off|session-id]"),
CommandDef("clear", "Clear screen and start fresh", "Session",
cli_only=True),
CommandDef("history", "Show conversation history", "Session",
cli_only=True),
CommandDef("model", "Select provider and model", "Configuration",
args_hint="[provider:model]"),
CommandDef("tools", "Configure tool enablement", "Configuration",
cli_only=True,
subcommands=("list", "enable", "disable")),
CommandDef("insights", "Show token usage and cache stats", "Info",
args_hint="[--days N]"),
CommandDef("skills", "List or manage skills", "Tools & Skills",
subcommands=("list", "install", "create")),
# ... 120+ 条
]
所有消费者从这里派生
这一份列表喂给 6 个消费者:
| 消费者 | 派生方式 |
|---|---|
CLI process_command() | resolve_command() 找 canonical name 后 dispatch |
CLI 帮助 show_help() | 按 category 分组渲染 |
Tab 补全 SlashCommandCompleter | 读 flat COMMANDS dict |
| Telegram BotCommand | telegram_bot_commands() 生成 menu |
| Slack subcommand 路由 | slack_subcommand_map() 生成 dispatch |
| Gateway runner | GATEWAY_KNOWN_COMMANDS frozenset 用于消息守卫 |
加一个新命令?只改 commands.py 一处,其他自动跟上。
加一个别名?编辑 aliases tuple,一行改完。
gateway_only / cli_only 的细节
有些命令只在特定环境有意义:
/clear— 清屏 → 只在 CLI(Telegram 没法清屏)/topic— Telegram DM topic 管理 → 只在 gateway/insights— 看 token 用量 → 两边都有(但显示方式不同)
gateway_config_gate:条件解锁
看 tools 命令——它是 cli_only=True,但又有
gateway_config_gate="display.tool_progress_command"。意思:
- 默认在 gateway 不可用。
- 用户在 config.yaml 里设
display.tool_progress_command: true, gateway 也解锁这个命令。
这种"feature flag in command"模式让单一注册表也能支持精细 routing。
11.5CLI 入口与子命令
看 hermes_cli/main.py:11000 附近的 main():
def main():
from hermes_cli._parser import build_top_level_parser
parser, subparsers, chat_parser = build_top_level_parser()
chat_parser.set_defaults(func=cmd_chat)
# model 子命令
model_parser = subparsers.add_parser("model", ...)
model_parser.set_defaults(func=cmd_model)
# tools 子命令
tools_parser = subparsers.add_parser("tools", ...)
tools_parser.set_defaults(func=cmd_tools)
# cron / gateway / curator / skills / ... 共 20+ 子命令
...
args = parser.parse_args()
args.func(args)
经典 argparse + set_defaults(func=...) 模式。每个 cmd_X
函数处理对应 subcommand。
注意区分:
hermes model— 命令行子命令(启动时一次性)/model— 交互式 slash 命令(已经在对话里)
这俩有时干同一件事(选模型),有时不同(hermes model 不进入聊天,只设置 default)。
两边都从 COMMAND_REGISTRY 派生命令信息保证一致。
11.6Gateway:多平台聚合
Gateway 是一个长跑的进程,同时连接多个 messaging 平台。架构:
flowchart TB
subgraph GW["Gateway Process"]
direction TB
AR["AgentRunner
per session"]:::core
AR -->|message events| TA["TelegramAdapter"]
AR --> DA["DiscordAdapter"]
AR --> XA["… 18 more adapters"]
end
TA -.-> TAPI["Telegram API"]
DA -.-> DAPI["Discord WebSocket"]
XA -.-> XAPI["Slack / WhatsApp /
Signal / Feishu / ..."]
TAPI --> U1["📱 用户 1
手机"]
DAPI --> U2["💻 用户 2
桌面"]
XAPI --> U3["..."]
classDef core fill:#f5ede0,stroke:#8b1538,color:#8b1538,font-weight:bold
Gateway 的精神:每个 user message 进来 → 转成 MessageEvent → routing 到 AgentRunner → AgentRunner 调 AIAgent → 输出回 adapter → 发回去。
11.7Platform Adapter 模式
每个平台一个子类。看 gateway/platforms/base.py:
gateway/platforms/base.py:1-100
class PlatformAdapter(ABC):
"""Base platform adapter interface.
All platform adapters (Telegram, Discord, WhatsApp, ...)
inherit and implement these methods.
"""
name: str # "telegram", "discord", ...
@abstractmethod
async def connect(self) -> None:
"""Connect to the platform (e.g. login, start polling)."""
@abstractmethod
async def disconnect(self) -> None:
"""Clean disconnect (e.g. release locks, close sockets)."""
@abstractmethod
async def send_message(self, message: str, to_user: str,
**kwargs) -> str:
"""Send a message; return platform-side message ID."""
@abstractmethod
async def handle_incoming(self, event: MessageEvent) -> None:
"""Process incoming platform message."""
# Pending message queue — incoming during agent busy
_pending_messages: deque = field(default_factory=deque)
_active_sessions: set = field(default_factory=set)
具体平台例子:Telegram
gateway/platforms/telegram.py (节选)
class TelegramAdapter(PlatformAdapter):
name = "telegram"
async def connect(self):
token = os.environ["TELEGRAM_BOT_TOKEN"]
# Token lock — 防止两个 profile 抢同一个 bot
await acquire_scoped_lock(f"telegram_token_{token}")
self.bot = telegram.Bot(token=token)
await self.bot.initialize()
# 注册到 Telegram 的命令菜单(从 COMMAND_REGISTRY 派生)
await self.bot.set_my_commands(telegram_bot_commands())
self._polling_task = asyncio.create_task(self._poll())
async def send_message(self, message, to_user, **kwargs):
chat_id = int(to_user)
# Telegram 上限 4096 UTF-16 code units
truncated = _prefix_within_utf16_limit(message, 4096)
msg = await self.bot.send_message(
chat_id=chat_id,
text=truncated,
parse_mode=ParseMode.MARKDOWN,
)
return str(msg.message_id)
async def handle_incoming(self, event):
# 路由给 AgentRunner
await self.agent_runner.dispatch(event)
async def _poll(self):
async for update in self.bot.get_updates(allowed_updates=[...]):
# 把 Telegram update 转成 MessageEvent
event = _telegram_update_to_event(update)
await self.handle_incoming(event)
每个具体 platform 的工作:
- 连接平台、认证。
- 注册 bot 命令菜单(如果平台支持)。
- 把平台原生事件翻译成
MessageEvent。 - 发送时把 Agent 输出按平台限制(4096 字节 / 2000 字节 / 不支持 markdown 等)切分/转换。
- 注意 token lock 防多实例抢同一个账户。
11.8消息守卫:双重队列
Agent 在跑一个长任务时(在调工具),用户又发来新消息怎么办? Hermes 用两道守卫:
守卫 1:base adapter 的 _pending_messages
gateway/platforms/base.py (摘要)
async def handle_incoming(self, event):
session_key = event.user_id + ":" + event.chat_id
if session_key in self._active_sessions:
# Agent 正忙 — 排队
self._pending_messages.append(event)
return
self._active_sessions.add(session_key)
try:
await self.agent_runner.dispatch(event)
finally:
self._active_sessions.remove(session_key)
# 处理队列里的下一条
if self._pending_messages:
await self.handle_incoming(self._pending_messages.popleft())
守卫 2:gateway runner 拦截控制命令
有些命令必须在 agent 跑着时也能用:/stop(打断)、/approve(审批)、
/queue(看队列)等。这些不能进 pending_messages(agent 不停就永远处理不到)。
所以 gateway runner 在守卫 1 之前还要再检查一次:
gateway/run.py (摘要)
async def on_message(event):
canonical = parse_command_name(event.text)
if canonical in CONTROL_COMMANDS: # stop, approve, queue, status, new
# Inline dispatch — 绕过 _pending_messages
return await _handle_control(canonical, event)
# 普通消息走守卫 1
await adapter.handle_incoming(event)
CONTROL_COMMANDS 和 GATEWAY_KNOWN_COMMANDS。
忘一个就是死锁 bug。
11.9Session 模型
每个用户、每个平台、每个 chat 是独立的session。Schema:
session_id = hash(platform + user_id + chat_id + thread_id)
各 session 互不知道:用户 A 在 Telegram 的对话和他在 Discord 的对话用不同 session_id, memory 不共享(除非 user_id 链接起来)。这避免泄漏。
跨平台用户识别
但用户也希望"同一个我"在多个平台被识别。Hermes 的方案:user_id 可以手动配置 alias。
config.yaml:
users:
zjw:
telegram: "@zhangsan"
discord: "zhangsan#1234"
email: "zjw@example.com"
这样不同平台来的消息可以被关联到同一个 user profile + memory。但 session_id 还是分开的——避免在 telegram 里说的 secret 漏到 discord。
11.10ACP:IDE 集成
最后一个出口:Agent Communication Protocol。这是 Zed 编辑器在 2024 年开始推的 开放协议,让外部 Agent 给 IDE 做 in-editor copilot。
Hermes 作为 ACP 后端:
hermes-acp # 启动 ACP server
Zed / VS Code(通过插件)以 stdio JSON-RPC 连接 ACP server。协议消息:
initializechat/turn— 用户发消息tool/edit_approval— 模型要修改文件,IDE 弹 diff 给用户审批session/list/session/resume
ACP 相对 MCP(第 7 章)是"外部 Agent 给 IDE 用",反过来 MCP 是"外部工具给 Agent 用"。 方向不同。但都是 Anthropic / Zed 一类标准化推动的产物。
11.112025–2026 出现的新界面形态
本章主要讲 CLI / 聊天平台 / IDE 三种"出口"。2025–2026 这一年还冒出几种新形态, 值得短暂扫一眼——你做 Agent 产品时可能要考虑其中之一。
① Cloud Workspace Agent(OpenAI Operator / Anthropic Computer Use / Devin / Manus)
Agent 不跑在你电脑上,跑在云端一个虚拟桌面里。你在网页打开一个 dashboard 看它操作。 典型例子:
- OpenAI Operator (2025.01):跑在 OpenAI 云端的 Chrome。 点击预订机票、买东西。
- Anthropic Computer Use API (2024.10):你自己提供桌面环境, API 提供"看 + 操作"能力。
- Devin (Cognition):云端的工程师沙箱——一个完整的 Linux 桌面 + IDE。
- Manus (2025.03):中国初创公司,类似 Devin 但更通用。
这种形态的界面挑战:
- Agent 跑了一小时,用户怎么"中途插话"?
- Agent 看到敏感信息(信用卡、密码),怎么打 mask?
- Agent 卡住时,怎么暴露"它在想什么"给用户看?
Hermes 没做 cloud workspace 形态——但它的 gateway + delegate_task
+ cron 组合可以模拟一部分(让 Hermes 在 VPS 上跑长任务,从手机发消息控制)。
② Voice / Realtime Agent
OpenAI 2024.10 发布 Realtime API(WebSocket + 音频双向流),让 Agent 能"听 + 说"。 到 2025–2026 这条线快速演进:
- Speech-to-speech(无中间文本,直接音频对音频)
- 低于 300ms 端到端延迟
- 语气、情感、打断都自然
对 Agent 工程的挑战:"工具调用"和"流式音频"怎么并存?模型不能在说话过程中突然停下调工具—— 得在合适的"语义边界"插入。Hermes 目前用 TTS 工具做"看完文本再合成音频"—— 和 Realtime API 这种 native 音频流是两种范式。
③ Embedded Agent(在产品里的 Agent)
不是独立 Agent 产品,而是把 Agent 嵌入现有产品。例:
- Notion AI / Linear Asks / Slack AI 等——产品内嵌的"提问 → Agent 回答"。
- Cursor / Zed / Windsurf——IDE 里的 inline AI。
- Stripe Agent SDK——金融操作型 Agent。
这种形态特点:Agent 不主导界面,是产品的一个 feature。 用户对它的期望也变了——快速、专注、不啰嗦。
④ Async Agent(异步 Agent / "Background Agent")
2026 年新趋势:Agent 不要求用户等着看它干活,而是后台跑 + 完成后通知。例:
- Cursor Background Agents:你提交个任务,几小时后看 PR。
- Devin:分配 Linear ticket 给 Devin,它自己跑。
- Hermes cron job:把 Hermes 当后台 Agent,定时跑任务、通过 Telegram 推送结果。
Hermes 的 cronjob 工具和 delegate_task(background=true) 是这个方向的早期实现。
本书第 14 章会有相关例子。
对照表
| 形态 | 典型产品 | Hermes 对应 |
|---|---|---|
| CLI | Aider / Hermes CLI | ✓ hermes 命令 |
| 聊天平台 | Hermes Gateway | ✓ 20+ adapter |
| IDE | Cursor / Zed / VS Code | ✓ ACP adapter |
| Cloud Workspace | Operator / Devin / Manus | ✗ 没做 |
| Voice Realtime | OpenAI Realtime / Sesame | ~ 走 TTS 工具不是 native |
| Embedded | Notion AI / Stripe SDK | ~ 可通过 plugin 实现部分 |
| Async Background | Cursor Background / Devin | ✓ cron + delegate_task |
11.12本章带走的
- Hermes 有三个出口:CLI、Gateway(20+ 平台)、ACP(IDE)。共用 AIAgent 核心。
- Profile 隔离用
HERMES_HOMEenv var。 关键技巧:在 import 之前注入。 - 六条 profile-safe 编码规则:永远用
get_hermes_home(),永不 hardcode。 - Slash Command Registry 集中在
hermes_cli/commands.py。 6 个消费者从这一份派生。加命令改一处。 cli_only/gateway_only/gateway_config_gate实现细粒度 routing。- PlatformAdapter 抽象类。每个平台 ~50–200 行子类。
- 双重消息守卫:base 的 pending queue + gateway runner 的 control command 直通。 新加 control 命令要同时改两处。
- Session 用
(platform, user_id, chat_id)唯一标识。跨平台用户可手动 alias。 - ACP 是 IDE 集成协议,与 MCP 互补(MCP 给 Agent 用工具,ACP 给 IDE 用 Agent)。
章末练习
-
Easy
为什么
_apply_profile_override()必须在 import 之前调用? Python 模块顶层有什么"陷阱"导致这个? -
Easy
Hermes 想加一个
/lol命令——只在 gateway 可用,发一张笑话图。 列出需要改的文件和具体改动。 - Medium 实现一个最简 PlatformAdapter,连一个"假平台"——就是从 stdin 读消息、stdout 输出。 约 80 行 Python。
-
Medium
"双重消息守卫"防止队列堵塞 control 命令。但 gateway runner 怎么知道哪些是 control?
读
hermes_cli/commands.py,找GATEWAY_KNOWN_COMMANDS, 解释它的生成机制。 -
Hard
Profile 隔离有一个边界 case:用户
cd到某项目,项目里有.hermes子目录。 Hermes 应不应该自动启用一个 project-local profile?讨论 trade-off (便利 vs. 隔离 vs. 可发现性)。 -
Hard
跨平台用户识别——同一个用户在 Telegram、Discord、Email 上的 messages 怎么并到一个 memory?
读
gateway/session.py(如果你下载了源码),看 user_id 是怎么 normalize 的。 然后讨论:anonymizing vs. cross-platform linking 的 trade-off。