# Hermes 源码视角：工具调用实现拆解

## 文档目的

这份文档从本地 Hermes 源码出发，拆解“工具调用是如何在框架内部被发现、暴露、验证、执行并回填给 LLM”的实现链路。

它是上一份概念文档《LLM 工具调用确认机制整理》的源码落地版，重点回答：

- Hermes 里工具 schema 从哪里来
- toolset 是如何解析的
- `run_conversation()` 主循环如何工作
- 模型返回 `tool_calls` 后，Hermes 如何验证并执行
- 工具结果如何重新放回消息历史
- 哪些地方做了健壮性保护（非法工具、非法 JSON、孤儿 tool result、并发执行、压缩等）

---

## 0. 本次检查基线

基于本地实际安装检查得到：

- Hermes 版本：`Hermes Agent v0.10.0 (2026.4.16)`
- 项目路径：`/root/.hermes/hermes-agent`

本次重点阅读的文件：

- `/root/.hermes/hermes-agent/run_agent.py`
- `/root/.hermes/hermes-agent/model_tools.py`
- `/root/.hermes/hermes-agent/tools/registry.py`
- `/root/.hermes/hermes-agent/toolsets.py`
- `/root/.hermes/hermes-agent/tools/file_tools.py`
- `/root/.hermes/hermes-agent/tools/terminal_tool.py`

说明：下面提到的行号基于当前本地版本，后续升级后可能漂移，但结构关系通常仍成立。

---

## 1. 先看整体结论

Hermes 的工具调用链路可以概括成 8 步：

1. 工具模块通过 `registry.register(...)` 在导入时自注册
2. `model_tools.py` 启动时触发工具发现（builtin / MCP / plugin）
3. `AIAgent.__init__()` 调用 `get_tool_definitions()`，按 toolset 解析出当前会话可见的工具 schema
4. `run_conversation()` 进入主循环，构造 `messages + tools` 发给模型
5. 模型返回 `assistant_message.tool_calls`
6. Hermes 先做工具名与参数层面的校验，再标准化为内部 assistant message
7. `_execute_tool_calls()` 执行工具，必要时并发执行，并把结果作为 `role=tool` 追加进消息历史
8. 主循环继续下一轮；如果没有 tool call，则走最终回复分支并返回

一句话版：

> Hermes 把“工具注册与筛选”“LLM 工具决策”“工具调度执行”“结果重新注入上下文”拆成了清晰的多层结构，而不是把所有逻辑塞在一个函数里。

---

## 2. 源码结构里，哪几个文件最关键

### 2.1 `run_agent.py`

这是主战场。

它负责：

- `AIAgent` 初始化
- 加载当前会话可用工具
- 构建系统提示与 API 消息
- 主循环 `run_conversation()`
- 解析模型响应
- 校验和执行 tool calls
- 把 tool result 加回消息历史
- 最终返回结果

### 2.2 `model_tools.py`

这是“工具编排层”。

它不是每个工具的具体实现，而是：

- 触发工具发现
- 解析 toolset
- 返回 OpenAI 格式的工具 schema
- 做统一的函数调度入口 `handle_function_call()`

可以把它理解成：

> run_agent 和底层工具实现之间的一层薄中间件。

### 2.3 `tools/registry.py`

这是工具注册中心。

它负责：

- 保存每个工具的 schema / handler / check_fn / toolset 等元信息
- 返回工具定义
- 按名字 dispatch 到具体 handler

这是 Hermes 的工具系统中枢。

### 2.4 `toolsets.py`

这是“工具集合定义层”。

它定义：

- `web`
- `terminal`
- `file`
- `browser`
- `hermes-cli`
- `hermes-discord`
- `hermes-gateway`
- 以及各类组合 toolset

也就是说，模型并不是默认看到“所有工具”，而是先经过 toolset 解析再暴露。

---

## 3. 工具是怎么“出现”在系统里的

Hermes 使用的是“自注册工具”模式，而不是在一个中央文件里手工维护超长工具清单。

### 3.1 `discover_builtin_tools()`：扫描并导入工具模块

在 `tools/registry.py:56-73`：

- `discover_builtin_tools()` 会扫描 `tools/` 目录
- 只挑出那些包含顶层 `registry.register(...)` 调用的模块
- 然后 `importlib.import_module(mod_name)` 导入它们

