Chapter 11

CLI / Gateway / Profile 架构

一个 Agent 核心,20 个外面世界的出口。Profile 隔离的"导入前" 技巧、中心化 Slash Command Registry、Platform Adapter 模式。

本章约 5,700 字 阅读 ~25 分钟 关键词:profile · COMMAND_REGISTRY · PlatformAdapter · Telegram · Discord

到目前为止我们看的是 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_HOMESESSION_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 ...
Listing 11.1 Profile 隔离的"导入前"注入

这个模式是"配置必须在代码读它之前注入"的解决方案。三步:

  1. 在 import hermes 模块之前,手动 parse argv--profile
  2. 设置 HERMES_HOME env var。
  3. 从 argv 删除 profile 参数(argparse 不认识)。
通用启示 任何 Python 项目想做"运行时切换数据目录"——比如多账户工具—— 必须在 import 之前注入 env var。Python 模块顶层是 frozen-time 的, 记住这一点能避免一类隐式 bug。

11.3Profile-Safe 代码规则

Hermes AGENTS.md 里有六条 profile-safe 规则。所有贡献者都遵守:

  1. 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"
  2. 用户消息用 display_hermes_home()
    # GOOD
    print(f"Saved to {display_hermes_home()}/config.yaml")
    # 输出 ~/.hermes/config.yaml 或 ~/.hermes/profiles/work/config.yaml
  3. 模块级常量也用 get_hermes_home()—— 它在 import 时调用, _apply_profile_override() 在更早执行。
  4. 测试要同时 mock Path.home()HERMES_HOME
    with patch.object(Path, "home", return_value=tmp_path), \
         patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
        ...
  5. Platform adapter 用 token lock。如果两个 profile 都用同一个 Telegram bot token 连上 Telegram,会冲突。通过 acquire_scoped_lock() 锁。
  6. 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 BotCommandtelegram_bot_commands() 生成 menu
Slack subcommand 路由slack_subcommand_map() 生成 dispatch
Gateway runnerGATEWAY_KNOWN_COMMANDS frozenset 用于消息守卫

加一个新命令?只改 commands.py 一处,其他自动跟上。 加一个别名?编辑 aliases tuple,一行改完。

gateway_only / cli_only 的细节

有些命令只在特定环境有意义:

gateway_config_gate:条件解锁

tools 命令——它是 cli_only=True,但又有 gateway_config_gate="display.tool_progress_command"。意思:

这种"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 不进入聊天,只设置 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 的工作:

  1. 连接平台、认证。
  2. 注册 bot 命令菜单(如果平台支持)。
  3. 把平台原生事件翻译成 MessageEvent
  4. 发送时把 Agent 输出按平台限制(4096 字节 / 2000 字节 / 不支持 markdown 等)切分/转换。
  5. 注意 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)
陷阱 AGENTS.md 警告:"Any new command that must reach the runner while the agent is blocked MUST bypass BOTH guards." 加新控制命令时必须更新两个 list: CONTROL_COMMANDSGATEWAY_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。协议消息:

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 看它操作。 典型例子:

这种形态的界面挑战

Hermes 没做 cloud workspace 形态——但它的 gateway + delegate_task + cron 组合可以模拟一部分(让 Hermes 在 VPS 上跑长任务,从手机发消息控制)。

② Voice / Realtime Agent

OpenAI 2024.10 发布 Realtime API(WebSocket + 音频双向流),让 Agent 能"听 + 说"。 到 2025–2026 这条线快速演进:

对 Agent 工程的挑战:"工具调用"和"流式音频"怎么并存?模型不能在说话过程中突然停下调工具—— 得在合适的"语义边界"插入。Hermes 目前用 TTS 工具做"看完文本再合成音频"—— 和 Realtime API 这种 native 音频流是两种范式。

③ Embedded Agent(在产品里的 Agent)

不是独立 Agent 产品,而是把 Agent 嵌入现有产品。例:

这种形态特点:Agent 不主导界面,是产品的一个 feature。 用户对它的期望也变了——快速、专注、不啰嗦。

④ Async Agent(异步 Agent / "Background Agent")

2026 年新趋势:Agent 不要求用户等着看它干活,而是后台跑 + 完成后通知。例:

Hermes 的 cronjob 工具和 delegate_taskbackground=true) 是这个方向的早期实现。 本书第 14 章会有相关例子。

对照表

形态典型产品Hermes 对应
CLIAider / Hermes CLI✓ hermes 命令
聊天平台Hermes Gateway✓ 20+ adapter
IDECursor / Zed / VS Code✓ ACP adapter
Cloud WorkspaceOperator / Devin / Manus✗ 没做
Voice RealtimeOpenAI Realtime / Sesame~ 走 TTS 工具不是 native
EmbeddedNotion AI / Stripe SDK~ 可通过 plugin 实现部分
Async BackgroundCursor Background / Devin✓ cron + delegate_task

11.12本章带走的

章末练习

  1. Easy 为什么 _apply_profile_override() 必须在 import 之前调用? Python 模块顶层有什么"陷阱"导致这个?
  2. Easy Hermes 想加一个 /lol 命令——只在 gateway 可用,发一张笑话图。 列出需要改的文件和具体改动。
  3. Medium 实现一个最简 PlatformAdapter,连一个"假平台"——就是从 stdin 读消息、stdout 输出。 约 80 行 Python。
  4. Medium "双重消息守卫"防止队列堵塞 control 命令。但 gateway runner 怎么知道哪些是 control? 读 hermes_cli/commands.py,找 GATEWAY_KNOWN_COMMANDS, 解释它的生成机制。
  5. Hard Profile 隔离有一个边界 case:用户 cd 到某项目,项目里有 .hermes 子目录。 Hermes 应不应该自动启用一个 project-local profile?讨论 trade-off (便利 vs. 隔离 vs. 可发现性)。
  6. Hard 跨平台用户识别——同一个用户在 Telegram、Discord、Email 上的 messages 怎么并到一个 memory? 读 gateway/session.py(如果你下载了源码),看 user_id 是怎么 normalize 的。 然后讨论:anonymizing vs. cross-platform linking 的 trade-off。