第 3 章 · 01 中央 registry 与 schema 收集


第 3 章 · 01 中央 registry 与 schema 收集

本节摘要:本节解剖内循环的"手"的神经中枢。tools/registry.py(1335 行)是中央工具注册表:一工具一文件的纪律下,133 个工具文件各自在模块顶层调用 registry.register(name, toolset, schema, handler, ...),discover_builtin_tools 用 AST 扫描找出所有"真的在顶层注册"的文件并 import 触发自注册(结论缓存到磁盘,mtime+size 命中即免解析)。model_tools.py(1641 行)则是注册表之上的薄编排层:import 即触发发现,然后提供 get_tool_definitions(schema 收集——把启用工具的 JSON schema 聚合成 OpenAI 格式工具列表交给 LLM)与 handle_function_call(分发——把 LLM 的调用路由到具体 handler)两大公共 API。官方架构文档的文件依赖链揭示了分层:registry(零依赖叶子)← tools/*.py(自注册)← model_tools ← run_agent。

内容来源:原项目源码 tools/registry.py:74-162(AST 发现)/:763-882(register)/:1044-1091(get_definitions)/:1128-1168(dispatch)、model_tools.py:1-30(模块 docstring)/:230(发现触发)/:323-414(schema 收集缓存)/:1192(分发);docs website/docs/developer-guide/architecture.md(File Dependency Chain)。

⚠️ 注意:官方架构文档同时出现"70+ tools"与"133 工具文件"两种口径——前者指常驻注册的核心工具,后者含 tools/ 目录全部工具模块(含 browser 五后端拆分的多个文件)。本教程沿用总纲的 133 口径指工具文件,读码时以 registry.get_all_tool_names() 的运行时结果为准。

学习目标

阅读完本节,你应当能够:

  1. 画出工具系统的文件依赖链,并解释"注册发生在 import 时"意味着什么。
  2. 描述 AST 扫描发现的三个环节:文本预筛/顶层调用判定/磁盘缓存。
  3. 列出 registry.register() 的完整参数表并说出 check_fn 与 dynamic_schema_overrides 的用途。
  4. 走读 get_tool_definitions 的双层缓存(memoization+check_fn 30 秒 TTL)。
  5. 走读 handle_function_call 的六道关卡(参数矫正→桥接→插件钩子→编辑审批→中间件→dispatch)。
  6. 复述一工具一文件的约定与命名/文档规范(schema 内嵌 description、emoji、max_result_size_chars)。

一、发现:AST 扫描 + import 自注册

tools/registry.py:111discover_builtin_tools 是整个工具系统的点火器:

111 def discover_builtin_tools(tools_dir: Optional[Path] = None) -> List[str]: 112 """Import built-in self-registering tool modules and return their module names. 114 The per-file AST scan (:func:`_module_registers_tools`) costs ~145 ms over 115 ~100 files on a warm cache, so verdicts are memoized on disk keyed by 116 ``(mtime_ns, size)``. 121 tools_path = Path(tools_dir) if tools_dir is not None else Path(__file__).resolve().parent 128 for path in sorted(tools_path.glob("*.py")): 129 if path.name in {"__init__.py", "registry.py", "mcp_tool.py"}: 130 continue ... 145 registers = _module_registers_tools(path) ... 148 if registers: 149 module_names.append(f"tools.{path.stem}") 155 for mod_name in module_names: 158 importlib.import_module(mod_name)

关键判断在 _module_registers_tools(:87):先做廉价文本预筛(源码里同时含 registryregister 才继续),再 ast.parse只检查模块顶层语句是否是 registry.register(...) 调用(:74-84 的 _is_registry_register_call)——只在函数体里调用的辅助模块不会被误收。发现结论按 (mtime_ns, size) 缓存进 ~/.hermes/cache/tool_discovery_cache.json,未变更的文件免解析。最终 importlib.import_module 每个"真注册者"——import 的副作用就是执行顶层 register 调用,工具进入注册表。这一设计意味着:给 tools/ 丢一个新文件,零配置零清单,下次进程启动即被收录model_tools.py:230 在模块顶层就执行 discover_builtin_tools()——所以任何入口只要 import model_tools,133 个工具就已就位。

二、注册:register() 的十三张登记表

registry.register(:763)的签名浓缩了一个工具的全部身份:

763 def register( 764 self, 765 name: str, 766 toolset: str, 767 schema: dict, 768 handler: Callable, 769 check_fn: Callable = None, 770 requires_env: list = None, 771 is_async: bool = False, 772 description: str = "", 773 emoji: str = "", 774 max_result_size_chars: int | float | None = None, 775 dynamic_schema_overrides: Callable = None, 776 override: bool = False, 777 scope: Optional[str] = None, 778 ): 779 """Register a tool. Called at module-import time by each tool file.

参数之外,函数体里还有一套防影子机制:跨 toolset 的同名注册直接拒绝(报 "would shadow existing tool");插件要覆盖内置工具必须显式 override=True 且操作员在 config 里 allow_tool_override 放行——三层门禁防止一个插件悄悄替换 terminal 之类的要害工具。check_fn可用性探针:注册≠可见,每次收集 schema 时 check_fn 返回 True 工具才进列表(例如 terminal 的 check_terminal_requirements 探测 docker/modal 可用性、homeassistant 探测 HASS_TOKEN)。dynamic_schema_overrides 让 schema 随配置动态重建——第 3-03 节会看到 delegate_task 用它把用户实配的并发上限写进 description。注册完成后 self._generation += 1,代数计数是后续两层缓存失效的依据。

三、schema 收集:get_tool_definitions 的双层缓存

model_tools.py:323get_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode) 把注册表内容变成 LLM 可见的工具列表,注释直陈其角色:

330 Get tool definitions for model API calls with toolset-based filtering. 332 All tools must be part of a toolset to be accessible. ... 347 # Fast path: memoized result when the caller doesn't need stdout prints. 348 # The cache key captures every argument-level input; the registry 349 # generation captures registry mutations (MCP refresh, plugin load). ... 366 cache_key = ( 367 registry.current_scope_key(), 368 frozenset(enabled_toolsets) if enabled_toolsets is not None else None, 370 registry._generation, 371 cfg_fp, # config.yaml 的 mtime+size 指纹 372 bool(os.environ.get("HERMES_KANBAN_TASK"), 374 _is_delegated_child_context(), ...

缓存键的考究程度值得细读:toolset 集合、注册表代数(MCP 刷新/插件加载都会推进代数)、config 文件的 mtime+size 指纹(用户改了 execute_code 模式或 discord 白名单,动态 schema 自动失效)、kanban 环境位、委派子上下文位。未命中则走 _compute_tool_definitions(:417):先按 enabled/disabled 解析出目标工具名集合(下一节 toolsets 的主场),再交给 registry.get_definitions(:1044)做逐工具过滤+包装:

1060 entries_by_name = {entry.name: entry for entry in self._snapshot_entries()} 1061 for name in sorted(tool_names): 1065 if entry.check_fn: 1067 check_results[entry.check_fn] = _check_fn_cached(entry.check_fn) 1068 if not check_results[entry.check_fn]: 1070 continue # check 失败 → 不进列表 1072 # Ensure schema always has a "name" field — use entry.name as fallback 1073 schema_with_name = {**entry.schema, "name": entry.name} 1079 if entry.dynamic_schema_overrides is not None: 1081 overrides = entry.dynamic_schema_overrides() 1083 schema_with_name.update(overrides) 1090 result.append({"type": "function", "function": schema_with_name})

check_fn 结果有约 30 秒 TTL 缓存(:1044 docstring:探测 modal/docker/playwright 都不便宜,但 hermes tools enable 的环境变更要能近实时生效)。最终每个工具变成 {"type": "function", "function": {...schema}} 的 OpenAI 形态——这份列表随 API 请求发出,就是 LLM 眼中的"手"。注意 sorted(tool_names):工具顺序字典序稳定,同样是第 6 章缓存铁律的伏笔(schema 顺序抖动会炸前缀缓存)。

四、分发:handle_function_call 的六道关卡

LLM 决定调用某工具后,请求回流到 model_tools.py:1192handle_function_call——工具系统的另一半公共 API。它远不止一张路由表,而是六道关卡的流水线:

1234 function_args = coerce_tool_args(function_name, function_args) # ① 参数类型矫正 ... 1267 if _ts_mod is not None and _ts_mod.is_bridge_tool(function_name): # ② 工具搜索桥 1289 if function_name == _ts_mod.TOOL_SEARCH_NAME: ... 1331 return handle_function_call(function_name=underlying_name, ...) # 解包递归 ... 1388 block_message, modified_args = _dispatch_pre_tool_call_hooks( # ③ pre_tool_call 插件钩子 1403 if block_message is not None: 1404 result = tool_error(block_message) # 可 block/approve/modify ... 1427 edit_block_message = maybe_require_edit_approval(...) # ④ ACP/Zed 编辑审批 ... 1517 result = run_tool_execution_middleware( # ⑤⑥ 执行中间件→registry.dispatch 1518 function_name, function_args, _dispatch, ...)

coerce_tool_args 把模型常犯的字符串参数按 schema 矫形("42"→42),弱模型的第一道救助;②tool_search/tool_describe/tool_call 是"工具搜索桥"——超大全集的工具面被折叠成三个检索工具,桥调用会解包成底层工具递归走完整流水线(所有钩子看到的是真实工具名,桥对钩子隐形);③插件 pre_tool_call 钩子可拦截/改参/要求审批——安全扩展点;④ACP 会话的文件编辑审批(编辑类工具先过用户确认);⑤工具执行中间件;⑥registry.dispatch(:1128)真执行:异步 handler 自动桥接(_run_async),结果归一化为字符串或多模态信封,所有异常捕获并格式化为 {"error": ...}——工具炸了不会掀翻会话,错误本身就是给模型的观察。另外 :1370 有一行冷面守卫:if function_name in _AGENT_LOOP_TOOLS: return tool_error("must be handled by the agent loop")——todo/memory/session_search/delegate_task 这类agent 级工具在 run_agent 里被提前拦截、直接改 agent 状态,根本不到 registry(官方 agent-loop.md 的 Agent-Level Tools 表)。

💡 循环要点:工具系统的架构一言以蔽之——声明式注册,集中式把关。每个工具用一份内嵌 schema 的 register 调用完成自我声明;发现靠 AST 扫描零配置;收集靠双层缓存+check_fn 探针决定"这次给模型看哪些手";分发靠六道关卡把类型矫正/安全/审计/执行串成一条线。133 个工具只有一份 schema、一个入口、一条流水线——这正是上一节"三种 API 模式收敛"能在工具层成立的前提。

本节要点回顾

  1. 依赖链:tools/registry.py(零依赖)← tools/*.py(顶层自注册)← model_tools.py(import 即发现)← run_agent/cli/batch_runner。
  2. 发现三环节:文本预筛(registry+register 同现)→ AST 顶层调用判定(函数体内调用不算)→ (mtime_ns,size) 磁盘缓存。
  3. discover_builtin_toolsmodel_tools.py:230 模块级执行;新工具文件零配置自动收录。
  4. register 十三参数;跨 toolset 同名拒绝、插件覆盖需 override=True+操作员放行;注册推进 _generation 代数。
  5. get_tool_definitions 缓存键含 toolset 集合/代数/config 指纹/kanban 位/委派位;registry.get_definitions 逐工具 check_fn(30 秒 TTL)过滤,结果按名称排序包成 OpenAI 形态。
  6. 分发六道关卡:参数矫正→搜索桥解包→pre_tool_call 钩子→编辑审批→执行中间件→registry.dispatch;异常一律转为工具错误结果。
  7. agent 级工具(todo/memory/session_search/delegate_task)在 run_agent 拦截,不进 registry。
  8. 纪律:一工具一文件、schema 内嵌 description、emoji 标识、max_result_size_chars 限流。

下一节把镜头拉远:133 个工具如何被 toolsets.py 切成 28 个 toolset 分组,平台预设如何裁剪工具面,以及 terminal/browser/file/computer_use/cron/delegate/kanban 各组的代表工具。


作者与出处
原作者: 灏天文库
整理: 灏天文库整理
本站整理收录,版权归原作者/开源协议所有;欢迎通过原文链接访问源仓库。
发布者: 作者: 灏天文库 转发
评论区 (0)
U