registry = ToolRegistry()
kotlin
所有工具文件都从模块顶层导入这个 `registry` 并调用 `registry.register()`。这个单例是整个工具系统的唯一真相来源------`model_tools.py`、`run_agent.py`、`cli.py` 都从它这里查询,没有平行数据。
`ToolRegistry` 的数据结构很简单------一个 `Dict[str, ToolEntry]`:
```python
class ToolEntry:
__slots__ = (
"name", "toolset", "schema", "handler", "check_fn",
"requires_env", "is_async", "description", "emoji",
"max_result_size_chars", "dynamic_schema_overrides",
)
关键字段:
| 字段 | 类型 | 作用 |
|---|---|---|
name |
str | 工具名,全局唯一 |
toolset |
str | 所属工具集,用于分组启用/禁用 |
schema |
dict | OpenAI 格式的 JSON Schema |
handler |
Callable | 真正的工具函数 |
check_fn |
Callable | 可用性检测(如检测 playwright 是否安装) |
requires_env |
list | 需要的环境变量列表 |
is_async |
bool | 是否为异步处理器 |
dynamic_schema_overrides |
Callable | 运行时 Schema 覆盖函数 |
2. 自注册发现:不靠手动 import,靠 AST 扫描
传统做法是把所有工具模块写在一张 import 表里。Hermes 不做这个------它通过 AST 扫描来发现哪些模块是"工具"。
入口在 discover_builtin_tools():
python
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}"
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) # ← AST 扫描
]
imported = []
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
_module_registers_tools() 通过 Python 的 ast 模块分析源码------它会真正解析语法树,检查脚本的模块级别 是否存在 registry.register(...) 调用:
python
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):
return False
return any(_is_registry_register_call(stmt) for stmt in tree.body)
为什么是 AST 而不是试 import?因为 import 失败可能导致级联崩溃------某个工具依赖的库没装,import 就抛异常。AST 扫描无副作作用。
python
def _is_registry_register_call(node: ast.AST) -> bool:
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"
)
这段代码检查的是:表达式是否是 registry.register(...) 的 AST 结构(Name('registry') → Attribute('register') → Call)。
扫描发现后,Hermes 会自动 import 匹配的模块。import 触发模块顶层的
registry.register()调用,工具就注册了。
3. register():从工具文件到注册表
每个工具文件的末尾(或模块顶层)都有一行类似这样的代码:
python
# 摘自 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() 的内部逻辑做了三件事:
python
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):
with self._lock:
existing = self._tools.get(name)
if existing and existing.toolset != toolset:
# 跨工具集重名保护
both_mcp = (
existing.toolset.startswith("mcp-")
and toolset.startswith("mcp-")
)
if both_mcp:
pass # MCP 同名工具间的覆盖是合法的
elif override:
pass # 显式 opt-in 覆盖
else:
logger.error("Tool registration REJECTED: '%s' ...")
return # ❌ 拒绝注册
# 注册工具
self._tools[name] = ToolEntry(...)
if check_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 缓存:
python
_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)
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
为什么 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 传入工具集列表,解析出工具名
python
def _compute_tool_definitions(enabled_toolsets, disabled_toolsets, ...):
tools_to_include = set()
if enabled_toolsets is not None:
for toolset_name in effective_enabled_toolsets:
if validate_toolset(toolset_name):
resolved = resolve_toolset(toolset_name)
tools_to_include.update(resolved)
else:
# 默认:加载所有工具集
for ts_name in get_all_toolsets():
tools_to_include.update(resolve_toolset(ts_name))
# 禁用工具集作为减法
if disabled_toolsets:
for toolset_name in disabled_toolsets:
resolved = resolve_toolset(toolset_name)
tools_to_include.difference_update(resolved)
注意 kanban worker 的特殊处理:
python
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 工具集被禁用了,沙箱中也必须不可见:
python
if "execute_code" in available_tool_names:
from tools.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())
for i, td in enumerate(filtered_tools):
if td.get("function", {}).get("name") == "execute_code":
filtered_tools[i] = {"type": "function", "function": dynamic_schema}
break
discord 和 discord_admin 也有类似的动态重写------基于 Discord Bot 的实际 intents(从 GET /applications/@me 检测)隐藏不支持的操作:
python
_discord_schema_fns = {
"discord": "get_dynamic_schema_core",
"discord_admin": "get_dynamic_schema_admin",
}
for discord_tool_name in _discord_schema_fns:
if discord_tool_name in available_tool_names:
from tools import discord_tool as _dt
schema_fn = getattr(_dt, _discord_schema_fns[discord_tool_name])
dynamic = schema_fn()
if dynamic:
# 替换静态 Schema
除了这些硬编码的动态重写,Registry 还支持通用的 dynamic_schema_overrides 回调:
python
if entry.dynamic_schema_overrides is not None:
try:
overrides = entry.dynamic_schema_overrides()
if isinstance(overrides, dict):
schema_with_name.update(overrides)
except Exception as exc:
logger.warning("dynamic_schema_overrides for tool %s raised %s", name, exc)
这个回调在 get_definitions() 每次调用时执行,返回的 dict 会和静态 Schema 做浅合并 。delegate_task 就利用这个机制------它的 max_concurrent_children 和 max_spawn_depth 参数描述必须反映当前配置值:
python
# tools/delegate_task.py(示意)
registry.register(
name="delegate_task",
toolset="agent",
dynamic_schema_overrides=_current_delegation_config, # 运行时回调
...
)
5.3 缓存策略
整个 get_tool_definitions() 支持多层缓存:
python
# 最外层: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)
if cached is not None:
return list(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():
python
def dispatch(self, name: str, args: dict) -> str:
entry = self.get_entry(name)
if not entry:
return json.dumps({"error": f"Unknown tool: {name}"})
try:
if entry.is_async:
from model_tools import _run_async
return _run_async(entry.handler(args))
return entry.handler(args)
except Exception as e:
logger.exception("Tool %s dispatch error: %s", name, e)
raw = f"Tool execution failed: {type(e).__name__}: {e}"
try:
from model_tools import _sanitize_tool_error
sanitized = _sanitize_tool_error(raw)
except Exception:
sanitized = raw
return json.dumps({"error": sanitized})
异常处理的细节:错误信息通过 _sanitize_tool_error() 过滤------防止 framing token、CDATA、反引号等结构性噪音污染模型的下一次推理。
每个工具的处理函数必须返回 JSON 字符串。注册表提供了两个通用辅助函数:
python
def tool_error(message, **extra) -> str:
result = {"error": str(message)}
if extra:
result.update(extra)
return json.dumps(result, ensure_ascii=False)
def tool_result(data=None, **kwargs) -> str:
if data is not None:
return json.dumps(data, ensure_ascii=False)
return json.dumps(kwargs, ensure_ascii=False)
使用方式:
python
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. 工具集模型:从注册到解析的完整链路
把整条链路串起来:
bash
工具文件(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. 这篇文章的代码索引
| 文件 | 行数 | 关键函数 |
|---|---|---|
tools/registry.py |
589 | register(), dispatch(), get_definitions(), discover_builtin_tools(), ToolEntry |
model_tools.py |
1174 | get_tool_definitions(), _compute_tool_definitions(), _tool_defs_cache |
toolsets.py |
--- | get_all_toolsets(), resolve_toolset(), validate_toolset() |
tools/code_execution_tool.py |
--- | SANDBOX_ALLOWED_TOOLS, build_execute_code_schema() |
tools/discord_tool.py |
--- | get_dynamic_schema_core(), get_dynamic_schema_admin() |
已注册的工具命名来源:每个工具文件就是一个工具,文件名即工具名(在 registry.register() 的 name 参数中指定)。
下一篇拆记忆矩阵------MEMORY.md、Hindsight 向量库、state.db SQLite 三层架构的协同与冲突。
本系列基于 Hermes Agent v0.15.2 源码。工具系统文件:tools/registry.py(589 行)+ model_tools.py(1174 行)。