附录 AD|Tool Registry 与插件注册专章:Hermes 为什么工具系统不是一堆 if-else,而是一条统一注册总线
先把注册总线看成基础设施
很多人第一次给 Agent 接工具时,最容易写出一种很快能跑的结构:
- 在某个文件里手写一大堆 tool schema
- 再在另一个文件里写一大堆
if function_name == ... - 然后慢慢补更多映射表
这种写法前期很快, 但工具一多,系统就会迅速开始分裂:
- schema 在一处
- handler 在一处
- toolset 分组在一处
- 可用性判断又在另一处
- 插件工具还要额外开旁路
最后你会得到一个典型问题:
- 工具系统表面上能跑
- 但没有统一的注册总线
Hermes 在这件事上的处理非常工程化。
它没有把工具系统做成:
- 一个巨大的
if-else - 或几份互相同步的平行字典
而是明确以:
tools/registry.py
作为工具世界的中心注册表, 再让:
tools/*.pymodel_tools.pytoolsets.py- 插件系统
- Agent Loop
都围着这条注册总线工作。
这一章就专门回答这个问题:
Hermes 为什么坚持把工具系统做成一条统一的 Tool Registry,并且让插件注册也走同一条总线?
这一篇主要结合这些源码来看:
tools/registry.pymodel_tools.pytoolsets.pyhermes_cli/plugins.pyrun_agent.py
1. Hermes 最核心的判断:工具不是“函数集合”,而是“带元数据的运行时实体”
先把问题想透。
在一个真正能跑多轮 Agent 的系统里,一把工具不只是一个 Python 函数。
它至少还自带这些信息:
- 工具名
- 所属 toolset
- 给模型看的 schema
- 真正执行的 handler
- 依赖检查函数
- 需要的环境变量
- 是否异步
- 展示描述
- emoji / UI 元信息
- 结果大小限制
也就是说,对 Hermes 来说, 工具从来不是:
def web_search(...): ...
而是:
- 一份“函数 + 元数据 + 可用性 + 分组归属”的完整声明
这就是为什么 tools/registry.py 一上来就定义了:
ToolEntry
它把每个工具都包装成一个统一的元数据对象。
这一点很重要。
因为它决定了 Hermes 后面可以用一套统一机制完成:
- schema 收集
- toolset 解析
- availability 检查
- 执行 dispatch
- UI 展示
- 插件工具接入
如果没有这层统一元数据对象, 后面每多一种能力,就会多长出一套平行结构。
2. tools/registry.py 的核心角色:它不是工具实现文件,而是全系统的工具账本
tools/registry.py 文件头注释已经把自己的定位写得很清楚:
- 每个工具文件在模块导入时调用
registry.register() model_tools.py查询 registry- 不再维护自己的平行数据结构
这几句话非常值钱。
因为它说明 Hermes 做 Registry 的目的, 不只是“集中一下工具列表”, 而是主动消灭这类老系统常见问题:
- schema 一套
- handler 一套
- toolset map 一套
- requirements 一套
- availability 判断又单独来一套
Hermes 的答案是:
- 这些都应该回到同一份 registry 真相源
2.1 register(...) 是这条总线的入口
一个工具注册进来时,至少会带上:
nametoolsetschemahandlercheck_fnrequires_envis_async
也就是说,Registry 的入口不是:
- “把这个函数记住”
而是:
- “把这个工具的运行时身份完整声明出来”
2.2 name collision 也被显式看见
register(...) 里还有一段很值得注意:
- 如果已有同名工具,但来自不同 toolset
- 会打 warning
这说明 Hermes 不是把注册过程当成“静默覆盖”。
它至少要让你知道:
- 工具命名已经开始冲突
这对插件系统尤其关键。
因为一旦第三方工具也能接进来, 命名冲突就不再只是理论风险。
3. 为什么 Hermes 要让 tools/*.py 在 import 时自注册
Hermes 工具体系最核心的装配动作之一就是:
- 每个工具模块自己在 import 时调用
registry.register()
这点在 tools/registry.py 文件头注释和 model_tools.py 的 _discover_tools() 里都写得很清楚。
3.1 这意味着工具声明和工具实现被绑在一起
一个 tools/*.py 文件通常会同时包含:
- 工具实现
- 可用性检查
- schema
registry.register(...)
这样做的直接好处是:
- 你不用去另一个中心文件同步登记一遍
也就是说,Hermes 避免了这种高成本模式:
- 写工具实现
- 去 schema 表里加一条
- 去 dispatch 表里加一条
- 去 toolset 表里再加一条
在 Hermes 里,工具模块自己对自己的注册负责。
3.2 model_tools.py 只负责“触发发现”,不负责重复维护定义
model_tools.py 的 _discover_tools() 会 import 一串工具模块:
tools.web_toolstools.terminal_tooltools.file_toolstools.skills_tool- ...
这些 import 的真正意义不是“为了调用里面的函数”, 而是:
- 触发模块级
registry.register()
这就是为什么 model_tools.py 文件头说它是:
- thin orchestration layer over the tool registry
它不再是工具定义中心, 而只是:
- 发现工具
- 向外暴露公共 API
这让系统分层变得清楚很多。
4. Registry 真正统一了哪些事情:schema、dispatch、toolset、availability 都回到了同一份数据
看 tools/registry.py 的方法集合,你会发现它解决的不是单点问题,而是一整组工具系统的基础问题。
4.1 schema retrieval
get_definitions(...)get_schema(...)
这让“给模型看的函数定义”回到 registry。
4.2 dispatch
dispatch(...)
这让“按工具名执行 handler”回到 registry。
4.3 toolset / requirements / availability
get_toolset_for_tool(...)get_tool_to_toolset_map(...)is_toolset_available(...)check_toolset_requirements(...)get_available_toolsets(...)get_toolset_requirements(...)
这让“工具属于哪个组、这个组是否可用、依赖什么环境变量”也回到 registry。
4.4 UI / 展示元数据
get_emoji(...)max_result_size_chars
这让“怎么显示工具”也不用另开一套表。
这正是统一注册总线最重要的价值:
- 一份声明
- 多个消费方共享
如果少了这层, Hermes 后面的 CLI、Gateway、Prompt Builder、插件系统、toolset 选择器都会开始各自维护自己的工具世界观。
5. model_tools.get_tool_definitions(...):Hermes 为什么还需要一个编排层,而不是直接让 run_agent 读 registry
如果 registry 已经这么强,为什么还要有 model_tools.py?
因为 Hermes 还需要一层“工具选择与运行时编排”。
get_tool_definitions(...) 做的核心事情是:
5.1 先根据 enabled / disabled toolsets 算出候选工具
它会:
- 解析
enabled_toolsets - 或在
disabled_toolsets基础上做差集 - 支持 legacy toolset 名称
也就是说,它先解决的是:
- 这一轮到底允许哪些工具进入模型视野
5.2 再调用 registry 做 availability 过滤
候选工具出来后,真正生成 schema 时还是会回到:
registry.get_definitions(...)
这里会执行每个工具的 check_fn()
只有通过检查的工具,才真的出现在最终的 OpenAI-format tool definitions 里。
这一步非常关键。
因为它说明 Hermes 做的是两层过滤:
- toolset 级别的“想不想给”
- runtime 级别的“现在能不能用”
5.3 再做 schema 后处理
后面 model_tools.py 还会根据最终可用工具做一些动态修正:
execute_code的 sandbox tool schema 只保留当前真的可用的工具browser_navigate在 web 工具不可用时,会删掉 description 里对它们的引用
这说明 model_tools.py 的意义不是和 registry 重复, 而是:
- 把 registry 产出的工具声明,修整成一轮具体 API 调用真正该看到的版本
所以它更像:
- registry 之上的运行时编排层
而不是:
- 第二套工具注册中心
6. handle_function_call(...):Hermes 执行工具时也坚持先走统一总线,再让 Agent Loop 接管特例
model_tools.py 的另一个核心入口是:
handle_function_call(...)
这个函数特别适合帮助你理解 Hermes 的执行边界。
6.1 默认路径:统一走 registry.dispatch(...)
绝大多数普通工具,最终都会走:
registry.dispatch(function_name, function_args, ...)
也就是说,Hermes 的默认心智模型是:
- 工具执行应当由统一注册表分发
而不是:
- 在 Agent Loop 里一层层手写
if name == ...
6.2 但 Hermes 也明确承认:有些工具不属于普通 registry dispatch
源码里专门定义了:
_AGENT_LOOP_TOOLS = {"todo", "memory", "session_search", "delegate_task"}
这些工具如果落到 handle_function_call(...), 会直接返回:
must be handled by the agent loop
这说明 Hermes 的态度不是教条式的“所有工具都必须走 registry”。
它更准确的态度是:
- 默认走统一总线
- 但需要 agent-level state 的工具,由 Agent Loop 接管
这和前面你让我补过的“Agent Loop 接管工具专章”正好能接上。
6.3 工具执行前后,插件 hook 也挂在同一条链上
handle_function_call(...) 在真正 dispatch 前后,还会调用:
invoke_hook("pre_tool_call", ...)invoke_hook("post_tool_call", ...)
这意味着插件不是绕开工具总线插进去的, 而是挂在工具总线两端。
这点非常重要。
因为它保证了插件和内建工具没有两套执行世界。
Hermes 依然在坚持:
- 一条总线
- 插件只是挂载点,不是旁路系统
7. toolsets.py 为什么也要围着 registry 转:静态工具集与插件工具集最终必须汇合
如果只有内建工具, toolsets.py 完全可以只维护一份静态 TOOLSETS 字典。
但 Hermes 没停在这里。
看 toolsets.py 后半段,你会发现它专门补了对插件工具集的兼容:
_get_plugin_toolset_names()get_all_toolsets()validate_toolset(...)resolve_toolset(...)
7.1 插件 toolset 不是写死在 TOOLSETS 里的
_get_plugin_toolset_names() 会直接去看:
registry._tools.values()
把所有:
entry.toolset not in TOOLSETS
的工具集名找出来。
也就是说,在 Hermes 里, 插件工具集并不要求你提前改 toolsets.py。
只要插件注册工具时声明了某个 toolset, 这个 toolset 就能被动态感知到。
7.2 resolve_toolset(...) 会对未知静态 toolset 回退到 registry
如果某个 toolset 名称不在静态 TOOLSETS 里, 但在插件工具集名集合里, Hermes 会直接从 registry 里把属于这个 toolset 的工具都找出来。
这一步特别重要。
因为它说明 Hermes 不是把插件工具“挂进 registry 但忘了 toolset 世界”。
它在有意识地让:
- 静态工具集
- 动态插件工具集
最后汇合到同一套解析逻辑里。
7.3 get_all_toolsets() 还会为插件工具集生成 synthetic entries
这样像 hermes tools 之类的 UI, 就能把插件 toolset 也展示出来。
这背后的方法论很清楚:
- 插件工具不是二等公民
- 一旦注册成功,就应该尽量进入 Hermes 原有的工具选择与展示体系
8. 插件工具为什么能天然接进这条总线:因为 PluginContext.register_tool() 直接委托给 registry
这就是这章最关键的连接点。
hermes_cli/plugins.py 里的:
PluginContext.register_tool(...)
最终做的事情就是:
- 调
registry.register(...)
然后顺手把工具名记进:
_plugin_tool_names
这意味着插件工具并不是:
- 单独一张注册表
- 单独一个 dispatch 分支
- 单独一套 schema 生成逻辑
而是直接进入 Hermes 现有的 Tool Registry 世界。
这是一个非常关键的工程选择。
因为它让插件作者只需要声明:
- 工具名
- toolset
- schema
- handler
- check_fn
- requires_env
然后后面的事情就自动复用 Hermes 原生机制:
- schema 出现在
get_tool_definitions(...) - toolset 进入
toolsets.py的动态解析 - availability 出现在 registry 检查里
- dispatch 走统一
registry.dispatch(...) - UI 展示也能从 registry 拿元数据
如果没有这步“插件注册也走 registry”, Hermes 很快就会分裂出:
- 内建工具系统
- 插件工具系统
两套平行世界。
而它显然不想要这个结果。
9. Registry 还解决了哪些“只有系统做大后才会痛”的问题
9.1 async handler 桥接
registry.dispatch(...) 里会识别:
entry.is_async
然后通过 model_tools._run_async(...) 桥过去。
也就是说,异步工具不是另开一套调用协议, 而是被 registry 统一桥接。
9.2 异常格式统一
dispatch 时所有异常都会被兜成:
{"error": "..."}
这让 LLM、UI、测试面对的错误格式更稳定。
9.3 MCP 动态工具更新的“拆旧建新”
registry.deregister(...) 的注释里还专门提到:
- 这用于 MCP 动态工具发现变化时的 nuke-and-repave
这说明 Registry 不只是启动时的一次性静态表, 它还能支持:
- 运行中的动态工具集变化
这对未来扩展特别关键。
10. 测试侧面说明了什么:Hermes 在验证“统一分发”,不是验证某个函数恰好能跑
虽然这一章不靠单个测试文件支撑,但已有测试已经侧面说明了几件关键事。
10.1 test_invoke_tool_dispatches_to_handle_function_call
tests/run_agent/test_run_agent.py 里这个测试验证:
_invoke_tool(...)对普通工具应路由到handle_function_call(...)
这说明 Agent Loop 对普通工具的默认态度就是:
- 走统一总线
而不是自己私下 dispatch。
10.2 test_skills_prompt_derives_available_toolsets_from_loaded_tools
同一个测试文件里,还有一组关于 skills prompt 的测试, 会根据实际加载的工具反推出:
available_toolsets
这说明 toolset 不是纯文档概念, 而是会反向影响 Prompt Builder 的运行时输入。
10.3 telegram_menu_commands 一类测试
tests/hermes_cli/test_commands.py 里关于 Telegram 命令菜单的测试, 本质上也在验证:
- 工具 / skill / plugin 命令最终必须进入统一的命令与展示体系
换句话说,Hermes 的统一总线不是只为了“代码好看”, 而是为了让:
- Prompt
- UI
- Dispatch
- Plugin
- Toolset
这些模块讲的是同一种工具语言。
11. 读完这一篇记住 5 点
11.1 工具系统一旦变复杂,就必须有单一事实源
schema、handler、toolset、availability、UI 元数据, 最好不要散在五个地方各维护一份。
11.2 默认走统一总线,特例再显式接管
Hermes 的做法不是“什么都强行 registry 化”, 而是:
- 普通工具走 registry
- 必须依赖 agent-level state 的工具明确由 Agent Loop 接管
这样边界最清楚。
11.3 插件工具最好复用原生工具注册机制
如果插件工具单独再来一套 schema / dispatch / toolset 逻辑, 系统迟早会裂成两半。
11.4 toolset 不是装饰,它是运行时过滤与提示词装配的一部分
Hermes 里 toolset 会影响:
- 工具是否进入本轮 API 调用
- Skills prompt 如何展示可用能力
- UI 如何让用户开关能力
所以它必须和 Registry 保持同一真相源。
11.5 好的 Registry 不只是“存东西”,还要承接演化
异步桥接、统一错误格式、动态 deregister、 插件工具集并入静态工具集解析, 这些都说明真正成熟的 Registry 是系统演化的基础设施。
最后把注册机制收一下
Hermes 的工具系统之所以能在内建工具、插件工具、toolset 选择、Prompt Builder、Gateway、CLI 之间保持一致, 核心原因不在于它有多少工具, 而在于它先建立了一条统一的 Tool Registry 总线。
顺着源码看,你会发现这条总线承担了整套工具世界的公共事实:
tools/*.py在 import 时自注册,把实现和声明绑在一起tools/registry.py持有工具的 schema、handler、toolset、availability 与元数据model_tools.py在 Registry 之上做运行时编排、toolset 过滤和 schema 后处理toolsets.py又把静态工具集和插件工具集统一解析到同一逻辑里PluginContext.register_tool()则让第三方工具直接复用这条总线,而不是另起炉灶
所以这章最值得你带走的一句话是:
当 Agent 开始拥有很多工具时,你真正需要设计的,往往不是“更多工具”, 而是“一条让所有工具声明、过滤、展示、执行都说同一种语言的注册总线”。
Hermes 的 Tool Registry,本质上就是这条工程原则在源码里的落地版本。