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.pydebug_helpers.py),它们不是工具但被其他工具模块导入。通过 AST 分析模块顶层是否有 registry.register() 调用,可以精确发现真正的工具模块。

Q3. 工具系统如何保证线程安全?

通过 RLock 锁和版本计数器。ToolRegistry._lockthreading.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.pymodel_tools.py。工具文件会自动被发现和导入。

FAQ 总纲

  • ToolRegistry 是中央注册表,单例模式,线程安全
  • 自注册模式解耦了工具定义和使用,新增工具无需修改核心代码
  • check_fn 机制确保只有配置正确的工具才对模型可见
  • 工具集系统支持灵活的分组和组合,满足不同平台的配置需求
  • handle_function_call 是统一分发入口,处理参数转换、插件钩子、执行计时等横切关注点
  • 动态 Schema 通过回调机制实现运行时配置注入

九、后续 Roadmap

预告:工具集详解与高级用法

本篇介绍了工具注册与分发机制,下一篇将深入讲解:

  • 28 个内置工具集详解:从 file 到 browser
  • 终端后端:local、docker、ssh、modal 等多种实现
  • 浏览器自动化:CDP 协议、元素定位、iframe 处理
  • 代码执行工具集:沙箱机制、安全隔离
  • MCP 工具集成:MCP 服务器的动态工具发现

敬请期待!


posted @ 2026-07-17 18:01  左扬  阅读(32)  评论(0)    收藏  举报