关键点：

导入工具模块本身，就是注册动作发生的时刻。

也就是：

- 不是“先 import，再手工 add 到列表”
- 而是“import 时模块自己调用 registry.register(...)”

### 3.2 例子：文件工具的注册

在 `tools/file_tools.py:941-944`，可以看到：

- `read_file`
- `write_file`
- `patch`
- `search_files`

都是通过 `registry.register(...)` 注册进去的。

### 3.3 例子：terminal 工具的注册

在 `tools/terminal_tool.py:2068-2076`：

- 工具名：`terminal`
- toolset：`terminal`
- schema：`TERMINAL_SCHEMA`
- handler：`_handle_terminal`
- 可用性检查：`check_terminal_requirements`

这说明每个工具在注册时就已经把以下信息交齐了：

- 名字
- 属于哪个 toolset
- 对外暴露的 schema
- 实际执行函数
- 当前是否可用的检查逻辑
- 可选的 emoji、结果大小限制等元数据

---

## 4. 注册中心 `ToolRegistry` 到底存了什么

在 `tools/registry.py` 里，核心数据结构是 `ToolEntry` 和 `ToolRegistry`。

### 4.1 `ToolEntry`

`tools/registry.py:76-98`

一个工具注册后，会被封装成 `ToolEntry`，里面至少包含：

- `name`
- `toolset`
- `schema`
- `handler`
- `check_fn`
- `requires_env`
- `is_async`
- `description`
- `emoji`
- `max_result_size_chars`

这说明 Hermes 对工具不是只存一个 callable，而是存“完整元信息对象”。

### 4.2 `ToolRegistry.register()`

`tools/registry.py:176-227`

这个函数负责把工具写进注册表。

几个重要点：

1. 会做冲突控制
   - 不允许普通工具轻易覆盖已有工具
   - 只对某些 MCP-to-MCP 的刷新场景放宽

2. 会记录 toolset 对应的 `check_fn`
   - 这为后续 toolset 可用性判断提供基础

3. 注册是线程安全的
   - Registry 内部有 `RLock`

这说明 Hermes 的工具系统是考虑过：

- 插件扩展
- MCP 动态刷新
- 多线程读取
- 名称冲突

而不是只服务于一个静态单进程 CLI。

---

## 5. toolset 是怎么解析成真实工具列表的

Hermes 不会简单把注册表里所有工具全发给模型，而是通过 `toolsets.py` 先做一轮筛选。

### 5.1 静态 toolset 定义

在 `toolsets.py:68` 开始，`TOOLSETS = {...}` 定义了很多 toolset。

例如：

- `web`
- `terminal`
- `file`
- `browser`
- `skills`
- `memory`
- `delegation`
- `hermes-cli`
- `hermes-gateway`

其中还有 `_HERMES_CORE_TOOLS`（`toolsets.py:31-63`），供多个平台 toolset 复用。

### 5.2 `resolve_toolset()`：递归展开

在 `toolsets.py:465-515`：

`resolve_toolset(name)` 会：

1. 找到该 toolset 的直接工具
2. 递归处理它的 `includes`
3. 去重后返回最终工具名列表

这就支持了：

- 单层 toolset
- 组合 toolset
- 平台级 toolset
- “all / *” 这种全量快捷别名

### 5.3 `validate_toolset()`：先验合法性检查

在 `toolsets.py:611-628`：

Hermes 会判断：

- 名字是否存在于静态 TOOLSETS
- 是否是插件注册的 toolset
- 是否是 registry alias
- 是否是 `all` / `*`

这就是为什么 `enabled_toolsets` 传进来以后，不是盲目相信，而是先验证。

---

## 6. 当前会话到底能看到哪些工具：`get_tool_definitions()`

这是工具从“注册状态”变成“实际暴露给模型的 schema”的关键一步。

位置：`model_tools.py:202-346`

### 6.1 解析 enabled / disabled toolsets

它会先根据：

- `enabled_toolsets`
- `disabled_toolsets`

决定要包含哪些工具名。

逻辑大概是：

- 如果显式启用了某些 toolset，就只解析这些
- 如果是禁用模式，就从全部 toolset 里减掉禁用项
- 如果都没传，则按全部可见 toolset 处理

### 6.2 调用 registry 取 schema

随后在 `model_tools.py:269-270`：

