KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
01 · 定位与七种运行宿主 — keel 龙骨
Hermes Agent 0.20.4 出自 Nous Research,是一个纯 Python 的 agent 内核,外面套着 CLI、消息网关、HTTP API、编辑器协议、定时器、批处理与桌面 GUI 共七张壳。它的自我定位写得很直白——「self-improving AI agent」,卖点不是「能调工具」,而是「会自己长出技能」。
Hermes Agent 0.20.4 出自 Nous Research,是一个纯 Python 的 agent 内核,外面套着 CLI、消息网关、HTTP API、编辑器协议、定时器、批处理与桌面 GUI 共七张壳。它的自我定位写得很直白——「self-improving AI agent」,卖点不是「能调工具」,而是「会自己长出技能」。
这一章先把「它自称是什么」和「它实际长什么样」对齐,然后回答三个问题:
- 顶层目录被切成了哪几层,每一层的职责边界在哪;
hermes这一个命令如何扇出到 50+ 子命令与七种宿主;- 容器镜像里为什么不是「一个进程」,而是一棵 s6-overlay 监督树。
阅读约定:本章所有路径都相对于仓库根。注意本机这份源码的实际根目录是
hermes-agent/hermes-agent-new/(外层hermes-agent/只是一个不完整的父目录),下文一律省略该前缀。
一、它自称是什么
pyproject.toml:6 的 description 是完整的一句话定位:
version = "0.20.4"
description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere"
authors = [{ name = "Nous Research" }]
README.md:19 把这句拆成五个可验证的能力声明:内置学习闭环(built-in learning loop)、使用中自我改进、主动 nudge 自己沉淀知识、检索自己过去的会话、跨会话建立对你的模型。同一段还给了部署姿态:$5 VPS、GPU 集群,或者闲置时几乎不花钱的 serverless;「不绑在你的笔记本上——它在云 VM 上干活时你可以从 Telegram 跟它对话」。
README.md:26 的表格行是这几条声明对应的实现清单:FTS5 会话检索 + LLM 摘要、Honcho 式辩证法(dialectic)用户建模、兼容 agentskills.io 开放标准。这三项在第 04 章展开。
需要当心一个易混点:README.md:29 列的「Seven terminal backends」(local / Docker / SSH / Singularity / Modal / Daytona / Vercel Sandbox)说的是终端后端,不是本章要讲的运行宿主。前者是「shell 命令在哪执行」,后者是「整个 agent 挂在什么进程模型上」。二者数量都常被凑成「七」,但所指完全不同。
二、顶层目录的三层切分
顶层目录可以按「内核 / 宿主 / 扩展」三层理解:
- 内核:
run_agent.py(class AIAgent,run_agent.py:412)、agent/(近 200 个模块,主循环、上下文、压缩、记忆、技能全在这里)、model_tools.py(工具 schema 与调用分发)、toolsets.py(工具集清单)、hermes_state.py(SQLite 会话库,SessionDB、AsyncSessionDB)、tools/(工具实现 + 注册表)。 - 宿主:
cli.py(HermesCLI)、hermes_cli/(hermes命令的全部子命令)、gateway/(消息网关,含run.py与platforms/)、acp_adapter/(编辑器协议)、cron/(定时调度)、batch_runner.py(离线批处理)、tui_gateway/+ui-tui/(TUI 前后端)、apps/(desktop/shared/bootstrap-installer三个前端包)。 - 扩展:
plugins/(memory/model-providers/platforms/observability…)、skills/(内置技能)、optional-skills/(默认不激活的重技能)、docker/(镜像与 s6 服务定义)、native/(如fts5_cjk原生扩展)。
关键点:扩展不是可选的装饰,而是主路径。36 个模型供应商、22 个渠道全部以插件形式存在(第 05 章),内核只提供协议与注册表。AGENTS.md:73 把这条讲成一句能力梯度:CLI command + skill → service-gated tool(check_fn)→ plugin → MCP server。
三个根级文件需要单独认位置,因为它们各自被不同层引用:
model_tools.py:工具 schema 的组装与调用分发。model_tools.py:323: def get_tool_definitions(...)给模型拼工具清单,:1192: def handle_function_call(...)处理模型返回的调用;tools/registry.py的异步桥接也反向 import 它(tools/registry.py:1123: from model_tools import _run_async)——这是一个双向依赖,说明model_tools与tools/registry是同一层拆开的两半。cli.py:HermesCLI,交互式 CLI 的实现体;hermes_cli/则是命令入口与全部子命令。两者名字相近但职责不同:前者是「一次会话怎么跑」,后者是「hermes这个命令怎么解析」。toolsets.py:只是分组表(_HERMES_CORE_TOOLS、TOOLSETS),不含任何工具实现——工具定义散在tools/各文件的模块级registry.register(...)里(第 03 章)。
三、入口:hermes 与 50+ 子命令
pyproject.toml:372 声明了三个 console script:
[project.scripts]
hermes = "hermes_cli.main:main"
hermes-agent = "run_agent:main"
hermes-acp = "acp_adapter.entry:main"
hermes 是主入口。hermes_cli/main.py:11433 的 _BUILTIN_SUBCOMMANDS 是一个 frozenset,一次性列出全部内置子命令:acp / approvals / auth / backup / chat / config / console / cron / curator / dashboard / serve / debug / doctor / egress / gateway / gui / hooks / kanban / mcp / memory / model / plugins / profile / project / proxy / resume / send / sessions / setup / skills / status / sync / tools / update / webhook / whatsapp-cloud / secrets / security / verify …共 70 余项。
这份集合有一个非显然的用途:_plugin_cli_discovery_needed()(hermes_cli/main.py:11519)用它做快速路径。若首个位置参数命中内置集合,就跳过插件发现——省掉约 500–650 ms 的 argparse 期开销;注释写明了代价:「插件命令不出现在顶层 --help 里,是可以接受的交换」。
子命令的解析器实现集中在 hermes_cli/subcommands/,共 47 个 .py(45 个子命令模块,另有 __init__.py 与 _shared.py),命名统一为 build_<group>_parser,例如 hermes_cli/subcommands/skills.py:12: def build_skills_parser(subparsers, *, cmd_skills)。
四、七种宿主
同一个 AIAgent 被七种不同的进程模型承载。按「谁拥有主循环、谁来喂 user message」区分:
- 交互 CLI / TUI。
hermes_cli.main:main()。是否进 TUI 由_wants_tui_early()(hermes_cli/main.py:311)在 argparse 之前判断,main.py:352据此分流。会话续跑hermes -c/--continue由_resolve_continue_arg()(hermes_cli/main.py:1823)解析。 - 消息网关。
gateway/run.py:30576: def main()启动,核心是gateway/run.py:6522: class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, GatewaySlashCommandsMixin)——多继承已经把「鉴权 / 看板观察 / 斜杠命令」三块拆开。 - HTTP API。
hermes serve起一个 OpenAI 兼容服务,实现是gateway/platforms/api_server.py:1352: class APIServerAdapter(BasePlatformAdapter);文件头注释写「OpenAI-compatible API server platform adapter」,可接 Open WebUI / LobeChat / LibreChat,鉴权用API_SERVER_KEY。 - ACP 适配器。
acp_adapter/entry.py:220: main();它强制「stdout 专供 JSON-RPC、日志走 stderr」(_setup_logging()把 root handler 挂到sys.stderr并套RedactingFormatter),最终交给acp_adapter/server.py:566: class HermesACPAgent(acp.Agent)。 - cron。
cron/scheduler.py,配合cron/jobs.py/cron/executions.py/cron/monitor.py构成一套带执行记录与生命周期守卫的调度器。 - 批处理。
batch_runner.py:1156: def main()+batch_runner.py:529: class BatchRunner,用于轨迹生成与离线评测。 - dashboard / kanban dispatcher。
hermes dashboard子命令(hermes_cli/subcommands/dashboard.py)+gateway/kanban_watchers.py,供 GUI 与多 agent 看板协作使用。这一项与其它六种的「独立宿主」性质略有差别——它更接近「网关的一个附加角色」(推断)。
五、容器与 s6 多进程监督
docker-compose.yml 定义两个 service,都跑同一个镜像 hermes-agent:
services:
gateway:
image: hermes-agent
network_mode: host
volumes:
- ~/.hermes:/opt/data
command: ["gateway", "run"]
dashboard:
image: hermes-agent
network_mode: host
command: ["dashboard", "--host", "127.0.0.1", "--no-open"]
三个细节值得记:network_mode: host(不是端口映射,容器直接共享宿主网络栈);~/.hermes 挂到 /opt/data(宿主配置与容器内数据是同一份);dashboard 默认只绑 127.0.0.1,注释明确写了「它存 API key,无鉴权暴露到 LAN 是不安全的,请走 SSH 隧道或加反代,不要 --insecure --host 0.0.0.0」。
多进程监督来自 s6-overlay 3.2.3.0(Dockerfile:108)。Dockerfile:456 的 ENTRYPOINT [ "/opt/hermes/docker/entrypoint-dispatch.sh" ] 是一个小派发器,/init(s6-overlay 的 PID 1 = s6-svscan)才是真正的监督根。服务定义在 docker/s6-rc.d/:main-hermes 与 dashboard 两个服务目录,各自带 run / type / dependencies.d/base。docker-compose.yml:9 的注释解释了 stage2 hook 的职责:按 HERMES_UID / HERMES_GID 用 usermod/groupmod 重映射内部 hermes 用户,随后每个受监督服务用 s6-setuidgid 掉权运行。
六、TUI 为什么拆成前后端
TUI 宿主是唯一一个被拆成两个顶层目录的:tui_gateway/(Python 侧)与 ui-tui/(前端侧)。tui_gateway/ 有 26 个模块,形状是一个 RPC 服务:server.py 是入口,methods_prompt.py / methods_session.py / methods_tools.py / methods_complete.py / methods_images.py / methods_config.py / methods_profiles.py 是按域切分的方法实现,transport.py 与 ws.py 负责传输,event_publisher.py 负责把内核回调转成前端事件,slash_worker.py / slash_fuzzy.py 处理斜杠命令与其模糊匹配。
ui-tui/ 侧是独立的 npm 包(ui-tui/package.json),并带一个自研渲染包 ui-tui/packages/hermes-ink/package.json——「ink」这个名字表明它是终端 React 风格渲染器。
拆开的原因在 AGENTS.md:537 里有一段旁证:tui_gateway/server.py 同时提供 commands.catalog(空查询时的命令清单)与 complete.slash(输入时的补全),两者都包含内置命令、用户 quick_commands、以及技能派生的命令(scan_skill_commands() / get_skill_commands());注释明确写「The desktop app does not need a new RPC to see skills」——也就是说这套 RPC 同时服务 TUI 与桌面 app,把「命令从哪来」收敛到后端一处。
代码地图
| 机制 | 位置 | 要点 |
|---|---|---|
| 自我定位 | pyproject.toml: description |
第 6 行;"self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere" |
| 版本与作者 | pyproject.toml: version |
0.20.4;Nous Research;requires-python = ">=3.11,<3.14" |
| 学习闭环卖点 | README.md 第 19 / 26 行 |
跨会话用户建模、FTS5 会话检索、Honcho dialectic、agentskills.io 兼容 |
| 内核入口类 | run_agent.py: AIAgent |
第 412 行;主循环与工具执行都在此类的实例上 |
| 主入口 | pyproject.toml: [project.scripts] |
hermes = hermes_cli.main:main;另有 hermes-agent、hermes-acp |
| 子命令全集 | hermes_cli/main.py: _BUILTIN_SUBCOMMANDS |
第 11433 行;70 余项 frozenset,main.py:11530 用它跳过插件发现 |
| 子命令解析器 | hermes_cli/subcommands/skills.py: build_skills_parser |
第 12 行;subcommands/ 共 47 个 .py,统一 build_<group>_parser 命名 |
| TUI 早分流 | hermes_cli/main.py: _wants_tui_early |
第 311 行;argparse 之前判断是否走 TUI |
| 会话续跑参数 | hermes_cli/main.py: _resolve_continue_arg |
第 1823 行;-c / --continue 支持可选会话名 |
| 网关宿主 | gateway/run.py: GatewayRunner |
第 6522 行;三 mixin 组合鉴权 / 看板 / 斜杠命令;main() 在第 30576 行 |
| HTTP 宿主 | gateway/platforms/api_server.py: APIServerAdapter |
第 1352 行;OpenAI 兼容,API_SERVER_KEY 鉴权 |
| ACP 宿主 | acp_adapter/entry.py: main |
第 220 行;stdout 留给 JSON-RPC,日志强制走 stderr |
| ACP Agent | acp_adapter/server.py: HermesACPAgent |
第 566 行;继承 acp.Agent |
| 批处理宿主 | batch_runner.py: BatchRunner |
第 529 行;main() 在第 1156 行 |
| 定时宿主 | cron/scheduler.py |
配合 jobs.py / executions.py / monitor.py / lifecycle_guard.py |
| 工具清单组装 | model_tools.py: get_tool_definitions |
第 323 行;调用分发 handle_function_call 在同文件 :1192 |
| TUI 后端 RPC | tui_gateway/server.py |
commands.catalog 与 complete.slash 两个方法(见 AGENTS.md:537),同时服务 TUI 与桌面 app |
| TUI 前端 | ui-tui/package.json |
独立 npm 包,含渲染包 ui-tui/packages/hermes-ink/package.json |
| 容器编排 | docker-compose.yml: services |
gateway 与 dashboard 两 service;network_mode: host;~/.hermes:/opt/data |
| 多进程监督 | Dockerfile: ENTRYPOINT |
第 456 行;派发到 /init(s6-overlay PID 1),服务定义见 docker/s6-rc.d/ |
关键取舍
把 provider 与 channel 全部外置成插件,代价是启动期要做发现与注册。
内核只认识协议(ProviderProfile、BasePlatformAdapter)和注册表(_REGISTRY、PlatformRegistry),36 个 provider / 22 个渠道都是插件。收益是新增一家供应商不需要改内核;代价是「有哪些能力可用」在静态阅读时看不出来,必须跑一次发现流程,且 CLI 启动为此引入了可观开销——这正是 _plugin_cli_discovery_needed() 需要存在的原因。
用 frozenset 硬编码内置子命令来换启动速度,代价是「插件命令不出现在 --help」。_BUILTIN_SUBCOMMANDS 是手写清单,与 subcommands/ 目录存在耦合:新增内置子命令若忘了登记,最坏后果是每次调用都多做一次插件发现(注释明确说这是「correctness-safe」方向)。这是一个用可维护性换冷启动延迟的典型取舍。
容器用 host 网络而不是端口映射,代价是隔离面变小。network_mode: host 让容器与宿主共享网络命名空间——省掉了端口映射与 NAT,也让 gateway 能直接监听平台回调端口;但容器内的网络策略此时约等于宿主策略,无法靠 Docker 网络规则约束 egress。文件里对 dashboard 的反复告警(只绑 127.0.0.1、走 SSH 隧道)正是对这个松弛面的补偿。
镜像用 s6-overlay 而不是「一个 entrypoint + 一个进程」,代价是启动链变长。/init → stage2 hook(改 UID/GID、reconcile profile、决定 dashboard 是否启用)→ s6-svscan → 各服务 run 脚本。任何绕过 /init 的 entrypoint 覆盖都会跳过全部 cont-init 准备,gateway 直接不可用——docker-compose.yml:18 把这条写成了硬约束。换来的是 gateway、dashboard、per-profile gateway 能在同一个容器里被独立重启与监督。
外层目录与仓库根分离,代价是路径引用必须小心。
本机这份源码真实根是 hermes-agent/hermes-agent-new/,而 hermes-agent/ 自身含有 gateway/、cron/、hermes_state.py 等条目但缺少 agent/ 与 run_agent.py。所有「路径:行号」引用都以 hermes-agent-new/ 为基准解析,否则会指向不存在或错位的文件。
自测题
README.md说的「七个终端后端」和本章说的「七种运行宿主」为什么不是同一回事?请各举两个例子,并说明如果一个 agent 同时有多个宿主,终端后端应当属于宿主层还是内核层。_wants_tui_early()在 argparse 之前运行,而_plugin_cli_discovery_needed()在 argparse 期运行。请说明为什么这两件事不能合并成一次判断,以及「插件命令不进 --help」这个代价具体体现在哪个调用路径上。docker-compose.yml里 gateway 用command: ["gateway","run"]而 dashboard 用["dashboard","--host","127.0.0.1","--no-open"]。如果不小心把ENTRYPOINT覆盖成["gateway","run"],容器会怎样失败?请从/init的职责推断。- 本章列出的第 7 种宿主(dashboard / kanban dispatcher)为什么被标注为「推断」?请从代码结构上找一个理由说明它和其它六种的差异。
- 内核把 provider 与渠道外置成插件。请指出这种设计在「排查『某个能力为什么没生效』」时带来的具体困难,并说明
check_fn/requires_env这类声明在这个问题上的作用。