03|工具系统才是 Agent 工程的地基:拆开 model_tools.py 看 Hermes 怎么把能力变成可控运行时
先把地基看清楚
如果说上一章我们回答的是“一个 Agent 到底是怎么跑起来的”,那么这一章要继续追问一个更底层的问题:
模型为什么能稳定地“看见”工具?
更进一步说,为什么 Hermes 里的工具不是一堆零散函数,而是一套对模型可见、对运行时可过滤、对平台可裁剪、对副作用可分层的能力系统?
这个问题,不能只去看 tools/ 目录。
因为 tools/ 目录只告诉你“有哪些工具”。 真正决定这些工具如何进入模型上下文、如何被不同入口裁剪、如何避免 schema 和实际可用性错位、如何被统一分发执行的,是 model_tools.py。
所以这一章的目标很明确:
严格基于当前 hermes-agent 仓库的 model_tools.py 现有源码,回答 Hermes 为什么能把工具系统做成 Agent 工程的地基,而不是一堆杂乱外挂。
1. model_tools.py 不是工具集合,而是“工具编排薄层”
打开 model_tools.py,文件开头的模块注释其实已经把定位说得非常明确。
它不是说“这里定义了所有工具”,而是说:
- 它是 tool registry 上方的一层 thin orchestration layer
- tools/ 里的每个工具文件通过 tools.registry.register() 自注册 schema、handler 和 metadata
- 这个模块负责触发 discovery
- 然后向 run_agent.py、cli.py、batch_runner.py、RL environments 暴露统一 public API
这段注释很重要,因为它直接说明 Hermes 对工具系统的基本哲学:
工具能力本身分散在各个实现文件里; 但工具如何被系统整体消费,必须经过一层统一编排。
这层统一编排,就是 model_tools.py。
从工程分层上看,这个选择非常对。
如果没有这一层,Hermes 会出现几个经典问题:
- run_agent.py 必须知道每个工具具体怎么注册
- CLI、Gateway、batch runner 可能各自维护一套工具加载逻辑
- 工具 schema 是否可见、工具运行时是否可用,会频繁错位
- 不同平台或 profile 想裁剪工具能力时,没有统一入口
而 model_tools.py 的作用,就是把这些分散问题压平。
所以这个文件最值得注意的地方,不是“写了哪些工具”,而是它把工具从分散实现,变成了一个可被整个 Hermes 运行时统一消费的能力层。
2. Hermes 的工具发现不是扫描目录,而是“导入即注册”
理解 Hermes 工具系统,第一步要看 _discover_tools()。
这个函数从第 132 行开始,注释写得很直白:
Import all tool modules to trigger their registry.register() calls.
也就是说,Hermes 采用的不是“扫描 tools 目录、解析文件名、动态反射类定义”那一套,而是更直接的一种模式:
每个工具模块在 import 时自己调用 registry.register() 完成注册; model_tools.py 只负责把这些模块导入进来,触发注册副作用。
源码里可以看到一个明确的 _modules 列表,当前包括:
- tools.web_tools
- tools.terminal_tool
- tools.file_tools
- tools.vision_tools
- tools.skills_tool
- tools.skill_manager_tool
- tools.browser_tool
- tools.cronjob_tools
- tools.todo_tool
- tools.memory_tool
- tools.session_search_tool
- tools.clarify_tool
- tools.code_execution_tool
- tools.delegate_tool
- tools.process_registry
- tools.send_message_tool
- tools.homeassistant_tool
等等。
这里有两个工程信号非常值得注意。
第一,discover 过程被包在函数里,并且每个 import 都有 try/except。
源码注释专门提到:
这样即使某个可选工具依赖没装好,比如 fal_client 缺失,也不会阻止其他工具加载。
这说明 Hermes 的工具发现不是全有或全无,而是允许部分能力降级。
第二,基础工具发现完成后,文件还会继续做两轮扩展发现:
- MCP tool discovery:通过 tools.mcp_tool.discover_mcp_tools()
- Plugin discovery:通过 hermes_cli.plugins.discover_plugins()
这意味着 Hermes 的工具系统从一开始就不是“内置工具表”那么简单,而是至少包含三层来源:
- 内置工具模块
- MCP 外部工具
- 插件工具
换句话说,Hermes 并不是把工具系统写死在仓库里,而是在架构上预留了外部能力接入位。
这会直接决定后面章节要谈的一件事:
Hermes 的 tool layer,不只是能力清单,更像一个能力总线。
3. registry 是能力底座,但 model_tools.py 决定“哪些能力真的对模型可见”
很多人看到 tools.registry.register() 之后,会本能觉得:
那 registry 才是关键,model_tools.py 只是转发而已。
这个理解只对一半。
registry 确实是能力底座,但真正决定“本次会话里哪些工具对模型可见”的,是 get_tool_definitions()。
这个函数从第 234 行开始,注释说得很清楚:
Get tool definitions for model API calls with toolset-based filtering.
它的职责不是返回“所有已注册工具”,而是返回“这一次模型 API 调用应该看到的工具 definitions”。
这两者差别非常大。
因为在一个真实 Agent 系统里:
- 已注册工具,不等于要全部开放
- 可安装工具,不等于当前平台适合暴露
- schema 存在,不等于依赖满足、当前真的能用
- 同一个 Hermes,不同 profile / platform / session 里,工具曝光面可以完全不同
get_tool_definitions() 正是在解决这个问题。
它先根据 enabled_toolsets / disabled_toolsets 算出 tools_to_include。
这里也能看到 Hermes 对兼容性的照顾:
- 如果 validate_toolset(toolset_name) 成立,就走新的 toolset 解析路径
- 如果是旧的 legacy 名称,比如 web_tools、browser_tools、file_tools,也还能映射到具体工具名单
- 如果什么都没指定,就默认加载所有 toolsets 解析出来的工具
这一步体现的是 Hermes 工具系统的第一个关键能力:
不是按“工具名列表”硬写,而是按 toolset 做分层暴露。
这样做的价值非常大。
因为一旦系统有了 toolset 这一层,平台、入口、配置和权限控制都可以在这个粒度上工作,而不是每次手写几十个工具名。
4. 工具可见性不由 schema 决定,而由“过滤后真实可用集合”决定
get_tool_definitions() 里最值得重视的一段,是第 301 行之后。
这里不是直接把 tools_to_include 原样返回,而是调用:
registry.get_definitions(tools_to_include, quiet=quiet_mode)
源码旁边的注释非常关键:
only returns tools whose check_fn passes
这意味着 Hermes 明确区分了两件事:
- 逻辑上想包含哪些工具
- 运行时真正可用哪些工具
一个工具即使属于当前启用的 toolset,如果它的 check_fn 没通过,最后也不会进入 filtered_tools。
然后 Hermes 还专门从 filtered_tools 里再提取出一份 available_tool_names。
注释解释得非常清楚:
后续任何会在 schema 描述里提到“其他工具名字”的逻辑,都必须基于这份真实可用集合,而不能基于原始 tools_to_include;否则模型会在描述里看见其实并不存在的工具,进而 hallucinate 不可用调用。
这段注释其实已经非常接近一本 Agent 工程书里最值钱的经验了。
很多工具系统最常见的 bug,不是工具本身坏了,而是:
schema 里说得好像能用,真正执行时才发现没 API key、没依赖、被禁用了。
对模型来说,这种“可见能力”和“真实能力”错位是非常致命的。
因为模型不是在读人类文档,而是在按你暴露给它的 schema 做行动决策。
Hermes 在这里做得非常工程化:
它把“真实可用工具集合”当成后续 schema 二次修正的唯一可信来源。
这说明 Hermes 的工具系统不是静态描述层,而是 runtime-aware 的能力暴露层。
5. execute_code 和 browser_navigate 两个特例,暴露出 Hermes 对“schema 幻觉”的强警惕
继续看 get_tool_definitions(),有两段非常值得单独分析。
5.1 execute_code 的 schema 会被动态重建
第 310 行之后,源码专门处理 execute_code:
- 从 tools.code_execution_tool 导入 SANDBOX_ALLOWED_TOOLS 和 build_execute_code_schema
- 取 sandbox_enabled = SANDBOX_ALLOWED_TOOLS ∩ available_tool_names
- 用这份交集动态生成 execute_code 的 schema
- 再把 filtered_tools 里原先的 execute_code definition 替换掉
为什么要这样做?
注释已经写明:
如果不这么做,模型会在 execute_code 的描述里看到“web_search 可以在沙箱里调用”之类的信息,即使这些工具当前 API key 没配或者 toolset 被禁用了。
这其实是非常典型的 Agent 工程问题。
因为 execute_code 这种“工具中的工具”天生带有跨工具引用,一旦静态 schema 写死,很容易出现描述比现实更乐观。
Hermes 的解决方案很干脆:
不信静态 schema,按当前会话真实可用工具重新生成。
5.2 browser_navigate 的描述会被动态去交叉引用
另一个例子是第 323 行之后对 browser_navigate 的处理。
静态 schema 里原本写着:
For simple information retrieval, prefer web_search or web_extract (faster, cheaper).
但如果当前 available_tool_names 里没有 web_search / web_extract,Hermes 会直接把这句从 description 里删掉。
原因同样非常现实:
如果描述里还保留这句,模型就会倾向去调用根本不存在的 web_search 或 web_extract。
这两个特例拼在一起,你会看到 Hermes 一个非常成熟的倾向:
它不把 schema 当成纯文档,而把 schema 当成模型决策界面。
既然 schema 会直接影响模型下一步要做什么,那么 schema 里任何跨工具引用都必须和当前运行时现实完全对齐。
这件事说起来很小,但实际上特别重要。
因为很多 Agent 系统到后期都会被这种“描述层失真”拖垮:
- 文档说能做
- 模型以为能做
- 运行时其实做不了
- 然后系统陷入错误重试、幻觉调用和无意义 token 消耗
Hermes 在 model_tools.py 里针对这类问题已经有了非常明确的防线。
6. _last_resolved_tool_names 这个小全局变量,说明工具系统不只是给模型看,还要给其他运行时组件看
在第 195 行附近,model_tools.py 定义了一个进程级变量:
_last_resolved_tool_names: List[str] = []
注释写的是:
Used by code_execution_tool to know which tools are available in this session.
到了 get_tool_definitions() 末尾,又会把当前 filtered_tools 的名字写回这个全局变量。
表面看,这像一个普通缓存。
但从系统结构上看,它暴露出一个更重要的事实:
工具可见性结果,不只是给模型 API 用的,还会反向影响某些运行时组件自己的行为。
最典型的就是 execute_code。
它并不只是“模型看见一个 execute_code schema 然后调用一下”这么简单; 它自己也需要知道当前 session 里到底有哪些工具可用,才能生成正确的 sandbox 能力边界。
这说明 Hermes 的工具系统不是单向的:
不是 registry -> schema -> model 这么一条线, 而是 schema 解析结果本身还会回流到运行时其他组件。
AGENTS.md 里还特别提醒过一件事:
_last_resolved_tool_names 是 process-global,delegate_tool.py 的 _run_single_child() 会在子 agent 执行时保存和恢复它。
这进一步说明工具系统已经不仅是配置问题,而是并发上下文问题。
也就是说,当 Hermes 开始支持子 Agent 和多执行上下文时,“当前有哪些工具可用”也成了一份必须被保护的运行时状态。
7. handle_function_call() 才是工具系统真正落地执行的统一入口
如果说 get_tool_definitions() 解决的是“模型看见什么工具”,那么 handle_function_call() 解决的就是“模型请求执行之后,系统到底怎么调”。
这个函数从第 459 行开始,注释写得很直接:
Main function call dispatcher that routes calls to the tool registry.
从职责上看,它做了几层非常关键的事情。
7.1 先做参数类型矫正,而不是盲目信任 LLM JSON
在真正 dispatch 之前,handle_function_call() 第一件事就是:
function_args = coerce_tool_args(function_name, function_args)
而 coerce_tool_args() 上面整整一段注释都在解释:
LLM 经常会把数字写成字符串、把布尔值写成字符串,所以这里会根据注册 schema 的类型定义,把 "42" 尝试转成 42,把 "true" / "false" 尝试转成布尔。
这其实非常实用。
因为真实模型输出的 JSON,远没有大家想象得那么严格。
如果没有这层矫正,很多工具执行错误根本不是业务错误,而只是因为模型把 limit="10" 而不是 limit=10。
Hermes 在这里选择的不是“要求模型永远完美遵守 schema”,而是运行时主动做一层保守修复。
这是非常典型的工程取向:
对上游不完美输入做容错,让工具层尽量看到更接近正确类型的参数。
7.2 Agent loop 自己管理的工具,不允许误走 registry
接着往下,第 497 行附近会先判断 function_name 是否属于 _AGENT_LOOP_TOOLS。
当前集合是:
- todo
- memory
- session_search
- delegate_task
如果命中,handle_function_call() 不会真的 dispatch,而是直接返回:
这一点和上一章 run_agent.py 的 _invoke_tool() 正好互相印证。
也就是说,model_tools.py 自己明确承认:
有一批工具虽然 schema 在 registry 里,但执行权不在这里,而在 agent loop。
这再次说明 Hermes 工具系统不是平面模型。
Schema 可以统一暴露; 但执行时仍然要分出“运行时内核工具”和“普通 registry 工具”。
这是非常重要的架构边界。
7.3 正常工具统一走 registry.dispatch()
对于普通工具,handle_function_call() 最终都会落到 registry.dispatch()。
其中 execute_code 还有一个特例:
- 优先使用调用方传进来的 enabled_tools
- 否则回退到 _last_resolved_tool_names
- 再把这份 sandbox_enabled 传给 registry.dispatch()
这说明 execute_code 的执行权限不是固定写死,而是跟当前 session 实际启用工具强绑定。
而普通工具则带着 task_id、user_task 等上下文进入 dispatch。
换句话说,model_tools.py 做的不是“自己执行全部逻辑”,而是把执行入口统一、上下文补齐、边界分流之后,再交给 registry。
这个“薄,但关键”的位置,恰恰就是它的价值所在。
8. 工具调用前后还有插件 hook,这说明 Hermes 不是只想“能调”,而是想“可扩展地调”
继续看 handle_function_call(),还有一个容易被忽略但很重要的点:
在真正 dispatch 前后,它都会尝试调用 hermes_cli.plugins.invoke_hook():
- pre_tool_call
- post_tool_call
也就是说,Hermes 不只是允许插件注册新工具, 它还允许插件在既有工具调用生命周期前后插入扩展逻辑。
这会给整个系统带来非常多可能性:
- 审计
- 遥测
- 调用观测
- 安全策略注入
- 额外日志归档
- 按平台做工具调用适配
这类 hook 设计特别说明一件事:
Hermes 的工具系统不是“工具注册完就结束”,而是已经开始考虑工具调用生命周期的二次编排。
这意味着它并不满足于“有工具可用”,而是在往“可插拔能力运行时”方向走。
对于一个 Agent 框架来说,这种能力往往比单独多几个工具更值钱。
因为它决定系统后期是否还能继续增长,而不会在新增需求面前只能不断改核心代码。
9. 异步桥接看起来像细节,其实决定了工具系统能不能在多入口环境里稳定活着
model_tools.py 前半部分还有一块非常关键,但很容易被忽视的内容:
_async bridging。
文件开头从第 35 行开始,就专门实现了一套 sync -> async 的桥接逻辑,包括:
- 主线程持久 event loop:_get_tool_loop()
- worker thread 的 thread-local event loop:_get_worker_loop()
- 统一桥接入口:_run_async(coro)
注释里反复提到一个真实问题:
如果每次都用 asyncio.run(),虽然方便,但会创建并关闭新的 event loop;而缓存的 httpx / AsyncOpenAI 客户端还绑定在旧 loop 上,后续垃圾回收或复用时就会报 “Event loop is closed”。
Hermes 的解决方案是:
- CLI 主线程上用持久 loop
- worker thread 上用 thread-local 持久 loop
- 如果当前已经在一个运行中的 event loop 内,比如 gateway 异步栈,就另开线程跑 asyncio.run()
这段逻辑单看似乎不像“工具系统”,更像底层兼容代码。
但实际上,它恰恰决定了工具系统能否在 Hermes 的多入口环境里稳定工作。
别忘了,Hermes 不是只跑在 CLI:
- gateway 是 async 环境
- delegate_task 可能在线程池里跑
- RL 环境也有自己的执行上下文
如果没有这层桥接保护,工具 handler 只要稍微涉及 async client,就很容易在不同入口里出现 loop 生命周期冲突。
所以这部分虽然不直接定义工具,却决定了工具系统有没有资格成为“全局能力底盘”。
一个只能在单线程单入口下勉强可用的工具系统,不能算成熟 Agent 工程; 而 Hermes 明显已经在这里下过功夫。
10. 从 model_tools.py 往回看,你会发现 Hermes 真正做的是“能力治理”
把前面这些点连起来看,model_tools.py 其实并不是一个简单中间层。
它做的事情可以概括为四个字:能力治理。
这里的“治理”,具体体现在:
10.1 统一发现
所有内置工具、MCP 工具、插件工具,最终都要先经过统一 discovery。
10.2 统一过滤
不是注册了就给模型看,而是要经过 toolset、legacy 兼容、check_fn 可用性过滤。
10.3 统一暴露
最终给模型看的不是静态 schema 仓库,而是当前 session / platform / config 条件下真实可用的 definitions。
10.4 统一调度入口
真正执行时,统一通过 handle_function_call() 进入 dispatch,必要时再把 agent-loop 工具分流出去。
这四件事叠在一起,Hermes 的工具系统就不再是“很多工具”,而变成“很多能力被统一治理后形成的一套运行时接口”。
这就是为什么我会说,Tool Use 不是 Hermes 的一个附属功能,而是它的地基。
没有这层治理:
- 模型看到的能力边界会混乱
- 不同平台会暴露出不一致甚至错误的工具集合
- 子 Agent、沙箱、插件之间的权限边界会不断穿透
- registry、schema、实际依赖状态会频繁错位
- 工具调用失败会有大量根本不必要的类型和上下文错误
而 model_tools.py 的价值,就是尽量让这些问题在进入主循环之前就被收束掉。
11. 这一章真正重要的结论:Hermes 的工具系统之所以像“地基”,是因为它已经从“功能枚举”升级成“运行时接口层”
很多项目在介绍工具系统时,重点总放在:
- 有多少工具
- 能不能联网
- 能不能读文件
- 能不能执行代码
但如果你认真读当前 hermes-agent 仓库里的 model_tools.py,会发现 Hermes 真正更在意的不是“工具多不多”,而是:
- 工具发现是不是统一的
- 工具可见性是不是 runtime-aware 的
- schema 和真实能力是否严格对齐
- 内核工具和普通工具是否分层
- 跨入口、跨线程、跨插件环境下的调用路径是否稳定
这几个问题一旦处理不好,工具越多,系统越乱。
而 Hermes 在 model_tools.py 里给出的答案,已经明显不是“把函数接给模型”这种初级做法了。
它在做的是:
把工具系统收束成一个可配置、可裁剪、可过滤、可扩展、可分流、可在多运行时环境稳定工作的能力接口层。
这就是它为什么像地基。
因为 run_agent.py 的多轮闭环能否成立,很大程度上取决于 model_tools.py 有没有先把“模型到底能看见什么、这些能力到底能不能安全稳定落地”处理好。
没有这个前提,再漂亮的主循环也只是空转。
最后把地基收一下
基于当前 hermes-agent 仓库的 model_tools.py 现有源码,我认为 Hermes 的工具系统可以这样概括:
它不是把一组工具函数塞给模型,而是围绕 discovery、toolset filtering、runtime availability、schema 动态修正、agent-loop 分流、统一 dispatch 和 async bridging,搭起了一层真正可运营的能力接口层。
这个结论主要来自以下源码事实:
- _discover_tools() 通过导入即注册触发内置工具发现,并继续扩展到 MCP 与插件工具
- get_tool_definitions() 不是返回全部注册工具,而是按 toolset 和 check_fn 过滤后生成当前会话真正可见的 definitions
- execute_code 与 browser_navigate 的 schema 会基于 available_tool_names 动态修正,避免模型看见虚假能力
- _last_resolved_tool_names 说明工具可见性结果还会回流到其他运行时组件,成为上下文状态的一部分
- handle_function_call() 在 dispatch 前做参数类型矫正、Agent 内核工具分流、插件 hook、上下文补齐和统一执行入口管理
- _run_async() 及其持久 event loop 机制说明工具系统被设计为可在 CLI、Gateway、线程池等多入口环境稳定工作
所以,如果第二章的关键词是“执行闭环”,那么这一章的关键词就是:能力治理。
下一章,我们可以继续往 Hermes 的长期状态层推进,去看记忆系统:为什么它不是每次都失忆,以及 memory / session_search / 用户画像到底是怎样被嵌入执行内核的。