- 调用 `registry.get_definitions(tools_to_include, quiet=quiet_mode)`

这一步已经不是静态工具名集合，而是真正生成 OpenAI 工具 schema 列表。

### 6.3 `registry.get_definitions()` 里的可用性过滤

在 `tools/registry.py:258-286`：

它会遍历请求中的工具名，并对每个工具做：

- 是否已注册
- `check_fn()` 是否通过

只有通过的工具才会进入最终结果。

也就是说：

> toolset 解析解决“理论上应该有哪些工具”，`check_fn` 过滤解决“此刻实际上哪些工具可用”。

### 6.4 动态 schema 修正

`model_tools.py:278-345` 还做了几件很工程化的事：

1. 动态重建 `execute_code` schema
   - 只把当前会话实际可用的 sandbox 工具写进去
   - 避免模型看到并不存在的工具名

2. 动态调整某些工具 schema
   - 例如 Discord 工具可根据能力动态收缩 schema

3. 清理跨工具描述误导
   - 例如 browser schema 里如果提到 `web_search`，但当前没启用，就删掉那段描述

这说明 Hermes 不只是“筛选工具”，还在尽量避免 schema 层的幻觉诱导。

### 6.5 结果写回 `AIAgent`

在 `run_agent.py:1302-1312`：

`AIAgent.__init__()` 调用：

- `self.tools = get_tool_definitions(...)`
- `self.valid_tool_names = {...}`

这两者分别承担：

- `self.tools`：发给模型的正式 schema 列表
- `self.valid_tool_names`：后续对模型返回 tool call 做合法性验证

这是一个非常重要的分层。

---

## 7. `run_conversation()`：主循环从哪里开始

主函数位置：`run_agent.py:8649`

它是 Hermes 的核心执行循环。

### 7.1 前置准备

在进入主循环前，`run_conversation()` 做了大量准备工作，包括：

- 安装安全 stdio wrapper
- 绑定 session context
- 清理上次 turn 的 fallback 状态
- 清洗用户输入中的 surrogate / memory-context 泄漏
- 生成 `effective_task_id`
- 重置各类 retry counter
- 初始化 `IterationBudget`
- 载入 conversation history
- 恢复 todo store
- 构建或复用 system prompt
- 预检查并可能触发 context compression
- 通过 plugin / memory manager 预取上下文

这说明 Hermes 的主循环并不是“用户消息 -> 直接调模型”，而是在进入推理前已经做了一整套上下文治理。

### 7.2 主循环条件

在 `run_agent.py:9018`：

```python
while (api_call_count < self.max_iterations and self.iteration_budget.remaining > 0) or self._budget_grace_call:
```

说明 Hermes 同时受两类约束：

- `api_call_count`
- `IterationBudget`

并且留了一个 grace call 机制。

这意味着它不是无限循环执行工具，而是严格受预算控制。

---

## 8. 每轮调用模型前，Hermes 做了什么

在主循环内部（大约 `run_agent.py:9129-9258`），Hermes 会构建真正发给 API 的 `api_messages`。

### 8.1 从内部 `messages` 拷贝出 `api_messages`

Hermes 内部保存的是 richer message 结构，里面可能包含：

- `reasoning`
- `finish_reason`
- `_thinking_prefill`
- Codex / provider-specific 字段

但发给 API 的时候，会做一轮清理和适配。

### 8.2 把 ephemeral context 注入当前 user message

包括：

- memory manager 预取内容
- plugin pre_llm_call 提供的上下文

这些内容会拼接进当前 user message，而不是改 system prompt。

这背后的设计意图是：

> 尽量保持 system prompt 稳定，以利于 prompt cache 命中。

### 8.3 拼接 system prompt

最后才把：

- cached system prompt
- ephemeral system prompt

拼成实际的 `role=system` 消息。

### 8.4 预发送校验：`_sanitize_api_messages()`

在 `run_agent.py:4217-4293`，每次 API 调用前都会跑这个安全网。

它做两件关键事：

1. 删除 orphaned tool result
   - 即有 `role=tool`，但找不到对应 assistant tool call 的结果消息

2. 给缺失结果的 tool call 自动补 stub
   - 内容是：`[Result unavailable — see context summary above]`

这一步非常关键，因为如果消息序列损坏，很多模型 API 会直接拒绝请求。

