Chapter 06

Tool Registry 与发现

把 LLM 接到外部世界的唯一通道。 79 个工具如何不相互打架、不出现幻觉名字、async 怎么和 sync 共存。

本章约 6,800 字 阅读 ~30 分钟 关键词:registry · auto-discovery · handler · check_fn · async bridging

上一章把 prompt 的事说完了。这一章我们看 prompt 里那个 tools 数组里的每个元素 是怎么从一个 Python 函数文件变成给 LLM 看的 schema,再变成被 LLM 调用时的 dispatch。 这一整套机制叫"工具系统"。Hermes 的工具系统在 tools/registry.pymodel_tools.py, 约 1500 行。

6.1一个工具的全貌

先看一个真实工具的完整文件。tools/clarify_tool.py——它的功能是"让 Agent 向用户提问":

tools/clarify_tool.py(简化到核心 ~50 行)
import json
from typing import Optional, List, Callable
from tools.registry import registry, tool_error

MAX_CHOICES = 4

# ─── 1. Handler:实际执行的 Python 函数 ─────────────────
def clarify_tool(
    question: str,
    choices: Optional[List[str]] = None,
    callback: Optional[Callable] = None,
) -> str:
    """Ask the user a question; optionally with choices."""
    if not question.strip():
        return tool_error("Question text is required.")

    if callback is None:
        return json.dumps({"error": "Clarify not available in this context."})

    user_response = callback(question, choices)
    return json.dumps({
        "question": question,
        "user_response": user_response,
    })


# ─── 2. check_fn:判断工具是否可用 ────────────────────
def check_clarify_requirements() -> bool:
    return True   # 永远可用


# ─── 3. Schema:给 LLM 看的 OpenAI Function 描述 ──────
CLARIFY_SCHEMA = {
    "name": "clarify",
    "description": (
        "Ask the user a question when you need clarification or a "
        "decision. Two modes: 1) multiple choice (up to 4); "
        "2) open-ended. Use when ambiguity blocks progress."
    ),
    "parameters": {
        "type": "object",
        "properties": {
            "question": {
                "type": "string",
                "description": "The question to present.",
            },
            "choices": {
                "type": "array",
                "items": {"type": "string"},
                "maxItems": MAX_CHOICES,
            },
        },
        "required": ["question"],
    },
}


# ─── 4. 注册:模块导入时自动执行 ───────────────────
registry.register(
    name="clarify",
    toolset="clarify",
    schema=CLARIFY_SCHEMA,
    handler=lambda args, **kw: clarify_tool(
        question=args.get("question", ""),
        choices=args.get("choices"),
        callback=kw.get("callback"),
    ),
    check_fn=check_clarify_requirements,
    emoji="❓",
)
Listing 6.1 一个工具的完整四件套:handler + check_fn + schema + register 调用

记住这四件套——所有 Hermes 工具都长这样:

  1. Handler:真正干活的 Python 函数。
  2. check_fn:判断工具是否可用(如环境变量是否设、依赖是否装)。
  3. Schema:给 LLM 的接口契约。
  4. register 调用:模块顶层执行,把四件事都登记到全局 registry。

6.2Registry:单例注册中心

所有工具的注册都进同一个全局 dict。tools/registry.py:

tools/registry.py:151-200
import threading
from dataclasses import dataclass
from typing import Callable, Dict

@dataclass
class ToolEntry:
    name: str
    toolset: str
    schema: dict
    handler: Callable
    check_fn: Callable = None
    requires_env: list = None
    is_async: bool = False
    emoji: str = ""
    max_result_size_chars: int = None
    dynamic_schema_overrides: Callable = None

class ToolRegistry:
    """单例 — 收集所有 tool 的 schema + handler。"""

    def __init__(self):
        self._tools: Dict[str, ToolEntry] = {}
        self._toolset_checks: Dict[str, Callable] = {}
        self._lock = threading.RLock()
        # Generation counter — 每次变更 +1,给 cache 用
        self._generation: int = 0

register 方法的全貌

