Hermes Agent 工具系统源码全解析
注册表自注册、按需加载与动态 Schema 重写 [04]
导读 [04] Hermes Agent 工具系统源码全解析:589 行的注册表如何驱动 70+ 工具自注册、28 个工具集按需加载与动态 Schema 重写 |
[04] Hermes Agent 工具系统源码全解析:589 行的注册表如何驱动 70+ 工具自注册、28 个工具集按需加载与动态 Schema 重写
> TL;DR:Hermes Agent 的工具系统不是一个大 switch-case。tools/registry.py(589行)是一个单例注册表,每个工具在模块顶层调用 registry.register() 自注册;model_tools.py 再根据启用的工具集做过滤、缓存、动态 Schema 重写。这篇从 registry.register() 到 dispatch() 的完整链路拆一遍——自注册发现、check_fn TTL 缓存、跨工具集重名保护、动态 Schema 覆盖。
---
上篇拆了 System Prompt 组装,这篇拆 Agent 的「手和脚」——工具系统。
你可能觉得工具系统没什么好讲的:不就是一堆函数挂上去吗?但 Hermes 的工具系统有 4 个值得单独写一篇文章的设计:
1. 自注册发现——70+ 个工具文件不会在代码里显式 import,而是通过 AST 扫描自动发现 2. check_fn TTL 缓存——每个工具集的可用性检查不是每次请求都跑,而是 30 秒缓存 3. 跨工具集重名保护——MCP 插件注册的工具不能覆盖内置工具(除非主动 opt-in) 4. 动态 Schema 重写——execute_code、discord 等工具的 Schema 在运行时根据配置重写
下面从注册表源码逐层拆。
---
1. 注册表内核:ToolRegistry 单例
入口文件 tools/registry.py(589 行),最尾部创建了模块级单例:
# Module-level singleton
registry = ToolRegistry()
所有工具文件都从模块顶层导入这个 registry 并调用 registry.register()。这个单例是整个工具系统的唯一真相来源——model_tools.py、run_agent.py、cli.py 都从它这里查询,没有平行数据。
ToolRegistry 的数据结构很简单——一个 Dict[str, ToolEntry]:
class ToolEntry:
__slots__= (
"name","toolset", "schema", "handler", "check_fn",
"requires_env","is_async", "description", "emoji",
"max_result_size_chars","dynamic_schema_overrides",
)
关键字段:

图 1:复杂表格已转换为 PNG,降低公众号导入变形风险。
---
2. 自注册发现:不靠手动 import,靠 AST 扫描
传统做法是把所有工具模块写在一张 import 表里。Hermes 不做这个——它通过 AST 扫描来发现哪些模块是"工具"。
入口在 discover_builtin_tools():
def discover_builtin_tools(tools_dir=None) -> List[str]:
tools_path= Path(tools_dir) if tools_dir is not None else Path(__file__).resolve().parent
module_names= [
f"tools.{path.stem}"
forpath in sorted(tools_path.glob("*.py"))
ifpath.name not in {"__init__.py", "registry.py", "mcp_tool.py"}
and_module_registers_tools(path)# ← AST 扫描
]
imported= []
formod_name in module_names:
try:
importlib.import_module(mod_name)
imported.append(mod_name)
exceptException as e:
logger.warning("Couldnot import tool module %s: %s", mod_name, e)
returnimported
_module_registers_tools() 通过 Python 的 ast 模块分析源码——它会真正解析语法树,检查脚本的模块级别是否存在 registry.register(...) 调用:
def _module_registers_tools(module_path: Path) -> bool:
try:
source= module_path.read_text(encoding="utf-8")
tree= ast.parse(source, filename=str(module_path))
except(OSError, SyntaxError):
returnFalse
returnany(_is_registry_register_call(stmt) for stmt in tree.body)
为什么是 AST 而不是试 import?因为 import 失败可能导致级联崩溃——某个工具依赖的库没装,import 就抛异常。AST 扫描无副作作用。
def _is_registry_register_call(node: ast.AST) -> bool:
ifnot isinstance(node, ast.Expr) or not isinstance(node.value, ast.Call):
returnFalse
func= node.value.func
return(
isinstance(func,ast.Attribute)
andfunc.attr == "register"
andisinstance(func.value, ast.Name)
andfunc.value.id == "registry"
)
这段代码检查的是:表达式是否是 registry.register(...) 的 AST 结构(Name('registry') → Attribute('register') → Call)。
> 扫描发现后,Hermes 会自动 import 匹配的模块。import 触发模块顶层的 registry.register() 调用,工具就注册了。
---
3. register():从工具文件到注册表
每个工具文件的末尾(或模块顶层)都有一行类似这样的代码:
# 摘自 tools/browser_tool.py(示意,非真实代码)
from tools.registry import registry, tool_result
def _handle_browser_navigate(args):
...
registry.register(
name="browser_navigate",
toolset="browser",
schema={
"description":"导航到指定 URL",
"parameters":{
"type":"object",
"properties":{
"url":{"type": "string", "description": "目标 URL"}
},
"required":["url"]
}
},
handler=_handle_browser_navigate,
check_fn=_check_playwright_installed,#实际检查函数
requires_env=["PLAYWRIGHT_BROWSER_PATH"],
)
register() 的内部逻辑做了三件事:
def register(self, name, toolset, schema, handler, check_fn=None,
requires_env=None,is_async=False, description="",
emoji="",max_result_size_chars=None,
dynamic_schema_overrides=None,override=False):
withself._lock:
existing= self._tools.get(name)
ifexisting and existing.toolset != toolset:
#跨工具集重名保护
both_mcp= (
existing.toolset.startswith("mcp-")
andtoolset.startswith("mcp-")
)
ifboth_mcp:
pass#MCP 同名工具间的覆盖是合法的
elifoverride:
pass#显式 opt-in 覆盖
else:
logger.error("Toolregistration REJECTED: '%s' ...")
return#❌ 拒绝注册
#注册工具
self._tools[name]= ToolEntry(...)
ifcheck_fn and toolset not in self._toolset_checks:
self._toolset_checks[toolset]= check_fn
self._generation+= 1# ← 版本号递增,通知缓存失效
_generation 是一个单调递增的计数器。每次注册/注销/别名变更都会递增。model_tools.py 的缓存会把这个 generation 作为 cache key 的一部分——generation 不变说明注册表状态没变,可以直接返回缓存。
跨工具集重名保护的场景:
场景 | 行为 |
内置工具(toolset="terminal")+ MCP 插件注册同名 | ❌ 拒绝,除非 MCP 服务的工具覆盖 |
两个 MCP 服务注册同名 | ✅ 允许(后注册覆盖先注册) |
插件 `override=True` | ✅ 允许(插件作者明确 opt-in) |
---
4. check_fn TTL 缓存:30 秒弹性
check_fn 是工具集可用性检测函数。例如 terminal 工具集的 check_fn 探测 Docker daemon、Modal SDK 是否存在;browser 工具集的 check_fn 探测 playwright 是否安装。
如果每次获取工具定义都重新跑一遍这些检测,启动时还好,但在一个长驻进程里(Gateway)就浪费了。Hermes 用了 30 秒 TTL 缓存:
_CHECK_FN_TTL_SECONDS = 30.0
_check_fn_cache: Dict[Callable, tuple[float, bool]] = {}
def _check_fn_cached(fn: Callable) -> bool:
now= time.monotonic()
with_check_fn_cache_lock:
cached= _check_fn_cache.get(fn)
ifcached is not None:
ts,value = cached
ifnow - ts < _CHECK_FN_TTL_SECONDS:
returnvalue# ← 命中缓存
try:
value= bool(fn())
exceptException:
value= False
with_check_fn_cache_lock:
_check_fn_cache[fn]= (now, value)
returnvalue
为什么 30 秒?注释里写了理由:
> 30s TTL chosen so env-var changes (hermes tools enable foo) still take effect in near-real-time without forcing a full cache flush on every call.
太短(如 5 秒)→ 每次 get_definitions() 都重新探测,浪费。太长(如 300 秒)→ 用户 hermes tools enable browser 后要等 5 分钟才能生效。30 秒是实测后的折中值。
同时还有一个单次调用的内存缓存(check_results: Dict[Callable, bool] = {})——在单次 get_definitions() 调用内,同一个 check_fn 不会被重复执行。
---
5. get_definitions():从注册表到模型的工具 JSON
model_tools.py 中的 get_tool_definitions() 是连接注册表和 Agent Loop 的桥梁。它的核心流程:
5.1 传入工具集列表,解析出工具名
def _compute_tool_definitions(enabled_toolsets, disabled_toolsets, ...):
tools_to_include= set()
ifenabled_toolsets is not None:
fortoolset_name in effective_enabled_toolsets:
ifvalidate_toolset(toolset_name):
resolved= resolve_toolset(toolset_name)
tools_to_include.update(resolved)
else:
#默认:加载所有工具集
forts_name in get_all_toolsets():
tools_to_include.update(resolve_toolset(ts_name))
#禁用工具集作为减法
ifdisabled_toolsets:
fortoolset_name in disabled_toolsets:
resolved= resolve_toolset(toolset_name)
tools_to_include.difference_update(resolved)
注意 kanban worker 的特殊处理:
if os.environ.get("HERMES_KANBAN_TASK") and "kanban" not in effective_enabled_toolsets:
effective_enabled_toolsets.append("kanban")
Dispatcher 启动了 kanban worker,即使 Profile 配置中没有 kanban 工具集,Worker 也必须有 kanban 工具集——否则 Worker 无法上报进度、无法完成/阻塞任务。这是强制注入,不受 Profile 配置影响。
5.2 动态 Schema 重写
工具注册时的 Schema 是静态的,但某些工具的 Schema 必须在运行时根据配置动态调整。
execute_code 是典型例子。它的 Schema 中有一个 sandbox_allowed_tools 数组,列出在 execute_code 沙箱中可用的工具。但这个列表必须实时反映实际启用的工具——如果 web_search 工具集被禁用了,沙箱中也必须不可见:
if "execute_code" in available_tool_names:
fromtools.code_execution_tool import SANDBOX_ALLOWED_TOOLS, build_execute_code_schema
sandbox_enabled= SANDBOX_ALLOWED_TOOLS & available_tool_names
dynamic_schema= build_execute_code_schema(sandbox_enabled, mode=_get_execution_mode())
fori, td in enumerate(filtered_tools):
iftd.get("function", {}).get("name") == "execute_code":
filtered_tools[i]= {"type": "function", "function": dynamic_schema}
break
discord 和 discord_admin 也有类似的动态重写——基于 Discord Bot 的实际 intents(从 GET /applications/@me 检测)隐藏不支持的操作:
_discord_schema_fns = {
"discord":"get_dynamic_schema_core",
"discord_admin":"get_dynamic_schema_admin",
}
for discord_tool_name in _discord_schema_fns:
ifdiscord_tool_name in available_tool_names:
fromtools import discord_tool as _dt
schema_fn= getattr(_dt, _discord_schema_fns[discord_tool_name])
dynamic= schema_fn()
ifdynamic:
#替换静态 Schema
除了这些硬编码的动态重写,Registry 还支持通用的 dynamic_schema_overrides 回调:
if entry.dynamic_schema_overrides is not None:
try:
overrides= entry.dynamic_schema_overrides()
ifisinstance(overrides, dict):
schema_with_name.update(overrides)
exceptException as exc:
logger.warning("dynamic_schema_overridesfor tool %s raised %s", name, exc)
这个回调在 get_definitions() 每次调用时执行,返回的 dict 会和静态 Schema 做浅合并。delegate_task 就利用这个机制——它的 max_concurrent_children 和 max_spawn_depth 参数描述必须反映当前配置值:
# tools/delegate_task.py(示意)
registry.register(
name="delegate_task",
toolset="agent",
dynamic_schema_overrides=_current_delegation_config,#运行时回调
...
)
5.3 缓存策略
整个 get_tool_definitions() 支持多层缓存:
# 最外层:model_tools 的内存缓存(以 registry generation + 配置 mtime 为 key)
if quiet_mode:
cache_key= (
frozenset(enabled_toolsets),
frozenset(disabled_toolsets),
registry._generation,#注册表版本
(cfg_path.stat().st_mtime_ns,cfg_path.stat().st_size),# 配置文件指纹
bool(os.environ.get("HERMES_KANBAN_TASK")),
)
cached= _tool_defs_cache.get(cache_key)
ifcached is not None:
returnlist(cached)
# 次外层:registry.get_definitions() 内部的 check_fn TTL 缓存
filtered_tools = registry.get_definitions(tools_to_include, quiet=quiet_mode)
两个缓存层级互不覆盖——外层按配置+版本缓存整个结果列表,内层只缓存 check_fn 的执行结果。
---
6. dispatch():从模型调用到工具执行
当模型返回 tool_calls 时,Agent Loop 调用 registry.dispatch():
def dispatch(self, name: str, args: dict) -> str:
entry= self.get_entry(name)
ifnot entry:
returnjson.dumps({"error": f"Unknown tool: {name}"})
try:
ifentry.is_async:
frommodel_tools import _run_async
return_run_async(entry.handler(args))
returnentry.handler(args)
exceptException as e:
logger.exception("Tool%s dispatch error: %s", name, e)
raw= f"Tool execution failed: {type(e).__name__}: {e}"
try:
frommodel_tools import _sanitize_tool_error
sanitized= _sanitize_tool_error(raw)
exceptException:
sanitized= raw
returnjson.dumps({"error": sanitized})
异常处理的细节:错误信息通过 _sanitize_tool_error() 过滤——防止 framing token、CDATA、反引号等结构性噪音污染模型的下一次推理。
每个工具的处理函数必须返回 JSON 字符串。注册表提供了两个通用辅助函数:
def tool_error(message, **extra) -> str:
result= {"error": str(message)}
ifextra:
result.update(extra)
returnjson.dumps(result, ensure_ascii=False)
def tool_result(data=None, **kwargs) -> str:
ifdata is not None:
returnjson.dumps(data, ensure_ascii=False)
returnjson.dumps(kwargs, ensure_ascii=False)
使用方式:
return tool_result(success=True, count=42)
# → '{"success": true, "count": 42}'
return tool_error("file not found", code=404)
# → '{"error": "file not found", "code": 404}'
---
7. 工具集模型:从注册到解析的完整链路
把整条链路串起来:
工具文件(tools/*.py)# 每个工具独立文件
│模块顶层 registry.register()
▼
ToolRegistry(tools/registry.py)# 单例注册表
│get_definitions() → 按 check_fn 过滤 → 缓存
▼
model_tools.get_tool_definitions()# 工具集解析 + 动态 Schema 重写
│Agent Loop 获取 → 注入 API 请求
▼
LLM 返回 tool_calls
│
▼
Agent Loop → registry.dispatch(name, args)# 执行工具
│异常 → _sanitize_tool_error() → 返回 JSON
▼
工具结果回填对话历史,进入下一轮迭代
---
8. 这篇文章的代码索引

图 2:复杂表格已转换为 PNG,降低公众号导入变形风险。
已注册的工具命名来源:每个工具文件就是一个工具,文件名即工具名(在 registry.register() 的 name 参数中指定)。
---
下一篇拆记忆矩阵——MEMORY.md、Hindsight 向量库、state.db SQLite 三层架构的协同与冲突。
---
*本系列基于 Hermes Agent v0.15.2 源码。工具系统文件:tools/registry.py(589 行)+ model_tools.py(1174 行)。*
夜雨聆风