所以 Hermes 在发送前会主动修补工具调用配对关系。

---

## 9. 模型返回后，assistant message 是怎么标准化的

Hermes 会把不同 provider / API 形态的返回统一归一化。

核心函数：`run_agent.py:7151-7293` 的 `_build_assistant_message()`

### 9.1 统一抽取 reasoning

它会：

- 读取结构化 reasoning 字段
- 如果没有，再尝试从 `<think>...</think>` 中提取
- 清洗 surrogate
- 把 reasoning 单独放到 `msg["reasoning"]`

### 9.2 清理内容中的 think block

它还会把 assistant 的可见 content 里的 `<think>` 标签剥离掉，避免：

- reasoning 泄露给最终用户
- 下轮上下文被无谓膨胀
- session title 被污染

### 9.3 标准化 `tool_calls`

如果模型返回了 `assistant_message.tool_calls`：

- 会把每个 tool call 统一转成 dict
- 确保有稳定的 `id` / `call_id`
- 保留 `function.name`
- 保留 `function.arguments`
- 保留某些 provider-specific 字段，例如 `extra_content`

这说明 Hermes 内部消息不是直接照抄 SDK 原始对象，而是做了自己的归一化层。

---

## 10. 模型返回 tool call 后，Hermes 先做哪些校验

这一段在 `run_agent.py:11091-11321` 附近，是最贴近“LLM 与工具调用确认”的核心实现。

### 10.1 先检查是否存在 tool calls

```python
if assistant_message.tool_calls:
```

进入这个分支，就说明当前不是最终文本回复，而是工具调用轮次。

### 10.2 工具名校验与自动修复

逻辑：`run_agent.py:11100-11149`

Hermes 会：

1. 检查 tool name 是否在 `self.valid_tool_names`
2. 如果不在，先尝试 `_repair_tool_call()` 自动修复
3. 仍无效则：
   - 记录重试次数
   - 把错误以 tool result 的形式回填给模型
   - 让模型下一轮自我修正

这点很关键：

> Hermes 对非法工具名不是立刻崩掉，而是尽量修复；修不了就把“未知工具”当成结构化错误反馈给模型。

### 10.3 参数 JSON 校验

逻辑：`run_agent.py:11151-11240`

Hermes 会检查：

- `arguments` 是否能被 `json.loads`
- 空字符串会被当作 `{}`
- 非字符串、dict/list 等也会标准化

如果 JSON 非法：

- 会区分“普通格式错误”与“输出被截断导致的半截 JSON”
- 普通情况先重试 API
- 超过阈值后，构造 tool error result 回给模型恢复
- 截断情况则直接作为 partial 失败处理，避免执行半截参数

这一步体现出 Hermes 对“模型输出脏数据”的容错做得很细。

### 10.4 post-call guardrails

接着在 `run_agent.py:11243-11249`：

- `_cap_delegate_task_calls(...)`
- `_deduplicate_tool_calls(...)`

说明 Hermes 在真正执行前，还会对某些高风险/高成本工具做额外收敛处理。

---

## 11. assistant message 何时被写回消息历史

在工具执行前，Hermes 会先：

- `assistant_msg = self._build_assistant_message(...)`
- `messages.append(assistant_msg)`

见 `run_agent.py:11251` 和 `11306`

这一步很重要，因为它保证消息顺序是：

1. assistant 发出 tool_calls
2. tool 返回各自结果

而不是只有 tool result 没有对应 assistant call。

这和 OpenAI / Anthropic 这类工具调用协议的消息形状是对齐的。

---

## 12. 工具执行入口：`_execute_tool_calls()`

位置：`run_agent.py:7652-7673`

这个函数负责根据当前 tool call 批次决定：

- 走顺序执行
- 还是并发执行

逻辑是：

- 如果 `_should_parallelize_tool_batch(tool_calls)` 返回 False，就走顺序
- 否则走并发

---

## 13. 并发执行判定是怎么做的

位置：`run_agent.py:289-330`

`_should_parallelize_tool_batch(tool_calls)` 不是简单地“多个工具就并发”，而是比较保守：

### 13.1 直接禁止并发的工具

`_NEVER_PARALLEL_TOOLS` 当前至少包含：

- `clarify`

这类需要用户交互或有强状态依赖的工具，绝不并发。

### 13.2 明确允许并发的只读工具