tools/registry.py:200-260 (摘要)
def register(self, name, toolset, schema, handler,
             check_fn=None, requires_env=None,
             is_async=False, override=False, ...):
    """Register a tool. Called at module-import time by each tool file."""
    with self._lock:
        existing = self._tools.get(name)

        # 防意外覆盖:除非显式 override=True
        if existing and existing.toolset != toolset:
            both_mcp = (existing.toolset.startswith("mcp-")
                        and toolset.startswith("mcp-"))
            if both_mcp:
                # MCP 之间的覆盖正常(server 重连刷新工具)
                logger.debug("MCP overwriting MCP for %s", name)
            elif override:
                logger.info("%s: '%s' override '%s' (explicit)",
                            name, toolset, existing.toolset)
            else:
                logger.error("Tool '%s' REJECTED: would shadow toolset '%s'",
                              name, existing.toolset)
                return

        self._tools[name] = ToolEntry(
            name=name, toolset=toolset, schema=schema, handler=handler,
            check_fn=check_fn, ...)

        if check_fn and toolset not in self._toolset_checks:
            self._toolset_checks[toolset] = check_fn

        self._generation += 1

# 模块级单例
registry = ToolRegistry()

四个值得深挖的设计决策:

① RLock 而不是普通 Lock

MCP server 可以在运行时刷新它的工具列表(比如 GitHub 加了新 API)。 Hermes 的 MCP 客户端会调 registry.register() 更新。 与此同时,主 Agent 循环可能正在调 registry.get_definitions() 读 schema。 读写竞争必须用锁

RLock(可重入)是因为有些路径在持锁状态下又调了别的同样需要锁的方法。 普通 Lock 会死锁。

② _generation 计数器

外部缓存(比如 schema 的 OpenAI 兼容包装)需要知道 registry 啥时候变了。 每次 mutation 把 _generation += 1,外部就拿这个数当 cache key。 命中就是字典查找,不命中就重算。不需要外部主动 invalidate

# 调用方代码(model_tools.py)
cache_key = (
    frozenset(enabled_toolsets),
    registry._generation,        ## ← 这一行
    config_mtime,
)
cached = _tool_defs_cache.get(cache_key)
if cached is not None:
    return cached
# 否则重算

③ override 显式打开

Plugin 想替换一个内置工具(比如换一个更强的 browser_navigate 实现)? 必须传 override=True。否则 Hermes 拒绝,并在 log 里大声警告。 这条规则是防止 plugin 互相意外覆盖——一个用户装了两个声明 browser 工具的 plugin, 没有 override 强制,谁先装谁赢,bug 隐蔽且难调。

④ MCP 互相覆盖默认允许

两个 MCP server 都暴露 read_file?前面注册的会被后面注册的覆盖,但 log 是 debug 级。 原因是MCP 重连时会刷新整套工具——你不想每次重连都出 ERROR 日志。

6.3自动发现

问题:谁来 import 这些 tool 文件?让用户在某个 __init__.py 里维护 79 个 import?

Hermes 的回答:自动扫描,按文件名导入。在 model_tools.py 第 180 行:

model_tools.py:180
# 触发整个 tools/ 目录扫描,每个 .py 文件被 import 一次
discover_builtin_tools()

discover_builtin_tools() 内部做的事:

model_tools.py (节选)
def discover_builtin_tools():
    """扫描 tools/*.py 并 import,触发模块顶层的 registry.register() 调用。"""
    tools_dir = Path(__file__).parent / "tools"
    for tool_file in tools_dir.glob("*.py"):
        if tool_file.startswith("_"): continue          # skip _utils.py 等
        module_name = f"tools.{tool_file.stem}"
        try:
            importlib.import_module(module_name)
        except Exception as e:
            logger.warning("Failed to import %s: %s", module_name, e)
            # 一个 tool import 失败不该挂掉整个 Agent
            continue

关键设计:

为什么这种"魔法"可以接受

