Hermes Agent 源码专题【左扬精讲】—— 工具注册与分发机制
Hermes Agent 源码专题【左扬精讲】—— 工具注册与分发机制
Hermes Agent 内置了 100+ 工具,覆盖文件操作、终端执行、Web 搜索、浏览器自动化、技能管理等。这些工具如何被发现、如何被组织、如何被调用?
本篇将深入剖析 Hermes 的工具系统 —— 一个模块化、可扩展的工具注册与分发架构。
┌───────────────────────────────────────────────────────┐
│ tools/registry.py ← ToolRegistry 核心类 │
│ tools/*.py ← 工具模块(自注册) │
│ toolsets.py ← 工具集定义 │
│ model_tools.py ← 工具发现 + Schema 导出 │
│ run_agent.py ← AIAgent 核心循环 │
└───────────────────────────────────────────────────────┘
工具注册 ToolRegistry 自注册模式 check_fn 工具集 工具分发
本篇学习重点
必须掌握
- 理解 ToolRegistry 核心数据结构
- 理解自注册模式工作原理
- 理解 check_fn 可用性检查机制
- 理解 handle_function_call 分发流程
需要了解
- AST 分析判断模块是否注册工具
- 动态 Schema 机制
- 工具集递归解析
一、概览:工具系统是什么
概览:
Hermes 的工具系统负责:
- 工具发现:自动扫描 tools/ 目录,发现所有自注册的工
- 工具注册:通过 registry.register() 将工具添加到中央注册表
- 可用性检查:通过 check_fn 判断工具是否可用
- 工具分发:将模型返回的函数调用路由到对应工具处理器
一句话定位:工具系统是 Hermes 的"能力接口",决定 Agent 能调用哪些能力。
二、定位:入口文件与核心类
定位:
核心文件:
- tools/registry.py — ToolRegistry 核心类(第 151 行)
- toolsets.py — 工具集定义(第 89 行 TOOLSETS 字典)
- model_tools.py — 工具发现与分发入口
关键类:
- ToolEntry(第 77 行)— 工具元数据结构
- ToolRegistry(第 151 行)— 单例注册表
关键函数:
- discover_builtin_tools(第 57 行)— 工具发现
- _module_registers_tools(第 42 行)— AST 分析
- _check_fn_cached(第 126 行)— check_fn 缓存
三、结构:模块组织关系
结构:
工具系统的模块依赖关系:
tools/registry.py ← 依赖链最底层,不导入任何工具模块
↑
tools/*.py ← 每个工具模块导入 registry
↑
model_tools.py ← 触发工具发现,提供公共 API
↑
run_agent.py ← AIAgent 核心循环
cli.py ← CLI 命令
batch_runner.py ← 批处理
工具文件组织
tools/
├── __init__.py
├── registry.py ← ToolRegistry 核心
├── mcp_tool.py ← MCP 工具支持
├── file_tools.py ← read_file, write_file, patch, search_files
├── web_tools.py ← web_search, web_extract
├── browser_tools.py ← browser_navigate, browser_snapshot, ...
├── terminal_tool.py ← terminal, process
├── skills_tools.py ← skills_list, skill_view, skill_manage
├── delegate_tool.py ← delegate_task
└── ... ← 100+ 工具
细节:
见 tools/registry.py 第 77-106 行的 ToolEntry 数据结构:
class ToolEntry:
"""Metadata for a single registered tool."""
__slots__ = (
"name", "toolset", "schema", "handler", "check_fn",
"requires_env", "is_async", "description", "emoji",
"max_result_size_chars", "dynamic_schema_overrides",
)
def __init__(self, name, toolset, schema, handler, check_fn,
requires_env, is_async, description, emoji,
max_result_size_chars=None, dynamic_schema_overrides=None):
self.name = name
self.toolset = toolset
self.schema = schema
self.handler = handler
self.check_fn = check_fn
self.requires_env = requires_env
self.is_async = is_async
self.description = description
self.emoji = emoji
self.max_result_size_chars = max_result_size_chars
self.dynamic_schema_overrides = dynamic_schema_overrides
四、行为:工具分发流程
行为:
工具从注册到分发的完整流程:
┌─────────────────────────────────────────────────────────────┐
│ 1. 工具发现(discover_builtin_tools) │
│ tools/registry.py 第 57 行 │
│ ↓ AST 分析,只导入包含 registry.register() 的模块 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 2. 工具注册(registry.register) │
│ 每个工具模块在文件末尾调用 │
│ ↓ 添加到 ToolRegistry._tools 字典 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 3. Schema 导出(get_definitions) │
│ model_tools.py │
│ ↓ check_fn 检查 + 动态 Schema │
│ ↓ 返回 OpenAI 格式的 tool schemas │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 4. 工具分发(handle_function_call) │
│ model_tools.py │
│ ↓ 参数类型强制转换 │
│ ↓ 插件 pre_tool_call 钩子 │
│ ↓ 调用 registry.dispatch │
│ ↓ 插件 post_tool_call 钩子 │
│ ↓ 返回 JSON 格式结果 │
└─────────────────────────────────────────────────────────────┘
细节:
自注册模式的实现:见 tools/registry.py 第 57-74 行
def discover_builtin_tools(tools_dir: Optional[Path] = None) -> List[str]:
"""Import built-in self-registering tool modules and return their module names."""
tools_path = Path(tools_dir) if tools_dir is not None else Path(__file__).resolve().parent
module_names = [
f"tools.{path.stem}"
for path in sorted(tools_path.glob("*.py"))
if path.name not in {"__init__.py", "registry.py", "mcp_tool.py"}
and _module_registers_tools(path) # 只导入包含 registry.register() 的模块
]
imported: List[str] = []
for mod_name in module_names:
try:
importlib.import_module(mod_name) # 触发自注册
imported.append(mod_name)
except Exception as e:
logger.warning("Could not import tool module %s: %s", mod_name, e)
return imported
细节:
AST 分析判断模块是否注册工具:见 tools/registry.py 第 29-54 行
def _is_registry_register_call(node: ast.AST) -> bool:
"""Return True when *node* is a ``registry.register(...)`` call expression."""
if not isinstance(node, ast.Expr) or not isinstance(node.value, ast.Call):
return False
func = node.value.func
return (
isinstance(func, ast.Attribute)
and func.attr == "register"
and isinstance(func.value, ast.Name)
and func.value.id == "registry"
)
def _module_registers_tools(module_path: Path) -> bool:
"""Return True when the module contains a top-level ``registry.register(...)`` call."""
try:
source = module_path.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(module_path))
except (OSError, SyntaxError):
return False
return any(_is_registry_register_call(stmt) for stmt in tree.body)
五、契约:核心 API 接口
契约:
ToolRegistry 的核心 API 契约:
核心方法签名
# 注册
def register(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) -> None
# 查询
def get_entry(name: str) -> Optional[ToolEntry]
def get_definitions(tool_names: Set[str]) -> List[dict] # OpenAI 格式
def get_registered_toolset_names() -> List[str]
# 分发
def dispatch(name: str, args: dict, **kwargs) -> str # JSON 字符串
# 工具集
def resolve_toolset(name: str) -> List[str] # 递归解析
check_fn 缓存机制
- TTL:30 秒(_CHECK_FN_TTL_SECONDS)
- 目的:避免频繁探测外部状态(Docker daemon、API Key)
- 缓存 Key:check_fn 函数对象本身
- 异常处理:抛出异常视为返回 False
动态 Schema 机制
dynamic_schema_overrides 回调函数在每次 get_definitions() 调用时执行,返回的字典会合并到 Schema 中。典型用途:delegate_task 的描述需要显示当前配置的 max_concurrent_children。
六、理由:为什么这样设计
理由:
为什么选择自注册而非显式注册?
答案:解耦工具定义和使用,遵守开闭原则。新增工具只需在 tools/ 目录创建文件并在末尾调用 registry.register(),无需修改核心代码。
为什么需要 check_fn?
答案:避免模型调用未配置的工具。如果工具需要 API Key 但用户没有配置,check_fn 返回 False,该工具的 Schema 就不会出现在 get_definitions() 返回结果中,模型不会尝试调用它。
为什么用 AST 分析而非直接导入所有模块?
答案:避免导入无用的辅助模块。tools/ 目录下有很多辅助模块(如 file_operations.py),它们不是工具但被其他工具模块导入。通过 AST 分析只导入真正注册工具的模块。
为什么 check_fn 缓存 TTL 设为 30 秒?
答案:工程权衡。外部状态(Docker daemon、API Key)在人类时间尺度上变化缓慢,频繁探测浪费资源;但用户通过 hermes tools 修改配置后期望快速生效。30 秒是这两者的平衡点。
What-if ─ 删除 ToolRegistry 中央注册表会发生什么?
① 每个工具需要手动添加到某个全局列表,维护成本高、易出错
② 新增工具需要修改核心文件,破坏开闭原则
③ 无法统一管理工具的可用性检查、错误处理、并发控制
④ 无法实现工具的按需加载和动态发现
七、细节:关键源码解析
ToolRegistry 类核心方法
见 tools/registry.py 第 151-305 行
class ToolRegistry:
def __init__(self):
self._tools: Dict[str, ToolEntry] = {} # 工具字典
self._toolset_checks: Dict[str, Callable] = {} # 工具集检查
self._toolset_aliases: Dict[str, str] = {} # 工具集别名
self._lock = threading.RLock() # 线程安全锁
self._generation: int = 0 # 版本计数器
def register(self, name, toolset, schema, handler, ...):
"""注册工具到注册表"""
with self._lock:
self._tools[name] = ToolEntry(...)
self._generation += 1
def get_definitions(self, tool_names: Set[str], quiet: bool = False):
"""返回 OpenAI 格式的工具 Schema,过滤不可用工具"""
result = []
entries_by_name = {entry.name: entry for entry in self._snapshot_entries()}
for name in sorted(tool_names):
entry = entries_by_name.get(name)
if entry.check_fn and not _check_fn_cached(entry.check_fn):
continue # check_fn 返回 False,跳过
result.append({"type": "function", "function": {**entry.schema, "name": entry.name}})
return result
def dispatch(self, name: str, args: dict, **kwargs) -> str:
"""执行工具处理器"""
entry = self.get_entry(name)
if not entry:
return json.dumps({"error": f"Unknown tool: {name}"})
try:
if entry.is_async:
return _run_async(entry.handler(args, **kwargs))
return entry.handler(args, **kwargs)
except Exception as e:
return json.dumps({"error": str(e)})
check_fn 缓存实现
见 tools/registry.py 第 120-148 行
_CHECK_FN_TTL_SECONDS = 30.0
_check_fn_cache: Dict[Callable, tuple[float, bool]] = {}
_check_fn_cache_lock = threading.Lock()
def _check_fn_cached(fn: Callable) -> bool:
"""Return bool(fn()), TTL-cached across calls. Swallows exceptions as False."""
now = time.monotonic()
with _check_fn_cache_lock:
cached = _check_fn_cache.get(fn)
if cached is not None:
ts, value = cached
if now - ts < _CHECK_FN_TTL_SECONDS:
return value # 缓存命中,直接返回
try:
value = bool(fn())
except Exception:
value = False
with _check_fn_cache_lock:
_check_fn_cache[fn] = (now, value)
return value
工具集定义
见 toolsets.py 第 89-200 行
TOOLSETS = {
"web": {
"description": "Web research and content extraction tools",
"tools": ["web_search", "web_extract"],
"includes": [] # 不包含其他工具集
},
"file": {
"description": "File manipulation tools: read, write, patch, search",
"tools": ["read_file", "write_file", "patch", "search_files"],
"includes": []
},
"browser": {
"description": "Browser automation for web interaction",
"tools": [
"browser_navigate", "browser_snapshot", "browser_click",
"browser_type", "browser_scroll", "browser_back",
"browser_press", "browser_get_images",
"browser_vision", "browser_console", "browser_cdp",
"browser_dialog", "web_search"
],
"includes": []
},
# 组合工具集 - 包含其他工具集
"safe": {
"description": "Safe toolkit without terminal access",
"tools": [],
"includes": ["web", "vision", "image_gen"] # 组合多个工具集
},
...
}
工具集递归解析
见 toolsets.py 第 630-701 行 resolve_toolset 函数
def resolve_toolset(name: str, visited: Set[str] = None) -> List[str]:
"""递归解析工具集,返回所有工具名列表"""
if visited is None:
visited = set()
# 循环检测
if name in visited:
return []
visited.add(name)
toolset = get_toolset(name)
if not toolset:
return []
# 收集直接工具
tools = set(toolset.get("tools", []))
# 递归解析包含的工具集
for included_name in toolset.get("includes", []):
included_tools = resolve_toolset(included_name, visited)
tools.update(included_tools)
return sorted(tools)
Lesson工具系统设计的三大原则 ─ (1) 自注册模式解耦工具定义和使用;(2) check_fn 机制保证只有可用工具对模型可见;(3) 工具集支持灵活分组和组合。理解这三条原则,就理解了 Hermes 工具系统的设计哲学。
本节小结
- ToolRegistry 是中央注册表,单例模式,线程安全
- 自注册模式:工具模块在导入时自动注册,无需修改核心代码
- AST 分析:只导入包含 registry.register() 的模块
- check_fn 缓存:30 秒 TTL,避免频繁探测外部状态
- 工具集支持嵌套组合,resolve_toolset 递归解析
- handle_function_call 统一处理分发、参数转换、插件钩子
八、FAQ 20 问
FAQ 分组说明
以下 20 组 FAQ 涵盖工具注册与分发机制的常见问题,按主题分为五组:架构(1-4)、注册(5-8)、可用性(9-12)、工具集(13-16)、分发(17-20)。
Q1. ToolRegistry 和普通的字典有什么区别?
ToolRegistry 不仅仅是字典,它封装了完整的工具生命周期管理。它包含版本计数器(支持缓存失效)、线程安全锁(支持并发访问)、check_fn 缓存(避免频繁探测)、工具集别名映射、动态 Schema 回调等高级特性。
Q2. 为什么要用 AST 分析判断模块是否注册工具?
为了避免导入无用的辅助模块。tools/ 目录下有很多辅助模块(如 file_operations.py、debug_helpers.py),它们不是工具但被其他工具模块导入。通过 AST 分析模块顶层是否有 registry.register() 调用,可以精确发现真正的工具模块。
Q3. 工具系统如何保证线程安全?
通过 RLock 锁和版本计数器。ToolRegistry._lock 是 threading.RLock(),序列化所有注册表变更操作。版本计数器 _generation 在每次变更时递增,外部缓存可以基于它失效。
Q4. 两个工具可以同名注册吗?
不可以,同名注册会被拒绝(除非是 MCP 到 MCP 的覆盖)。registry.register() 会检查是否存在同名工具,如果工具集不同则报错。如果是 MCP 到 MCP 的覆盖则允许。
Q5. check_fn 返回 False 时会发生什么?
该工具的 Schema 不会被包含在 get_definitions() 返回结果中。模型在生成工具调用时,只会看到通过 check_fn 检查的可用工具。如果模型尝试调用一个不可用的工具,会收到 "Unknown tool" 错误。
Q6. 30 秒的 check_fn TTL 是如何确定的?
这是一个工程权衡:外部状态(Docker daemon、API Key)在人类时间尺度上变化缓慢,但用户通过 hermes tools 修改配置后期望快速生效。30 秒既避免了频繁探测外部状态的开销,又保证了配置变更能在合理时间内生效。
Q7. 插件如何注册自己的工具?
插件通过 ctx.register_tool() 注册工具。这与内置工具的 registry.register() 调用方式相同,但插件注册的工具会被添加到独立的工具集中。
Q8. dynamic_schema_overrides 的典型使用场景是什么?
最典型的场景是 delegate_task 工具。它的描述需要显示当前配置的 max_concurrent_children(默认 3)和 max_spawn_depth(默认 2),这些值从 config.yaml 读取,运行时可能变化。
Q9. 工具的 is_async 字段有什么用?
标记异步工具,在 registry.dispatch() 中自动桥接到事件循环。如果一个工具的处理器是协程函数但被同步调用,_run_async() 会处理协程的执行。
Q10. 为什么需要 coerce_tool_args?
因为 LLM 经常返回不精确的类型。例如 JSON Schema 要求整数参数,但 LLM 可能返回字符串 "42"。如果不进行类型强制转换,工具执行会报错。
Q11. pre_tool_call 钩子和 post_tool_call 钩子有什么区别?
pre_tool_call 在工具执行前调用,可以阻止工具执行(返回 block_message);post_tool_call 在工具执行后调用,仅用于观测和日志。pre 钩子常用于权限检查和安全过滤,post 钩子常用于监控和计费。
Q12. 工具执行超时怎么处理?
工具执行本身不设超时,但 handle_function_call() 有 300 秒的硬超时。如果工具执行超过 300 秒,会被强制取消。
Q13. 工具集可以嵌套吗?最多嵌套几层?
工具集可以嵌套(通过 includes 字段),并且 resolve_toolset() 实现了循环检测。理论上可以嵌套任意层,但实践中建议保持扁平(1-2 层),便于理解和维护。
Q14. 为什么工具集要用 _HERMES_CORE_TOOLS 列表?
避免重复定义,保持跨平台一致性。所有平台的工具集都继承自核心工具列表,新增核心工具时只需修改一处。
Q15. 工具集的 safe 是什么意思?
safe 是不包含终端访问的安全工具集。它包含 web、vision、image_gen 工具,但不包含 terminal 等可能执行危险操作的工具。Webhook 等不可信来源默认使用 safe 工具集。
Q16. 工具集别名有什么用?
支持工具集的别名映射和 MCP 服务器的工具集注册。例如用户可以为 hermes-TG 设置别名 my-TG。
Q17. 工具执行出错时如何返回错误?
所有工具必须返回 JSON 字符串。注册表提供了 tool_error() 和 tool_result() 辅助函数,工具应使用它们返回结果,而不是直接返回字符串。
Q18. 工具的 max_result_size_chars 是什么?
限制工具返回结果的最大字符数。大结果会被截断,避免超过模型的上下文窗口。这对于文件读取(默认 10 万字符)、Web 搜索等可能返回大量数据的工具很重要。
Q19. transform_tool_result 钩子有什么用?
允许插件在工具结果返回给模型之前进行转换。例如可以添加元数据、过滤敏感信息、格式化输出等。
Q20. 如何新增一个内置工具?
只需两步:在 tools/ 目录创建工具文件,并在文件末尾调用 registry.register()。无需修改 toolsets.py 或 model_tools.py。工具文件会自动被发现和导入。
FAQ 总纲
- ToolRegistry 是中央注册表,单例模式,线程安全
- 自注册模式解耦了工具定义和使用,新增工具无需修改核心代码
- check_fn 机制确保只有配置正确的工具才对模型可见
- 工具集系统支持灵活的分组和组合,满足不同平台的配置需求
- handle_function_call 是统一分发入口,处理参数转换、插件钩子、执行计时等横切关注点
- 动态 Schema 通过回调机制实现运行时配置注入
九、后续 Roadmap
预告:工具集详解与高级用法
本篇介绍了工具注册与分发机制,下一篇将深入讲解:
- 28 个内置工具集详解:从 file 到 browser
- 终端后端:local、docker、ssh、modal 等多种实现
- 浏览器自动化:CDP 协议、元素定位、iframe 处理
- 代码执行工具集:沙箱机制、安全隔离
- MCP 工具集成:MCP 服务器的动态工具发现
敬请期待!

浙公网安备 33010602011771号