`_PARALLEL_SAFE_TOOLS` 包括：

- `read_file`
- `search_files`
- `session_search`
- `skill_view`
- `skills_list`
- `vision_analyze`
- `web_search`
- `web_extract`
- 以及若干只读工具

### 13.3 路径作用域工具要做路径冲突判断

`_PATH_SCOPED_TOOLS` 包括：

- `read_file`
- `write_file`
- `patch`

如果两个调用目标路径重叠，就不允许并发。

也就是说，Hermes 的并发策略是：

> 只有看起来彼此独立、不会互相污染状态的工具调用，才走并发。

这很实用，也很稳。

---

## 14. 真正执行单个工具时走哪条路径

核心函数：`run_agent.py:7694-7771` 的 `_invoke_tool()`

这里是非常清晰的分层点。

### 14.1 Agent-level tools：不走 registry.dispatch

这些工具需要依赖 agent 自身状态，所以直接在 `_invoke_tool()` 里处理：

- `todo`
- `session_search`
- `memory`
- `clarify`
- `delegate_task`
- 以及 memory manager 暴露的外部记忆工具

原因很合理：

这些工具依赖：

- todo store
- session db
- memory store
- clarify callback
- parent agent

不是一个纯函数 handler 能搞定的。

### 14.2 普通工具：走 `handle_function_call()`

如果不是 agent-level tool，就进入：

```python
handle_function_call(...)
```

这又把控制权交回 `model_tools.py`。

也就是说：

- agent 有状态的工具，在 agent 层拦截
- 通用工具，走 registry-dispatch 体系

这个边界划分是合理的。

---

## 15. `handle_function_call()` 做了什么

位置：`model_tools.py:452-583`

### 15.1 参数类型矫正

在进入主逻辑前，`coerce_tool_args()` 会按 schema 尝试把：

- `"42" -> 42`
- `"true" -> True`

这类 LLM 常见的字符串化参数矫正回来。

这一步很重要，因为模型即使 schema 看懂了，也常把数字和布尔输出成字符串。

### 15.2 Agent-loop tools 保护

如果有些工具本应在 agent loop 中处理，却意外落到这里，会返回：

- `{"error": "xxx must be handled by the agent loop"}`

避免错误路径被静默执行。

### 15.3 plugin hook

执行前后会触发：

- `pre_tool_call`
- `post_tool_call`
- `transform_tool_result`

这说明 Hermes 的工具执行链路为插件系统预留了扩展点。

### 15.4 最终交给 `registry.dispatch()`

- `execute_code` 会带上当前可用工具集合
- 其他工具正常 dispatch

出错时统一返回 JSON error string。

---

## 16. `registry.dispatch()` 是最后一跳分发

位置：`tools/registry.py:292-309`

逻辑非常直接：

1. 通过名字找到 `ToolEntry`
2. 如果不存在，返回 `Unknown tool`
3. 如果是 async handler，则通过 `_run_async()` 桥接
4. 否则直接调用 `entry.handler(args, **kwargs)`
5. 捕获异常并统一返回 JSON error

这就是最底层的工具执行门。

### 16.1 为什么 async bridging 要单独做

`model_tools.py:81-131` 里定义了 `_run_async()`，并处理：

- 主线程持久 event loop
- worker thread 独立 loop
- 当前线程已在 async 环境中的回退方式

目的是避免：

- `Event loop is closed`
- cached async client 绑定死 loop
- 多线程并发工具执行时的 loop 混乱

这部分很工程化，说明 Hermes 在 async tool 上踩过坑，也已经固化了解法。

---

## 17. 并发工具执行后，结果如何回填

看 `run_agent.py:7798-8100` 的 `_execute_tool_calls_concurrent()`。

### 17.1 worker 线程实际调用 `_invoke_tool()`

每个 worker 会：

- 记录线程 id
- 绑定 activity callback
- 执行 `_invoke_tool()`
- 记录 duration
- 判断是否 error
- 把结果放回 `results[index]`

### 17.2 结果按原始顺序回填

虽然执行是并发的，但 Hermes 最后仍按原始 tool_call 顺序，把结果追加进 `messages`：

```python
tool_msg = {
    "role": "tool",
    "content": function_result,
    "tool_call_id": tc.id,
}
messages.append(tool_msg)
```

这点非常重要，因为很多 API 要求：

