工具系统 ToolRegistry
职责
Tool 是 agent 能调用的原子函数(终端、浏览器、文件、MCP、技能调用……)。ToolRegistry 在模块导入时通过 AST 扫描自动发现 tools/*.py 里的工具并注册,提供一个 name → ToolEntry 的查询表给主循环做工具分发。工具是静态的——与技能的「可演化」相对。
设计动机
为什么用注册表而非硬编码分支?hermes-agent 有约 80 个工具文件,新加工具如果要去 if name == "xxx" 链表里登记,很快腐烂。AST 扫描让「往 tools/ 丢一个调 registry.register 的文件」就是接入动作。registry 是全局单例,MCP 动态工具、插件、内置工具共用一张表。
关键文件
discover_builtin_tools:67-86— AST 扫描tools/*.py找出注册了工具的模块:
def discover_builtin_tools(tools_dir: Optional[Path] = 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)
]
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跳过 __init__.py / registry.py / mcp_tool.py,其余 .py 只要 AST 判定调过 registry.register 就导入,导入动作本身触发模块级 register(...)。单模块导入失败只 warning。
_is_registry_register_call / _module_registers_tools:30-43— 判断某模块是否调用registry.registerclass ToolEntry:87-153— 工具元数据。__slots__让 80+ 个 entry 占用最小化:
class ToolEntry:
__slots__ = (
"name", "toolset", "schema", "handler", "check_fn",
"requires_env", "is_async", "description", "emoji",
"max_result_size_chars", "dynamic_schema_overrides",
)dynamic_schema_overrides 是零参 callable,每次 get_definitions() 时调用,把依赖运行时配置的字段(比如 delegate_task 的 max_concurrent_children)合并到 schema,避免模型看到错误限制。
class ToolRegistry:217-365— 注册表本体def register:365-436— 注册一个工具,核心是覆盖与冲突判定:
def register(self, name, toolset, schema, handler, ..., override: bool = 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:
... # MCP 刷新覆盖 MCP
elif override:
# 插件显式 opt-in 覆盖内置,需要 operator allow_tool_override
...
else:
logger.error("Tool registration REJECTED: '%s' ...", name, toolset, existing.toolset)
return
self._tools[name] = ToolEntry(...)
self._generation += 1同名工具默认拒绝覆盖。MCP 对 MCP 允许(notifications/tools/list_changed 刷新场景),插件覆盖内置需要 override=True + operator allow_tool_override: true,否则 PermissionError。_generation 自增让外层缓存能 memoize。
def get_entry(self, name: str) -> Optional[ToolEntry]:
"""Return a registered tool entry by name, or None."""
with self._lock:
return self._tools.get(name)主循环拿到 tool_call.name 后只查一次 get_entry 就拿到 handler。register_toolset_alias 让 tools="mcp-foo" 这种动态 toolset 在配置里写得更短。
register_plugin_override_policy:316-365— 插件工具的命名空间准入策略registry 单例:765—registry = ToolRegistry()(模块级单例)tool_error / tool_result:784-810— 工具返回值标准封装tools/ 目录— 约 80 个工具文件(terminal/browser/web/file/mcp/skills/delegate…)
数据流
init_agent:276触发 toolsets 配置。discover_builtin_tools:67AST 扫描tools/,命中的模块被导入,模块级registry.register(...)把ToolEntry写入registry 单例:765。- 主循环每轮把启用的 toolsets 对应的
ToolEntry.schema收集进发给 provider 的tools字段。 - provider 返回 tool_call →
get_entry(name)(tools/registry.py:274)查 handler 执行 → 结果用tool_result:798封装回填。
边界与失败
- 工具名冲突:同名内置工具拒绝注册;插件覆盖内置需
override=True+ operatorallow_tool_override: true,否则PermissionError。MCP 对 MCP(mcp-前缀)例外,server 刷新 nuke-and-repave 合法。 - check_fn 抖动:Docker / playwright 探针可能瞬时 False。
_check_fn_cached有 30s TTL + 60s grace window:grace 窗口内即使新探针失败也返回上次成功的 True,避免 delegate_task 子 agent 突然报「Tool read_file does not exist」。 - MCP 并发:MCP server 推
tools/list_changed时可能有线程在读。_lock是RLock,mutate 序列化,reader 走 snapshot。_generation让外层缓存命中检测。 - 模块导入失败:tool 文件 import error 不拖死注册表,只 warning 跳过,但该 tool 主循环调不到——查 agent.log "Could not import tool module"。
小结
工具系统的精妙在「导入即注册」——靠 AST 扫描避免手动维护清单,新增工具只需往 tools/ 丢一个调 register 的文件。registry 单例是全局唯一真相,主循环只读它。和技能的区别:工具是死的函数,技能是可被学习循环改写的流程。