Hermes Agent 源码专题【左扬精讲】—— 开篇总览
Hermes Agent 源码专题【左扬精讲】— 开篇总览
这是 Hermes Agent 源码专题【左扬精讲】系列的 第 1 篇 / 共 40 篇。本篇是 Layer 框架(架构 / 拓扑 / 平台分发场景)。
本篇不是把整个仓库讲一遍,而是把读者第一次接触 Hermes 源码时 最该看到的地图 画出来:当你敲下 hermes、hermes gateway、hermes serve 时,代码到底从哪里跑、怎么把 6 个核心模块拼到一起,以及这个 6 部分 / 40 篇的系列 为什么要这样拆。
Layer 框架的视角是:先看分层(Layer),再看路由(哪条入口走哪条链路),最后看边界(避免 god-file)。
Layer 0 ─ 进程与字符集层
hermes_bootstrap.py ← Windows UTF-8 脚手架(必须在所有 import 之前)
Layer 1 ─ 入口启动层
hermes_cli/main.py ← main():argv 解析 → profile 覆盖 → 子命令分发
cli.py ← HermesCLI 类(CLI 线,2 个 Mixin)
gateway/run.py ← GatewayRunner 类(gateway 线,3 个 Mixin)
batch_runner.py ← 批量并行入口(跳过 UI 层)
Layer 2 ─ 核心循环层
run_agent.py ← AIAgent 类 ─ 三条入口线共用的核心对话循环
model_tools.py ← handle_function_call 工具分发的唯一入口
toolsets.py ← _HERMES_CORE_TOOLS 默认工具集 + TOOLSETS
Layer 3 ─ 工具实现层(自注册)
tools/registry.py ← 全局注册表
tools/*.py ← 工具实现(import 时自调用 registry.register())
Layer 4 ─ 插件与扩展层(与 Layer 3 并列)
hermes_cli/plugins.py ← PluginManager ─ 钩子 + 工具 + CLI 子命令
~/.hermes/plugins/* ← 用户级 / pip entry point 插件
Layer 拓扑 入口启动 模块边界 HermesCLI GatewayRunner Profile 工具自注册 系列地图
本篇学习重点
必须掌握
- 理解 5 个公开入口(hermes / hermes gateway / hermes serve / hermes-acp / hermes-agent)共用同一条 Layer 1 → Layer 2 的启动顺序
- 理解 hermes_bootstrap 为什么必须在所有 import 之前执行
- 理解 _apply_profile_override 为什么必须在入口最早期覆盖 HERMES_HOME
- 理解"注册表(存在性)vs 工具集(暴露子集)"的双层语义差
需要了解
- 40 篇系列的 6 部分划分与三条推荐阅读路径
- Core 工具 vs Plugin 工具的两套注册系统
一、Layer 1 ─ 入口启动流程:从 hermes_bootstrap 到 AIAgent
Layer 视角 ─ 这一层解决什么?
Hermes 共有 5 个公开入口(hermes / hermes gateway / hermes serve / hermes-acp / hermes-agent)。它们看起来五花八门,但走的是同一条 Layer 1 启动顺序:
- 子层 0 ─ 字符集脚手架:hermes_bootstrap.apply_windows_utf8_bootstrap()(仅 Windows)
- 子层 1 ─ 入口主函数:hermes_cli/main.py::main()(argv 解析 + profile 覆盖 + 子命令分发)
- 子层 2 ─ 入口类:HermesCLI(CLI 线)/ GatewayRunner(gateway 线)/ batch_runner(批处理线)
这一层的关键约束:每个子层都对调用顺序敏感 — 启动顺序写错就立刻在 Windows 或多 profile 下崩。
1.1 子层 0 ─ hermes_bootstrap:Windows UTF-8 脚手架
看 hermes_bootstrap.py 顶部 module docstring:
源码视角 ─ apply_windows_utf8_bootstrap 的最小骨架
看 hermes_bootstrap.py 第 50-65 行:
from __future__ import annotations
import os
import sys
_IS_WINDOWS = sys.platform == "win32"
_bootstrap_applied = False
def apply_windows_utf8_bootstrap() -> bool:
"""Apply the Windows UTF-8 bootstrap if we're on Windows."""
global _bootstrap_applied
if _bootstrap_applied:
return False
_bootstrap_applied = True
if not _IS_WINDOWS:
return False # POSIX 不动
os.environ["PYTHONUTF8"] = "1" # PEP 540 UTF-8 模式
os.environ["PYTHONIOENCODING"] = "utf-8" # 子进程也吃 UTF-8
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8") # 父进程 stdio 重绑
except Exception:
pass
return True
关键路由点:
- apply_windows_utf8_bootstrap() 必须在任何 import 之前调用 — 否则子模块 import 时已经触发 sys.stdout 写出,cp1252 编码已经定死
- 它通过设置 PYTHONUTF8 让所有 spawn 出去的 Python 子进程(execute_code sandbox、delegate_task child、linter)也都用 UTF-8
- _bootstrap_applied 标志保证幂等 — 子代理或 gateway 内部 import 重复调用不会重复 reconfigure
- POSIX 上整个函数 return False,LANG / LC_* 不动 — 不覆盖用户配置
What-if ─ 删掉 hermes_bootstrap 会发生什么?
① 删 apply_windows_utf8_bootstrap():Windows cmd / PowerShell 下 print("café") 立刻抛 UnicodeEncodeError: 'charmap' codec can't encode character '\xe9',所有带非 ASCII 输出的工具(中文路径、Emoji、希腊字母)全部 500。
② 改顺序:把它移到 from hermes_state import SessionDB 之后 — hermes_state.py 模块级 DEFAULT_DB_PATH = get_hermes_home() / "state.db" 已经在 import 时跑过,profile 还没覆盖,整个子进程拿到错路径。
③ 真实场景:Hermes 项目在 Windows CI 上跑测试时,本地 WSL 没事但 CI runner 报错 — 根因就是少调了一次 bootstrap(GitHub Actions 默认的 windows-latest 用 cp1252 stdout,不挂 bootstrap 任何 print() 非 ASCII 都会挂)。
1.2 子层 1 ─ hermes_cli/main.py::main()
所有 hermes <sub> 都最终走 hermes_cli/main.py 的 main()。它做几件关键的事:
源码视角 ─ main 函数的真实入口骨架
看 hermes_cli/main.py main() 函数:
def main():
"""Main entry point for hermes CLI."""
# Cosmetic: make the process show up as 'hermes' instead of 'python3.11'
# in ps/top/htop. Non-fatal — just a nicer UX.
_set_process_title()
# Force UTF-8 stdio on Windows before anything prints. No-op elsewhere.
try:
from hermes_cli.stdio import configure_windows_stdio
configure_windows_stdio()
except Exception:
pass
# Sweep stale hermes.exe.old.* quarantine files left by previous
# hermes update runs on Windows. Silent no-op on non-Windows or when
# there's nothing to clean.
try:
_cleanup_quarantined_exes()
except Exception:
pass
入口函数的关键步骤:
- 进程名伪装:让 ps / top 显示为 hermes 而非 python
- Windows UTF-8 stdio:在打印前强制 UTF-8 stdio,避免 cp1252 编码错误
- 清理隔离文件:Windows 上 hermes update 留下的 quarantine 文件
1.3 子层 2 ─ HermesCLI 类(CLI 线)
源码视角 ─ HermesCLI 由两个 Mixin 组合
看 cli.py:
class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
"""交互式 CLI 编排器。代理 init + 工具装载 + 命令分发 + 流式渲染。"""
关键边界:
- Mixin 组合:CLIAgentSetupMixin 管 AIAgent 初始化、credential pool 装载、皮肤初始化;CLICommandsMixin 管所有 /<cmd> 分发
- HermesCLI 不直接构造 — 由 hermes_cli/main.py 的内部函数包装后调用
1.4 子层 2 ─ GatewayRunner 类(gateway 线)
源码视角 ─ GatewayRunner 由三个 Mixin 组合
看 gateway/run.py:
class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, GatewaySlashCommandsMixin):
"""消息网关主类:消息接收 + 平台分发 + 流式回传 + agent 循环。"""
三个 Mixin 的职责:
- GatewayAuthorizationMixin:pairing store、slash 访问策略、跨平台鉴权
- GatewayKanbanWatchersMixin:kanban dispatcher 在 gateway 内部运行时的 watcher
- GatewaySlashCommandsMixin:/help、/model、/queue 等消息平台专属命令
1.5 子层 2 ─ batch_runner:并行入口
batch_runner.py 是 Hermes 的批处理入口 — 一组 prompts 并行跑多个 AIAgent 实例。它和 CLI / gateway 共用 run_agent.py 的 AIAgent,但跳过 HermesCLI 的渲染层。这是三条入口线在核心层汇合的关键证据 — 下一节展开。
Lesson读 Layer 1 的顺序就是读入口的顺序 — bootstrap → profile 覆盖 → main() → CLI/Gateway/batch 分发。任何想"加一个新入口"的 PR 都必须遵守这套启动顺序,否则在 Windows 或多 profile 下静默失败。判断标准:能在 5 秒内说出"为什么这一步在 import 之前 / 之后"。
本节小结
- 5 个公开入口共用同一条 Layer 1 启动顺序:bootstrap → profile 覆盖 → 子命令分发
- HermesCLI(CLI 线)和 GatewayRunner(gateway 线)都是 Mixin 组合,符合 god-file 重构准则
- batch_runner 跳过 UI 层直跑核心循环,是三条线汇合的证据
二、Layer 2 ─ 模块拓扑:三条入口线如何共用 run_agent.py
Layer 视角 ─ 这一层是入口层之下的核心
Hermes 表面上有几十个入口文件(CLI / gateway / batch / TUI / desktop / dashboard),但 Layer 2(核心循环层)只有 3 个文件:
- run_agent.py(对话循环)
- model_tools.py(工具编排)
- toolsets.py(工具集定义)
Layer 1 的 5 个入口全部最终落到 Layer 2 这 3 个文件。理解谁 import 谁,等于理解了 80% 的依赖链。Layer 2 与 Layer 3 的边界:Layer 2 决定"调什么工具",Layer 3(tools/*.py)决定"工具怎么实现"。
2.1 核心依赖链(严格单向)
Layer 3 ─ tools/registry.py (no deps ─ 被所有 tools/*.py import)
↑
Layer 3 ─ tools/*.py (each calls registry.register() at import time)
↑
Layer 2 ─ model_tools.py (imports tools/registry + triggers tool discovery)
↑
Layer 2 ─ run_agent.py, cli.py, batch_runner.py, tools/environments/*
↑
Layer 1 ─ hermes_cli/main.py / gateway/run.py(入口启动)
这个依赖链是严格单向的 — 没有反向依赖,没有循环引用。Layer 3 不准 import Layer 2,这是 Hermes 启动稳定性的基础 — 一旦允许反向,tools/*.py 的 import 时机会跟 AIAgent 构造耦合,加载顺序一改就崩。
2.2 三条入口线在 run_agent.py 汇合
源码视角 ─ AIAgent 类签名
看 run_agent.py 中 class AIAgent:,__init__ 签名包含数十个参数(节选主要参数):
class AIAgent:
def __init__(
self,
base_url: str = None,
api_key: str = None,
provider: str = None,
api_mode: str = None,
model: str = "",
max_iterations: int = 90,
enabled_toolsets: List[str] = None,
disabled_toolsets: List[str] = None,
quiet_mode: bool = False,
save_trajectories: bool = False,
platform: str = None,
session_id: str = None,
skip_context_files: bool = False,
skip_memory: bool = False,
credential_pool=None,
# ... plus callbacks, thread/user/chat IDs, iteration_budget,
# fallback_model, checkpoints config, prefill_messages,
# service_tier, reasoning_config, etc.
):
...
def chat(self, message: str) -> str:
"""Simple interface — returns final response string."""
def run_conversation(self, user_message: str, system_message: str = None,
conversation_history: list = None, task_id: str = None) -> dict:
"""Full interface — returns dict with final_response + messages."""
关键设计点:
- 构造签名巨大:所有上下文必须在构造时注入,避免后续异步修改 — 体现 AGENTS.md "Per-conversation prompt caching is sacred" 准则
- 平台入口有两个方法:chat() 给 CLI / TUI 这种简单场景用,run_conversation() 给 gateway / batch 这种需要拿到完整消息历史的场景用
2.3 model_tools.py 与 handle_function_call
model_tools.py 是模型层和工具层之间的唯一接口。关键函数:
源码视角 ─ handle_function_call 的全局唯一性
handle_function_call 是工具分发的核心 — 所有工具调用都从这里路由。它配合 discover_builtin_tools()(在 tools/registry.py 中定义),后者在 model_tools.py 顶层被调用一次,遍历 tools/*.py 触发各工具自己的 registry.register():
# Simplified signature — real function has ~20 kwargs for hooks, sessions, etc.
def handle_function_call(function_name: str, function_args: Dict[str, Any],
task_id: Optional[str] = None, ...) -> str:
"""模型层发来一次 tool_call,分发到对应 handler,返回 JSON 字符串。"""
def get_tool_definitions(enabled_toolsets=None, disabled_toolsets=None,
quiet_mode=False, ...) -> List[Dict[str, Any]]:
"""收集当前 session 启用的所有工具 schema,供发给模型。"""
为什么这个唯一性至关重要:
- CLI / gateway / TUI / batch 在调用 AIAgent.run_conversation() 时,工具分发走同一个 handle_function_call,行为完全一致
- 任何新工具(注册后)立即对所有入口生效 — 不需要逐个去 CLI / gateway / TUI 添加适配
- discover_builtin_tools() 的调用时序是核心:必须在 AIAgent 构造之前 import 一次
2.4 toolsets.py ─ 平台分发的入口
每个平台(TG / Discord / Slack / CLI / TUI / batch)从 _HERMES_CORE_TOOLS 继承默认工具集,再加自己的平台专属工具。看 toolsets.py:
源码视角 ─ _HERMES_CORE_TOOLS 是默认继承点
看 toolsets.py 顶部(_HERMES_CORE_TOOLS):
# _HERMES_CORE_TOOLS 是 NOT dead code ─ 它是每个平台基类工具集的默认继承点
# 所有平台 adapter 都从这里继承基础工具,再加自己的专属工具。
_HERMES_CORE_TOOLS = [
"terminal", "file_operations", "patch", "search_files", "read_file",
"todo", "web_search", "web_extract", "skill_manage", "delegate_task",
# ...
]
TOOLSETS = {
"browser": [...],
"code_execution": [...],
"messaging": [...],
"TG": [...], # 平台专属 ─ 继承 _HERMES_CORE_TOOLS
# ...
}
双层结构:
- _HERMES_CORE_TOOLS:默认继承点,每个平台基类工具集从这里继承
- TOOLSETS:具体工具集定义;同名工具集的"继承"通过代码显式合并
- 用户用 hermes tools 或 config.yaml 的 tools.<platform>.enabled 覆盖
What-if ─ 把 20 个平台适配器合并成 if-elif 大表会发生什么?
① 改 1 行:把 20 个平台的事件分发写成 if platform == "TG": ... elif platform == "discord": ... 大表 — 立刻多出 4~5 个真实 bug:TG 和 Signal 的 typing indicator 时序不同、Slack 的 thread_ts 处理、Discord 的 ephemeral flag、BlueBubbles 的 iMessage guid。这 4 类差异每一类都是 100~300 行代码,挤在一个 dispatch 表里没法做 unit test。
② 删 ABC:删 gateway/platforms/base.py 的 BasePlatformAdapter ABC — 每个 adapter 自己实现 send_message / edit_message / start_typing — 然后某个新平台作者忘了实现 start_typing,gateway 调度时 AttributeError 在事件循环里崩。
③ 真实案例:Hermes 在 20+ 平台维护中,第 22 篇专门讲 ABC 的强制约束 — 没有 ABC 时 issue 修复散落在每个 adapter 里,bug 反复在三个不同平台上重现。
Lesson判断"是否需要 Layer"的金标准:同一逻辑被 3 个以上调用方复用。handle_function_call 是 Layer 2 的窄腰(narrow waist),所有入口穿过它;registry.register() 是 Layer 3 的窄腰,所有工具通过它进入系统。判断新代码该放哪一层:看它"被多少调用方消费"。1 个调用方 = 进调用方;3+ 个调用方 = 升 Layer。
本节小结
- 核心依赖链严格单向:Layer 3 tools/registry.py → Layer 3 tools/*.py → Layer 2 model_tools.py → Layer 2 run_agent.py / cli.py
- handle_function_call 是工具分发的唯一入口,CLI / gateway / TUI / batch 全走它
- _HERMES_CORE_TOOLS 是平台分发的默认继承点,TOOLSETS 是具体平台工具集定义
三、Layer 1.5 ─ Profile 覆盖机制:_apply_profile_override 的时序要求
Layer 视角 ─ 横切所有层的配置层
Profile 是 Hermes 的多实例隔离机制:每个 profile 有独立的 HERMES_HOME,进而独立的 config.yaml、auth.json、sessions.db、skills、logs。Profile 不是一个独立 Layer — 它是 Layer 0~Layer 3 都要遵守的横切约束。把它放在 Layer 1.5(入口层之"下"、核心层之"上")是因为它必须在所有 import 之前跑。
3.1 _apply_profile_override 的实现
源码视角 ─ profile 覆盖的 argv 解析(语义描述)
实际函数 _apply_profile_override() 定义在 hermes_cli/main.py,调用在所有 import 完成后的模块顶层。它做的事:
- 第一步:pre-parse --profile / -p ─ 在 argparse 之前手动扫 sys.argv;扫到后从 sys.argv 里直接摘除(不让 argparse 看到);找不到就 fallback 读 ~/.hermes/active_profile(sticky default)。
- 第二步:覆盖 HERMES_HOME ─ 设置成对应 profile 的目录,并把名字写进 HERMES_PROFILE。
模块顶层调用(不是 main() 内)确保 HERMES_HOME 在任何模块级 import 完成之前就位。
3.2 模块级常量的陷阱
看 hermes_state.py 顶部:
源码视角 ─ DEFAULT_DB_PATH 在 import 时就固定
看 hermes_state.py 模块顶部:
from hermes_constants import get_hermes_home
# 模块级常量:import 时调一次 get_hermes_home(),后续所有使用都引用这个固定值
DEFAULT_DB_PATH = get_hermes_home() / "state.db"
陷阱:
- DEFAULT_DB_PATH 在 hermes_state.py import 时就被固定 — 此时如果 HERMES_HOME 还没被 profile 覆盖,路径就是默认 profile 的
- 所以 hermes_state.py 必须在 _apply_profile_override 之后import — 这就是为什么 main.py 顶部有 import 顺序约束
- 如果直接 import hermes_state 而 _apply_profile_override 还没跑,DEFAULT_DB_PATH 就是错的,永远不会自动重读
3.3 多 Profile 的边界:双锚点原则
源码视角 ─ _get_profiles_root 锚定在 HOME 而非 HERMES_HOME
看 hermes_cli/profiles.py 的 _get_profiles_root:
def _get_profiles_root() -> Path:
"""Return the directory where named profiles are stored.
Anchored to the hermes root, NOT to the current HERMES_HOME
(which may itself be a profile). This ensures ``coder profile list``
can see all profiles.
In Docker/custom deployments where HERMES_HOME points outside
``~/.hermes``, profiles live under ``HERMES_HOME/profiles/`` so
they persist on the mounted volume.
"""
return _get_default_hermes_home() / "profiles"
为什么这么设计:
- profile 列表是跨实例的 — 即便当前在 coder profile 里跑 profile list,也要看到 default / coder / writer 全部
- _get_profiles_root 走 _get_default_hermes_home(),不直接 Path.home() / ".hermes" ── 后者会让 Docker/CI 部署完全失效(HERMES_HOME 被改指到 /opt/data 时找不到 profiles)
- HOME-anchored 让 profile 操作成为全局视图,而 HERMES_HOME-anchored 让数据操作保持实例隔离
What-if ─ 把 profile 操作也走 HERMES_HOME 会发生什么?
① 改 1 行:把 _get_profiles_root() 改成 return get_hermes_home() / "profiles" — coder profile 里跑 hermes profile list 只能看到 coder 一个 profile,看不到 default / writer。
② 真实案例:PR #3575 修复了 5 个相关 bug(AGENTS.md 第 1219 行原文:"This was the source of 5 bugs fixed in PR #3575"),根因都是把"跨实例的 profile 列表操作"误走 HERMES_HOME。修复模式:profile 列表 = HOME/默认根 锚点,profile 数据 = HERMES_HOME 锚点。
Lesson双锚点原则 ─ Hermes 把"跨实例的元操作"锚在 Path.home()(profile 列表、profile 创建、profile 删除),把"实例内的数据操作"锚在 get_hermes_home()(config、auth、sessions、logs)。判断新代码该锚哪:问"这个操作的结果是否应该被其他实例看到"。看到 = HOME,看不到 = HERMES_HOME。
本节小结
- _apply_profile_override() 必须在所有 import 之前 — 因为模块级常量在 import 时就固定了路径
- profile 列表操作(profile list)是 HOME-anchored,profile 数据操作是 HERMES_HOME-anchored
- 子进程通过 os.environ 继承 HERMES_HOME,无需额外传递
四、Layer 2 ↔ Layer 3 ─ 工具自注册与 _HERMES_CORE_TOOLS 双层结构
Layer 视角 ─ Layer 2 / Layer 3 之间的接口面
Layer 3(tools/*.py)的工具有两层意义:
- 存在性(注册表)— 工具被 import 后调用 registry.register() 注册到全局表里
- 暴露(工具集)— 工具必须被 TOOLSETS 字典中某个列表显式列出,才会被 get_tool_definitions() 发给模型
这两层故意分开:注册表让新增工具零成本接入(自注册),工具集显式列出保证可控暴露。本节聚焦 Layer 2 ↔ Layer 3 的接口面 — 即"工具如何被注册"与"工具如何被暴露"的边界。
4.1 registry.register 的最小样例
以下为 AGENTS.md 文档中的最小骨架示例(用于说明接口):
源码视角 ─ 工具注册最小骨架(来自 AGENTS.md 文档示例)
import json
import os
from tools.registry import registry
def check_requirements() -> bool:
"""gate:是否启用。缺 API key 时返回 False,工具对模型不可见。"""
return bool(os.getenv("EXAMPLE_API_KEY"))
def example_tool(param: str, task_id: str = None) -> str:
"""handler:实际工具逻辑,返回 JSON 字符串。"""
return json.dumps({"success": True, "data": "..."})
# 模块级:import 时自动注册
registry.register(
name="example_tool",
toolset="example",
schema={
"name": "example_tool",
"description": "...",
"parameters": {...},
},
handler=lambda args, **kw: example_tool(
param=args.get("param", ""),
task_id=kw.get("task_id"),
),
check_fn=check_requirements,
requires_env=["EXAMPLE_API_KEY"],
)
五个关键参数:
- name:工具名(模型在 tool_calls 里调用的标识)
- toolset:所属工具集(决定它在哪些平台暴露)
- schema:OpenAI 兼容的工具 schema,发给模型时序列化
- handler:实际执行的函数 — 必须返回 JSON 字符串
- check_fn:gate — 返回 False 时工具从模型 schema 中消失(credential-gated)
4.2 自注册 vs 工具集 ─ 双层结构的语义差
源码视角 ─ _HERMES_CORE_TOOLS 是"暴露点"
看 toolsets.py:
_HERMES_CORE_TOOLS = [
"terminal", "patch", "search_files", "read_file", "todo",
"web_search", "web_extract", "skill_manage", "delegate_task",
# ...
]
TOOLSETS = {
"browser": ["browser_navigate", "browser_click", ...],
"code_execution": ["execute_code", "code_search"],
"TG": [ # 平台工具集:继承 _HERMES_CORE_TOOLS + 加 platform 专属
*_HERMES_CORE_TOOLS,
"send_message",
"edit_message",
# ...
],
}
双层语义:
- 注册表(registry):所有工具的存在性,是全集
- 工具集(TOOLSETS):每个 session 实际暴露给模型的子集,决定哪些可见
- 添加工具:① 在 tools/ 创建文件 → ② 在 toolsets.py 加名字。漏 ② = 工具存在但永远不暴露
注意:自动发现 ≠ 自动启用。任何 tools/<name>.py 文件 import 时会自动调 registry.register() 把工具加进注册表 — 但工具只有在某个 TOOLSETS 列表里被显式列出后,才会出现在 session 的工具 schema 里。这两层是故意分开的:自动发现让新增工具零成本接入,注册表显式列出保证可控暴露。
4.3 Core 工具 vs Plugin 工具
Hermes 有两套工具注册系统:
| 维度 | Core 工具(tools/) | Plugin 工具(plugins/<name>/) |
|---|---|---|
| 注册机制 | registry.register() at import time | ctx.register_tool(...) in plugin's register(ctx) |
| 发现方式 | model_tools.py import 时扫描 | hermes_cli/plugins.py PluginManager 扫描 |
| 启用控制 | 在 toolsets.py 显式列出 | plugin manifest 声明 + 用户启用 |
| 典型例子 | terminal、browser_navigate | honcho memory tools、image-gen 工具 |
关键原则:plugin 不能改 core
AGENTS.md 的 "Plugins" 节明确:plugin 作者不能改 core 文件(run_agent.py / cli.py / gateway/run.py 等)。如果 plugin 需要 core 没暴露的能力,必须扩展通用 plugin 表面(新增 hook / 新 ctx method),让所有 plugin 受益。
What-if ─ 删掉 registry.register() 自注册改回中心化声明表会发生什么?
① 改 1 行:把 tools/registry.py 改成 __init__.py 显式 import * from every_tool 大表 — 新工具 PR 必须改这个中心文件,立刻和 5 个不相关 PR 绑在一起 merge,冲突率上升。
② 删 check_fn:删掉 gate — 没有 API key 的工具也出现在 schema 里,模型调用后 handler 内 raise ValueError("missing key")。模型每次都要重新试错,浪费 token。
③ 设计权衡:check_fn 把"这个工具现在能不能用"的所有判断下放到工具自己(缺 API key、缺外设、用户通过 tools.<platform>.disabled 禁用等),让 get_tool_definitions() 只问一个布尔 ── 这种"gate 自包含于工具"的模式详见第 10 篇工具注册表详解。
Lesson双层语义差 ─ "存在性"(注册表)和"暴露"(工具集)的分离,让"加工具"和"暴露工具"成为两个独立决策。前者是开发者的添加(删文件即删除),后者是安全/UX 的门控(人工 review 后才暴露给模型)。如果合并成一层,要么开发者每次都要走 review 流程,要么安全门控形同虚设。判断新功能该放哪:问"加这个是否需要安全 review"。需要 = 暴露层;不需要 = 注册层。
本节小结
- 工具自注册 = registry.register() at import time;新增工具零核心改动
- 双层结构:注册表(存在性)vs 工具集(暴露子集)
- Core 工具与 plugin 工具走两套注册系统,AGENTS.md 禁止 plugin 改 core
五、系列地图:6 部分 40 篇与三条推荐阅读路径
Layer 视角 ─ 系列也是分层
把 Hermes 源码专题拆为6 部分 / 40 篇,每个标题对应一个独立的源码主题。本节把这个"内容地图"也按 Layer 框架来组织:6 部分是横向 Layer(核心→存储→工具→网关→高级→生态),每篇是纵向专题(一个 .py 主文件 + 1~2 个次要文件做衔接)。这个拆分不是任意切的 — 它由"每个标题只允许一个 .py 主文件作为深度主轴"这条原则驱动。原文 hermes_state.py 单文件 6410 行包 6 个子系统,被拆成 4 篇(06~09)就是这个原则的体现。
5.1 6 部分总览(横向 Layer)
| Layer | 部分 | 范围 | 篇数 | 核心主轴文件 |
|---|---|---|---|---|
| L1 | 1. 核心架构 | 入口、循环、Provider | 5 | run_agent.py、providers/、hermes_cli/auth.py |
| L2 | 2. 会话存储层 | SessionDB 拆分 | 4 | hermes_state.py 6410 行的 6 个子系统 |
| L3 | 3. 工具系统 | 注册、编排、工具集、终端、MCP | 10 | tools/、tools/environments/ |
| L4 | 4. 消息网关 | GatewayRunner + 20 个平台 | 8 | gateway/ |
| L5 | 5. 高级特性 | Cron / 压缩 / 缓存 / 插件 / 技能 / Profile | 7 | cron/、agent/、hermes_cli/plugins.py |
| L6 | 6. 生态与终端 | TUI / 桌面 / Dashboard / CLI | 6 | ui-tui/、apps/desktop/、hermes_cli/commands.py |
5.2 三条推荐阅读路径
根据读者身份推荐三条路线(按 Layer 顺序):
路径 A ─ 核心→工具(新人入门):
L1.01 入口启动流程(本文)
L1.02 AIAgent 核心循环:消息构造
L1.03 AIAgent 循环内部:缓存/流式/子代理
L1.04 Provider 运行时解析
L3.10 工具注册表
L3.11 工具编排层
L3.12 工具集(30 个)
L3.15 浏览器自动化(CDP 通道)
L3.16 MCP 集成(双向通道)
路径 B ─ 网关→生态(消息平台开发者):
L1.01 入口启动流程(本文)
L4.20 GatewayRunner
L4.21 流式事件分发
L4.22 平台适配器 ABC
L4.23 TG/Discord/Slack
L4.24 IM 长尾平台
L4.25 国内办公平台
L4.26 Webhook 与 APIServer
L4.27 记忆系统
L6.35 TUI(Ink)
L6.37 桌面应用 Electron
路径 C ─ 垂直专题(性能 / 可靠性 / 故障排查):
L2.06 SessionDB 表结构与血缘
L2.07 WAL 与跨平台容错
L2.08 FTS5 全文检索
L2.09 state.db 损坏自愈
L3.19 委派子代理
L4.27 记忆系统
L5.28 Cron 调度系统
L5.30 上下文压缩
L5.31 缓存优化
L5.34 Profile 多实例
每条路径都先读 01(本文)建立全景,再按需深入。
5.3 拆分对照(节选)
系列结构 ─ 拆分驱动点
主要拆分点:
篇数 主题 拆分说明
04 会话存储层 拆为 4 篇 (06-09) ⭐ 用户痛点最高
06 28 个工具集详解 拆为 4 篇 (13-16) 按文件/终端/浏览器/MCP
09 Gateway 消息处理 拆为 2 篇 (20-21) 入口启动 vs 流式事件
10 20 个平台适配器 拆为 5 篇 (22-26) ABC + 三大 + 长尾 + 国内 + webhook
17 Electron 桌面应用与 TUI 拆为 4 篇 (35-38) 4 个独立渲染/通信表面
如何读这一系列
每篇的硬结构 = 导语 + 源码文件路径列表 + 学习重点 + 目录 + 5 章正文(每章 1 个 h2 / 3~4 个 h3)+ FAQ 20 问 + 下篇预告 + 参考资源。每章内的每个知识点是 Layer 框架(架构/拓扑/平台分发场景)。每篇的必预读源文件列在 pllan/prompt.md 附录里 — 用 Read 工具实际读源文件(仅 grep / glob 不算预读)。
Lesson拆分原则 = 窄腰识别 ─ 系列拆分表面上是"按文件分",本质是"识别 narrow waist"。每个 Layer 都有 1~2 个 narrow waist(AIAgent / handle_function_call / registry.register())。一篇博客讲透一个 narrow waist + 它连接的子模块 = 不会心智过载。判断一篇是否需要拆:看它涉及几个 narrow waist。1 个 = 不拆;2+ 个 = 拆。
本节小结
- 6 部分 / 40 篇拆分由"每个标题只允许一个 .py 主文件"原则驱动,本质是窄腰识别
- 三条推荐路径:核心→工具、网关→生态、垂直专题
- 最显著的拆分:第 04 篇(会话存储层)拆为 4 篇
六、FAQ 20 问
FAQ 分组说明
本节围绕开篇总览的 4 个核心问题:启动顺序本身、Profile / 工具集 / 系列结构。每条都是"读整个系列前最该知道的事",不重复后续专题的细节。
Q1. hermes 和 hermes-agent 有什么区别?
同一个 agent core,两个不同的启动入口。hermes 走 hermes_cli/main.py(含 profile 覆盖、fast-path、UI 渲染),hermes-agent 是单文件启动入口(更轻量)。两者最终都构造 AIAgent,共享 run_agent.py 的循环。
Q2. 为什么要分 hermes gateway 和 hermes serve?
前者带 Web UI,后者纯 headless。gateway 跑 GatewayRunner + 浏览器可访问的 web dashboard;serve 跳过 SPA 渲染只暴露 JSON-RPC / WebSocket,供 Electron 桌面 / 第三方客户端调用。
Q3. acp_adapter/ 是给谁用的?
IDE 协议层。acp_adapter/ 下的 ACP server 让 VS Code / Zed / JetBrains 等支持 Agent Client Protocol 的 IDE 把 Hermes 作为 agent 后端。它是又一个启动入口,复用同一个 AIAgent。
Q4. _apply_profile_override 必须在 main() 第一行吗?
必须在所有 get_hermes_home() 调用之前。看 hermes_cli/main.py:_apply_profile_override() 在模块顶层 import 完就立即执行(不是 main() 内),因为模块级常量在 import 时就固定了。
Q5. 我能在 ~/.hermes/ 下手动建 profile 目录吗?
可以但不推荐。hermes profile create <name> 会自动建目录、初始化 config.yaml、auth.json 和 sticky active_profile 标记。手动建的目录缺这些文件,后续切换时会抛 FileNotFoundError: Profile '<name>' does not exist. Create it with: hermes profile create <name>(来自 hermes_cli/profiles.py 约第 1853-1856 行)。
Q6. 工具集 TG 和 discord 都会暴露 terminal 吗?
会的。terminal 在 _HERMES_CORE_TOOLS 里,所有平台基类工具集都从它继承。除非用户在 config.yaml 用 tools.<platform>.disabled 显式禁用。
Q7. 添加一个新 core 工具要改几个文件?
2 个:创建 tools/<name>.py + 在 toolsets.py 加名字。其他 0 改动 — 自注册机制会在 import 时自动发现。详见第 10 篇"工具注册表"。
Q8. 为什么不把所有平台适配器写成 if platform == "TG": ... 的大表?
因为 20 个平台各自有不同的事件流、鉴权、消息格式。20 个独立 adapter + 公共 ABC(gateway/platforms/base.py)是 AGENTS.md "Extend, don't duplicate" 准则的体现。详见第 22~26 篇。
Q9. AIAgent 构造签名巨大(~100+ 个参数)是过度设计吗?
不是。每个参数都是某个上层的真实配置(session context / credential pool / 回调 / 预算),且必须在构造时一次注入,因为中途修改会破坏 prompt caching。详见第 02 篇 "AIAgent 核心循环"。
Q10. handle_function_call 是怎么知道工具是否可用的?
通过 check_fn。每个工具注册时带一个 check_fn,每次 get_tool_definitions() 时检查(缺 API key、用户禁用等)。返回 False 时工具从 schema 里消失,模型看不到也不会调。
Q11. 40 篇里"上下文压缩"和"缓存优化"两篇是不是重复了?
不重复。第 30 篇"上下文压缩"讲 agent/context_compressor.py 与 agent/curator.py 的 checkpoint 触发链(第 30 篇主文件见 prompt.md);第 31 篇"缓存优化"讲 prompt caching 失效语义与重建策略。前者是何时触发,后者是如何不让缓存失效。
Q12. 我应该按 1~40 顺序读还是跳着读?
看你的目标。新人 = 路径 A(核心→工具,01 + 02 + 03 + 04 + 10 + 11 + 12);消息平台 = 路径 B(01 + 20 + 21 + 22~26);性能 / 故障排查 = 路径 C(01 + 06~09 + 30 + 31)。第 5.2 节有完整路径图。
Q13. 如果我只想读"会话存储层"那 4 篇,可以跳过其它吗?
可以,但需要先读第 01 篇。理解 AIAgent 怎么用 SessionDB、profile 对 state.db 路径的影响,是读懂 06~09 篇的前提。
Q14. cron/scheduler.py 和 cron/jobs.py 谁是核心?
jobs.py 是存储 + 状态机,scheduler.py 是 tick 循环。前者决定"一个 job 是什么、怎么 claim、怎么存",后者决定"什么时候 wakeup、谁来 claim、tick 间隔多长"。详见第 28~29 篇。
Q15. 我能在 plugin 里改 run_agent.py 吗?
不能。AGENTS.md "Plugins" 节明确:plugin MUST NOT modify core files。如果 plugin 需要新能力,必须扩通用 plugin 表面(新增 hook / ctx method)— 让所有 plugin 受益,而不是硬编码 plugin 特定逻辑。
Q16. TUI 和 hermes dashboard 是什么关系?
dashboard 嵌入的是真的 hermes --tui。不是 React 写的"伪 TUI"。所以 TUI 加任何新功能都自动同步到 dashboard。详见第 35、38 篇。
Q17. 桌面应用 apps/desktop 和 CLI / dashboard 是同一套代码吗?
不是。apps/desktop 是独立的 Electron + React 表面(用 @assistant-ui/react + nanostores),通过 JSON-RPC 调 tui_gateway 后端。它有自己的 composer / transcript / slash 命令管线,不嵌入 Ink TUI。详见第 37 篇。
Q18. hermes tools 是 curses 写的吗?
是。hermes_cli/curses_ui.py 是 curses 风格工具开关界面。simple_term_menu 仅作为 legacy fallback 保留 — 新代码必须用 curses(见 AGENTS.md "DO NOT introduce new simple_term_menu usage")。
Q19. 40 篇博文什么时候写完?
渐进发布。每篇独立成文、可单独读。按生成速度估算,每周 2~3 篇,6 个月内完成。索引文件 pllan/articles/hermes-blog-index.md 持续更新。
Q20. 哪一篇是最值得先读的(除了本文)?
第 06 篇"SessionDB 表结构与血缘模型"。它是整个系列中唯一把"会话"作为一等公民讲清楚的一篇 — 后面所有涉及 session_id 的篇章(gateway / cron / 委派 / 缓存)都会反复引用 06 篇定义的字段。
本节总纲
- 本系列 5 个启动入口共用同一个 AIAgent core(核心循环、工具注册、Provider 解析)
- 启动顺序敏感:bootstrap → profile 覆盖 → 子命令分发 → 核心循环
- 40 篇分 6 部分,由"每篇一个 .py 主文件"原则驱动,本质是 narrow waist 识别
- 新人从本文 + 第 02 + 第 06 篇开始读,覆盖率达 40%
下一篇
下一篇 第 2 篇:AIAgent 核心循环:消息构造与工具调度边界 将深入 run_agent.py 的核心循环:消息列表如何构造、prompt caching 怎么注入、模型返回的 tool_calls 如何分发、为什么所有异常都在 run_conversation() 内被拦截。理解了 AIAgent 循环,后面的工具系统、会话存储层、缓存优化才有共同语言。

浙公网安备 33010602011771号