- tool results 的顺序和 assistant tool_calls 一一对应
- 至少要维持 call_id 配对关系

### 17.3 工具结果还有后处理

回填前后，Hermes 还会做：

- `maybe_persist_tool_result(...)`
- subdirectory hints 注入
- per-turn aggregate budget enforcement
- pending steer 注入

说明 tool result 不只是原样字符串回填，而是进入了框架自己的结果治理流程。

---

## 18. 顺序执行路径和并发路径的区别

顺序执行在 `run_agent.py:8101+`。

核心差异：

- 更适合交互式或状态敏感工具
- 每个工具执行前后可插入更多逐步控制逻辑
- 同样会把结果作为 `role=tool` 消息写回 `messages`

不管并发还是顺序，核心协议都不变：

1. assistant message 先入历史
2. 每个 tool result 都带 `tool_call_id`
3. 结果进入下一轮模型上下文

这保证了协议一致性。

---

## 19. 工具执行完成后，主循环如何继续

在 `run_agent.py:11321-11385`：

- `_execute_tool_calls(...)` 执行完后
- 工具结果已经写回 `messages`
- Hermes 会保存增量 session log
- 必要时触发 context compression
- 然后 `continue`

这意味着：

> 同一个 `run_conversation()` 会继续下一轮 API 调用，让模型读取刚刚的 tool results，再决定后续动作。

这就是 Agent 闭环真正落地的地方。

---

## 20. 如果没有 tool calls，会发生什么

在 `run_agent.py:11387` 开始进入最终回复分支。

即：

```python
final_response = assistant_message.content or ""
```

然后 Hermes 会处理很多边缘情况：

- 只有 think block 没有可见内容
- 部分流式结果恢复
- 上一轮内容 + housekeeping tool 的 fallback
- post-tool empty response nudge
- thinking-only continuation
- 空回复重试
- fallback provider 切换

这些都说明：

Hermes 不是“没 tool_calls 就简单 return”，而是会尽量把最终回答做稳。

不过从工具调用机制视角看，最核心的判断仍然是：

- 有 tool_calls -> 执行工具后继续循环
- 无 tool_calls -> 进入最终回答路径

---

## 21. 从源码角度看，Hermes 的“确认工具调用”发生在哪几层

把前面内容压缩一下，Hermes 里真正的“确认”大概分成 5 层：

### 第 1 层：注册确认

发生在：

- `discover_builtin_tools()`
- `registry.register()`

确认内容：

- 这个工具是否真的存在于系统中
- 它的 schema / handler / check_fn 是否完整

### 第 2 层：可见性确认

发生在：

- `resolve_toolset()`
- `get_tool_definitions()`
- `registry.get_definitions()`

确认内容：

- 当前会话到底允许模型看到哪些工具
- 哪些工具因 `check_fn` 失败而被隐藏

### 第 3 层：模型输出合法性确认

发生在：

- `valid_tool_names` 校验
- tool name auto repair
- JSON 参数校验
- delegate_task 限流 / 去重

确认内容：

- 模型返回的 tool_call 是否可执行

### 第 4 层：调度执行确认

发生在：

- `_invoke_tool()`
- `handle_function_call()`
- `registry.dispatch()`

确认内容：

- 调哪个 handler
- 是否需要 async bridging
- 异常如何标准化

### 第 5 层：结果配对确认

发生在：

- tool result append
- `_sanitize_api_messages()`

确认内容：

- 每个 `tool_call_id` 是否有对应 result
- 是否存在 orphaned tool result
- API 消息序列是否仍然合法

这 5 层一起构成了 Hermes 的工具调用闭环。

---

## 22. 一张 Hermes 源码视角的链路图

```text
工具模块 (tools/*.py)
  -> registry.register(...)
  -> ToolRegistry

model_tools.py
  -> discover_builtin_tools()
  -> resolve_toolset()
  -> registry.get_definitions()
  -> get_tool_definitions()

AIAgent.__init__()
  -> self.tools
  -> self.valid_tool_names

run_conversation()
  -> 构造 api_messages + tools
  -> 调模型
  -> assistant_message

if assistant_message.tool_calls:
  -> 校验 tool name / JSON args
  -> _build_assistant_message()
  -> messages.append(assistant tool_calls)
  -> _execute_tool_calls()
      -> _invoke_tool()
          -> agent-level tools / handle_function_call()
              -> registry.dispatch()
      -> messages.append(role=tool, tool_call_id=...)
  -> continue 下一轮
else:
  -> final_response
  -> return
```

