Hermes Agent 源码专题【左扬精讲】—— 开篇总览

Hermes Agent 源码专题【左扬精讲】— 开篇总览

这是 Hermes Agent 源码专题【左扬精讲】系列的 第 1 篇 / 共 40 篇。本篇是 Layer 框架(架构 / 拓扑 / 平台分发场景)。

本篇不是把整个仓库讲一遍,而是把读者第一次接触 Hermes 源码时 最该看到的地图 画出来:当你敲下 hermeshermes gatewayhermes 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 FalseLANG / 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.pymain()。它做几件关键的事:

源码视角 ─ 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.pyAIAgent,但跳过 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.pyclass 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.pyBasePlatformAdapter 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_PATHhermes_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 声明 + 用户启用
典型例子 terminalbrowser_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 里,模型调用后 handlerraise 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.pyproviders/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. hermeshermes-agent 有什么区别?

同一个 agent core,两个不同的启动入口。hermeshermes_cli/main.py(含 profile 覆盖、fast-path、UI 渲染),hermes-agent 是单文件启动入口(更轻量)。两者最终都构造 AIAgent,共享 run_agent.py 的循环。

Q2. 为什么要分 hermes gatewayhermes serve

前者带 Web UI,后者纯 headless。gatewayGatewayRunner + 浏览器可访问的 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. 工具集 TGdiscord 都会暴露 terminal 吗?

会的。terminal_HERMES_CORE_TOOLS 里,所有平台基类工具集都从它继承。除非用户在 config.yamltools.<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.pyagent/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 怎么用 SessionDBprofilestate.db 路径的影响,是读懂 06~09 篇的前提。

Q14. cron/scheduler.pycron/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. TUIhermes 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 循环,后面的工具系统、会话存储层、缓存优化才有共同语言。


posted @ 2026-07-15 16:52  左扬  阅读(42)  评论(0)    收藏  举报