"模块级副作用"在 Python 圈通常被视为反模式——容易出隐藏 bug。 但工具发现是少数合理的例外:

  1. 规则简单:所有 tools/*.py 都会被 import,模块顶层调 register
  2. 不会引入循环依赖:tool 文件只依赖 tools.registry 和标准库。
  3. 排查容易:log 里有 "Failed to import" 警告。
  4. 替代方案更糟:维护一份 79 项的显式 import 列表,每次加工具都要改两处。
设计教训 当"自动发现"的规则足够简单(一个目录、一种 import 模式、一次副作用), 收益 > 风险。但如果规则复杂(要看注解、要看类继承),还是退回显式注册更可靠。

6.4Plugin 工具:另一条注册路径

上面是 内置工具的注册路径。Plugin 工具走另一条路: hermes_cli/plugins.py 里有 PluginManager.register_tool()

hermes_cli/plugins.py:317-360
def register_tool(
    self,
    name: str,
    toolset: str,
    schema: dict,
    handler: Callable,
    check_fn: Callable = None,
    requires_env: list = None,
    is_async: bool = False,
    description: str = "",
    emoji: str = "",
    override: bool = False,
) -> None:
    """Register a tool in the global registry AND mark it as plugin-provided."""
    from tools.registry import registry

    registry.register(
        name=name, toolset=toolset, schema=schema, handler=handler,
        check_fn=check_fn, requires_env=requires_env,
        is_async=is_async, description=description, emoji=emoji,
        override=override,
    )
    self._manager._plugin_tool_names.add(name)   # 多记一个"这是 plugin 来的"

用户的 plugin 长这样:

~/.hermes/plugins/fortune/__init__.py
import json, random

def fortune_handler(args, **kw):
    quotes = ["Talk is cheap.", "Make it work, make it right..."]
    return json.dumps({"quote": random.choice(quotes)})

FORTUNE_SCHEMA = {
    "name": "fortune_cookie",
    "description": "Return a random programming fortune quote.",
    "parameters": {"type": "object", "properties": {}, "required": []},
}

# 注意:不是直接调 registry.register,而是通过 ctx
def register(ctx):
    ctx.register_tool(
        name="fortune_cookie",
        toolset="fortune",
        schema=FORTUNE_SCHEMA,
        handler=fortune_handler,
        emoji="🥠",
    )

插件的 register(ctx) 函数由 PluginManager 在加载 plugin 时调用。 ctx 提供 register_tool 等便捷方法。这样 Hermes 知道哪些工具来自 plugin, 方便诊断和卸载。

6.5get_definitions:给 LLM 准备 schema

注册完工具,下一步是把 schema 提供给 LLM。在 model_tools.py

model_tools.py:264-326 (摘要)
def get_tool_definitions(
    enabled_toolsets: List[str] = None,
    disabled_toolsets: List[str] = None,
    quiet_mode: bool = False,
) -> List[Dict[str, Any]]:
    """Get tool definitions for LLM API calls with toolset-based filtering.

    All tools must belong to a toolset to be accessible. Tools whose
    check_fn() returns False are filtered out (e.g. terminal tool when
    Docker isn't running).
    """
    # 缓存机制:用 (toolsets, registry._generation, config_mtime) 做 key
    if quiet_mode:
        cache_key = _build_cache_key(enabled_toolsets, disabled_toolsets)
        cached = _tool_defs_cache.get(cache_key)
        if cached is not None:
            return list(cached)   # shallow copy 避免污染

    # 实际计算
    result = _compute_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode)
    if quiet_mode:
        _tool_defs_cache[cache_key] = result
    return list(result)

_compute_tool_definitions 的逻辑:

  1. toolsets.pyTOOLSETS 字典展开 enabled_toolsets 到具体 tool name 集合。
  2. 从 disabled_toolsets 中拿到要排除的名字。
  3. registry.get_definitions(tool_names)
  4. Registry 内部对每个 tool 调 check_fn()——返回 False 的剔掉。
  5. 给每个 schema 应用 dynamic_schema_overrides()(如果有)。
  6. 包装成 OpenAI {"type": "function", "function": {...}} 格式。

check_fn 的 TTL 缓存

check_fn 可能做实际的 I/O("docker 在跑吗?")。每次 get_definitions 都 fresh 调一次太贵。Registry 内部对 check_fn 结果有 30 秒 TTL 缓存:

@functools.lru_cache(maxsize=128)
def _check_fn_with_ttl(fn, ttl_bucket):
    return fn()

def _check_fn_cached(fn):
    # 把 TTL 切成 30 秒"桶",同一桶内同一 fn 调用结果一致
    bucket = int(time.time()) // 30
    return _check_fn_with_ttl(fn, bucket)

30 秒选这个数字是权衡:太短,频繁 I/O;太长,用户改了配置(启用 terminal) 要等 60 秒才生效。30 秒让用户感觉"近实时"

Dynamic Schema Overrides

有些工具的 schema 描述依赖运行时配置。例如 delegate_task 的描述里说"最多并发 N 个子 agent",N 来自 config.yaml。Hermes 让工具注册 dynamic_schema_overrides 回调:

def _dynamic_delegate_schema():
    cfg = load_config()
    max_concurrent = cfg.get("delegation.max_concurrent_children", 3)
    return {
        "description": f"Delegate a subtask... max {max_concurrent} concurrent."
    }

registry.register(
    name="delegate_task",
    schema=STATIC_SCHEMA,
    dynamic_schema_overrides=_dynamic_delegate_schema,
    ...
)

每次 get_tool_definitions 被调用时,dynamic override 被求值。 cache key 包含 config_mtime,所以 config 改了 cache 自动失效。

6.6handle_function_call:执行管道

LLM 返回 tool_calls 后,model_tools.pyhandle_function_call 接管:

model_tools.py:741-894 (摘要)
def handle_function_call(
    function_name: str,
    function_args: Dict[str, Any],
    task_id: Optional[str] = None,
    tool_call_id: Optional[str] = None,
    session_id: Optional[str] = None,
    enabled_tools: Optional[List[str]] = None,
    skip_pre_tool_call_hook: bool = False,
) -> str:
    """Main dispatcher — routes calls to the registry."""

    # 1. 参数类型转换("42" → 42 等)
    function_args = coerce_tool_args(function_name, function_args)

    # 2. Agent-loop 内置工具(todo / memory)不走这里
    if function_name in _AGENT_LOOP_TOOLS:
        return json.dumps({"error": f"{function_name} must be handled by agent loop"})

    # 3. 调 pre_tool_call hook(plugin 可以阻止)
    if not skip_pre_tool_call_hook:
        block_message = get_pre_tool_call_block_message(
            function_name, function_args,
            task_id=task_id or "",
            session_id=session_id or "",
        )
        if block_message is not None:
            return json.dumps({"error": block_message}, ensure_ascii=False)

    # 4. ACP/Zed edit approval(GUI 集成时)
    edit_block = maybe_require_edit_approval(function_name, function_args)
    if edit_block is not None:
        return edit_block

    # 5. 实际 dispatch — 测时长
    _start = time.monotonic()
    result = registry.dispatch(
        function_name, function_args,
        task_id=task_id,
        ...
    )
    duration_ms = int((time.monotonic() - _start) * 1000)

    # 6. post_tool_call hook(plugin 观察用)
    invoke_hook("post_tool_call",
                tool_name=function_name, args=function_args,
                result=result, duration_ms=duration_ms,
                task_id=task_id or "")

    # 7. transform_tool_result hook(plugin 可以改写结果)
    hook_results = invoke_hook("transform_tool_result", ...)
    for hr in hook_results:
        if isinstance(hr, str):
            result = hr
            break

    return result
Listing 6.2 工具调用的 7 步管道

这 7 步可以分成三组:

步骤作用
预处理1, 2参数规范化,特殊工具走 fast-path
权限 / 拦截3, 4Plugin 和 GUI 集成的"否决权"
执行 + 观察5, 6, 7实际调用 + 监控 + 改写

registry.dispatch

registry.dispatch 是最里层的执行:

tools/registry.py:260-305
def dispatch(self, name: str, args: dict, **kwargs) -> str:
    """Execute a tool handler by name.

    * Async handlers bridged via _run_async()
    * All exceptions caught and returned as JSON error
    """
    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, **kwargs))
        return entry.handler(args, **kwargs)
    except Exception as e:
        logger.exception("Tool %s dispatch error: %s", name, e)
        raw = f"Tool execution failed: {type(e).__name__}: {e}"
        sanitized = _sanitize_tool_error(raw)
        return json.dumps({"error": sanitized})

三个细节:

  1. 异常一定 catch。Agent 主循环不应该被工具异常打死。
  2. 错误清洗_sanitize_tool_error<tool_call>```、CDATA 这种"框架字符串"从异常消息里剥掉——免得错误信息里有 <tool_call> 让模型把它解析成嵌套工具调用。
  3. async handler 自动桥接:用 _run_async 跑 coroutine。

6.7async 桥接的细节

Hermes 同时支持纯 sync 的 CLI 和 asyncio-based 的 Gateway。 工具可以写成 async(处理 HTTP/网络等),但调用方可能在两种环境里。 _run_async 是这个桥:

model_tools.py:84-173 (摘要)
_persistent_loops: Dict[int, asyncio.AbstractEventLoop] = {}

def _run_async(coro):
    """Run an async coroutine from a sync context."""
    try:
        running = asyncio.get_running_loop()
    except RuntimeError:
        running = None

    if running is not None:
        # 当前线程已经在 event loop 里 — 起一个临时线程跑 asyncio.run
        return _run_in_thread(coro)
    else:
        # 没 loop — 用持久 loop 跑(per-thread)
        tid = threading.get_ident()
        loop = _persistent_loops.get(tid)
        if loop is None or loop.is_closed():
            loop = asyncio.new_event_loop()
            _persistent_loops[tid] = loop
        return loop.run_until_complete(coro)

三种场景:

场景处理
CLI 主线程,无 event loop 用 per-thread 持久 loop(避免 asyncio.run 不断创建/销毁 loop 导致缓存 client 失效)
Gateway,主线程已经有 asyncio loop 起临时线程跑 asyncio.run。不能在已有 loop 上嵌套
并发 worker 子线程 per-thread 持久 loop,每个 worker 自己的 loop
为什么不能在 running loop 上直接 run_until_complete Python 不允许在已经在跑的 event loop 上再 run_until_complete——抛 RuntimeError: This event loop is already running。 所以 Hermes 必须检测当前是否在 loop 里,是的话开线程绕过去。

6.8Schema description 的艺术

LLM 选用哪个工具完全description 字段。写好它直接决定工具被 正确调用的频率。

反例

"description": "A tool for searching."     # 模糊到没用
"description": "Use this powerful tool to access advanced search capabilities..."
     # 营销话术,全是噪声

好的范例

todo_tool.py 的 description:

"description": (
    "Manage your task list for the current session. Use for complex "
    "tasks with 3+ steps or when the user provides multiple tasks. "
    "Call with no parameters to read the current list.\n\n"
    "Writing:\n"
    "- Provide 'todos' array to create/update items\n"
    "- merge=false (default): replace the entire list with a fresh plan\n"
    "- merge=true: update existing items by id, add any new ones\n\n"
    "Each item: {id, content, status: pending|in_progress|completed|cancelled}\n"
    "List order is priority. Only ONE item in_progress at a time.\n"
    "Mark items completed immediately when done."
)

这段 description 长 250 字,但每一字都"告诉模型何时该用、怎么用、有什么约束"。 注意几个写作技巧:

迭代方法 写 schema description 是实验的。把工具部署上去看 LLM 的实际调用日志: 调用太频繁?描述太宽。该用没用?描述太窄。读 LLM 调它前后的 reasoning, 找出它对你 description 的"误解",针对性修改。这是 Agent 工程里最像 prompt engineering 的部分。

禁忌:跨 toolset 引用

Hermes 的一条规则:tool schema description 不能提到其他 toolset 的工具名

反例:

"description": "Search the web. For local files, prefer read_file instead."

问题:如果 read_file 在当前 session 不可用(toolset 没启), LLM 会被指向一个看不见的工具,可能幻觉调用。Hermes 在 get_tool_definitions 里用 dynamic override 动态加上这种跨引用, 而不是写死在 static schema 里。

6.9本章带走的

章末练习

  1. Easy 为什么 register() 默认拒绝覆盖同名工具?什么场景应该用 override=True
  2. Easy 在你最熟悉的项目里挑一个能让 LLM 用的功能,写出对应工具的 name + schema。 (不用写 handler,只写 LLM 能看到的部分。)
  3. Medium check_fn TTL 30 秒在大多数情况下是合理的。但有一种边缘场景会出错——你启用 terminal toolset 后 0–30 秒内 LLM 不会"看见"它。设计一个能立即生效的方法, 同时不引入永久轮询。
  4. Medium 打开 Hermes 的 tools/web_tools.py,找 web_search 的 schema。 用你的 LLM API 跑几次,故意写一个模糊的 query 看它怎么调用。然后试着改写 description, 让它在 query 不明确时更倾向于反问而不是直接 search。
  5. Hard 模仿 clarify_tool.py 的结构,写一个完整的 image_caption 工具: 接受图片 URL,调一个 vision model 返回描述。包括 4 件套 + 一个能跑的最简 main。
  6. Hard Hermes 的 async 桥接在已经有 loop 时开临时线程跑 asyncio.run。这有性能开销。 研究:能不能用 asyncio.run_coroutine_threadsafe(coro, loop)同一个 loop 上调度,然后 future.result() 等结果?这种方案有什么 trade-off?