---

## 23. 一个很值得注意的设计取舍

### 23.1 Agent-level tools 与 registry tools 分层

Hermes 没有把所有工具都丢给统一 registry handler。

像：

- `todo`
- `memory`
- `session_search`
- `clarify`
- `delegate_task`

这些依赖 agent 实例状态的工具，被留在 `_invoke_tool()` 里处理。

这是一个很合理的边界划分。

### 23.2 schema 过滤不是一次性的

Hermes 不只是“启动时筛工具”，还会在 `get_tool_definitions()` 阶段对某些 schema 动态重写。

这说明它意识到：

> 错误的 schema 暴露本身就会诱导模型产生错误工具调用。

### 23.3 消息配对修复是必要的

`_sanitize_api_messages()` 这类函数看似不起眼，但实际上是多轮工具调用系统稳定运行的关键。

因为真实环境里会出现：

- session 历史加载不完整
- 压缩丢掉局部消息
- 插件插消息
- 中断导致 tool result 缺失

如果不修补，模型 API 很容易直接报错。

---

## 24. 如果你要读 Hermes 的工具调用源码，推荐阅读顺序

最省脑的顺序建议如下：

### 第一轮：抓主链

1. `run_agent.py`
   - `AIAgent.__init__()` 看 `self.tools` / `self.valid_tool_names`
   - `run_conversation()` 看主循环
   - `_build_assistant_message()`
   - `_execute_tool_calls()`
   - `_invoke_tool()`

### 第二轮：看工具编排层

2. `model_tools.py`
   - `get_tool_definitions()`
   - `handle_function_call()`
   - `_run_async()`

### 第三轮：看注册中心

3. `tools/registry.py`
   - `discover_builtin_tools()`
   - `register()`
   - `get_definitions()`
   - `dispatch()`

### 第四轮：看 toolset

4. `toolsets.py`
   - `_HERMES_CORE_TOOLS`
   - `TOOLSETS`
   - `resolve_toolset()`
   - `validate_toolset()`

### 第五轮：挑具体工具看样板

5. 工具实现文件
   - `tools/file_tools.py`
   - `tools/terminal_tool.py`

这样能最快建立 Hermes 的整体心智模型。

---

## 25. 最终总结

从源码来看，Hermes 的工具调用实现有几个鲜明特点：

1. 自注册工具架构
   - 工具模块导入即注册

2. toolset 驱动的工具暴露
   - 不是所有工具都默认暴露给模型

3. registry 统一管理 schema / handler / check_fn
   - 把工具元数据集中化

4. `run_conversation()` 内部有清晰的工具循环
   - 模型返回 tool_calls -> 执行 -> 回填 -> 继续

5. 对“模型不可靠输出”做了很多守护
   - 工具名自动修复
   - JSON 参数校验
   - orphaned tool result 修补
   - post-tool 空回复恢复
   - context compression 与 fallback

一句话总结：

> Hermes 不是“让模型直接调工具”，而是通过注册中心、toolset 解析、主循环校验、调度执行和消息修补，把工具调用包装成一个强约束、可恢复、可持续多轮运行的协议闭环。

---

## 附：和上一份概念文档的对应关系

如果把两份文档连起来看：

- 《LLM 工具调用确认机制整理》讲的是抽象层：
  - User / LLM / Agent / Tool 四层关系
  - tool call / tool result 闭环
  - 时序图和伪代码

- 本文讲的是 Hermes 具体实现层：
  - 哪些文件负责哪一层
  - 哪个函数在哪一步参与
  - 本地版本中真实的源码落点

两者合起来，既有“原理图”，也有“实物拆机图”。

---

## 后续可继续扩展的方向

如果后续还想继续深入，可以再单独补这几篇：

1. `run_conversation()` 全链路逐段精读版
2. Hermes 各类工具（terminal/file/browser）的 schema 设计模式
3. Hermes 插件系统如何插入 pre_tool_call / post_tool_call
4. Hermes 的 session persistence 与 message replay 机制
5. Hermes 在不同 provider（OpenAI/Anthropic/OpenRouter/Codex）下的工具调用兼容层
