Skip to content

工具系统 ToolRegistry

源码版本v2026.7.20

职责

Tool 是 agent 能调用的原子函数(终端、浏览器、文件、MCP、技能调用……)。ToolRegistry 在模块导入时通过 AST 扫描自动发现 tools/*.py 里的工具并注册,提供一个 name → ToolEntry 的查询表给主循环做工具分发。工具是静态的——与技能的「可演化」相对。

设计动机

为什么用注册表而非硬编码分支?hermes-agent 有约 80 个工具文件,新加工具如果要去 if name == "xxx" 链表里登记,很快腐烂。AST 扫描让「往 tools/ 丢一个调 registry.register 的文件」就是接入动作。registry 是全局单例,MCP 动态工具、插件、内置工具共用一张表。

关键文件

python
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。

python
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_taskmax_concurrent_children)合并到 schema,避免模型看到错误限制。

python
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。

python
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_aliastools="mcp-foo" 这种动态 toolset 在配置里写得更短。

数据流

  1. init_agent:276 触发 toolsets 配置。
  2. discover_builtin_tools:67 AST 扫描 tools/,命中的模块被导入,模块级 registry.register(...)ToolEntry 写入 registry 单例:765
  3. 主循环每轮把启用的 toolsets 对应的 ToolEntry.schema 收集进发给 provider 的 tools 字段。
  4. provider 返回 tool_call → get_entry(name)(tools/registry.py:274)查 handler 执行 → 结果用 tool_result:798 封装回填。

边界与失败

  • 工具名冲突:同名内置工具拒绝注册;插件覆盖内置需 override=True + operator allow_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 时可能有线程在读。_lockRLock,mutate 序列化,reader 走 snapshot。_generation 让外层缓存命中检测。
  • 模块导入失败:tool 文件 import error 不拖死注册表,只 warning 跳过,但该 tool 主循环调不到——查 agent.log "Could not import tool module"。

小结

工具系统的精妙在「导入即注册」——靠 AST 扫描避免手动维护清单,新增工具只需往 tools/ 丢一个调 register 的文件。registry 单例是全局唯一真相,主循环只读它。和技能的区别:工具是死的函数,技能是可被学习循环改写的流程。

非官方社区学习站,内容以 MIT 许可的 NousResearch/hermes-agent 源码为依据。