KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

03 · 工具体系:注册表、自动发现与审批链 — keel 龙骨

Hermes Agent 有 92 个实体工具注册点,但它们没有一个中央清单。工具的全部声明散落在 tools/ 目录各个 _tool.py 的模块级 registry.register(...) 里,由注册表在导入时收集;toolsets.py 只是一个「哪些名字归为一组」的分组表,不是定义表。

Hermes Agent 有 92 个实体工具注册点,但它们没有一个中央清单。工具的全部声明散落在 tools/ 目录各个 *_tool*.py 的模块级 registry.register(...) 里,由注册表在导入时收集;toolsets.py 只是一个「哪些名字归为一组」的分组表,不是定义表。

这一章回答:

  1. 一个工具从「模块级一行调用」到「出现在模型 schema 里」经过了什么;
  2. register() 的 12 个参数各自解决什么,为什么会有 override 与 dynamic_schema_overrides;
  3. dispatch() 如何处理异步 handler 与异常;
  4. toolset 的「窄腰」是怎么被刻意维持的;
  5. 审批链的三种模式、四个决策值,以及路径与写入安全的两道独立防线。

一、单例注册表与 register 契约

注册表本体是 tools/registry.py:426: class ToolRegistry,全局单例在 tools/registry.py:1263: registry = ToolRegistry()。条目类型是 tools/registry.py:204: class ToolEntry。

register() 的签名(tools/registry.py:737)是这一章的核心:

def register(
    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 = "",
    max_result_size_chars: int | float | None = None,
    dynamic_schema_overrides: Callable = None,
    override: bool = False, scope: Optional[str] = None,
):

几个非显然参数的语义:

register() 内部还有一层作用域(scope)逻辑:_scoped_tools 与全局 _tools 并存,若插件试图遮蔽全局工具,会走 shadows_global 分支——未开 override 就 logger.error 后 return,开了则要求 operator 的 allow_tool_override,否则抛 PermissionError(tools/registry.py:783-797)。

二、自动发现:AST 扫描而不是导入

discover_builtin_tools()(tools/registry.py:111)负责在启动时找出所有工具模块。它不靠 import,而是靠 AST:

tree = ast.parse(source, filename=str(module_path))

配合 _is_registry_register_call()(tools/registry.py:74)与一个「模块是否含顶层 registry.register(...)」的判定(:88)。注释写明了两个约束:一是「注册调用必须写在模块级」——写在函数里的不会被发现;二是先用便宜的文本预筛再 ast.parse,避免对无关注文件付解析成本。

这块的实测分布:tools/ 目录 .py 文件中 registry.register( 共出现 97 处,其中 tools/registry.py 自身占 5 处(:3、:75、:88、:91、:95),全部是文档串,实体注册点 92 处。分布极不均匀:tools/browser_tool.py 10 处、tools/kanban_tools.py 14 处,而多数工具文件只有 1 处。

三、dispatch:异步桥接与错误归一

调用侧入口是 tools/registry.py:1102: def dispatch(...)。它做了两件内核级的事:

  1. 异步桥接。注释(tools/registry.py:1112)说明「Async handlers are bridged automatically via _run_async()」,实现是 tools/registry.py:1123-1124:
from model_tools import _run_async
result = _run_async(entry.handler(args, **kwargs))

所以 register(is_async=True) 的 handler 对调用方而言是同步的,桥接由注册表承担,而不是每个调用点各自处理。

  1. 异常归一。handler 抛出的异常被统一成 tool_error 形状的返回值,而不是向上冒泡。这是「工具出错不能中断主循环」这一约定的落点:模型应该看到一条 tool_error 消息并自行决定重试或换路,而不是让整轮崩掉。

工具 schema 则是普通的模块级 dict,例如 tools/file_tools.py:2652: READ_FILE_SCHEMA、:2666: WRITE_FILE_SCHEMA、:2684: PATCH_SCHEMA。schema 与 handler 在同一文件相邻定义,是「读一个文件就能理解一个工具的全部」的刻意安排。

四、toolset:刻意维持的窄腰

toolsets.py:31: _HERMES_CORE_TOOLS 是 CLI 与所有消息平台共享的工具名单,「Edit this once to update all platforms simultaneously」。它包含 web、terminal/process、文件四件套(read_file / write_file / patch / search_files)、视觉与图像、skills 三件套、browser 系列、text_to_speech、todo / memory、session_search、clarify、execute_code / delegate_task、cronjob、ha_*、kanban_*、computer_use。

toolsets.py:107: TOOLSETS 是分组与 includes 关系表。真正有意思的是两条「故意不放进去」的注释:

五、审批链:三种模式、四个决策值

审批的唯一真源是 tools/approval.py。模式归一化在 _normalize_approval_mode()(tools/approval.py:3142),合法值是 _VALID_MODES = ("manual", "smart", "off")(:3153)。它的 docstring 顺手记了一个 YAML 陷阱:YAML 1.1 把裸词 off 解析成布尔 False,所以 approvals.mode: off 必须被当成字符串模式而不是「回落到 manual」。

决策值域定义在 tools/approval.py:2598 的注释里:"once" | "session" | "always" | "deny";:2983 补充了第五种落空值 'timeout'。终端工具据此把结果分三种形状落地(tools/terminal_tool.py:3011-3049):

两条绕过路径需要单独记:cron 与 single-query 场景也能给出 deny(tools/approval.py:3235 / :3248 一带),因为无人可问时必须 fail closed;YOLO 模式则相反,模块导入时就冻结成 _YOLO_MODE_FROZEN(tools/approval.py:37),并在 :4393、:3759、:5023 等处短路掉审批——注释(:34)解释了「冻结」的理由:如果每次调用都读环境变量,中途改 env 会让一次已授权的运行突然失去授权。

不过 hardline 命令永远不可绕过:detect_hardline_command()(tools/approval.py:601)的 docstring 明说「Hardline patterns are NEVER bypassable, even in YOLO mode」;用户自定义黑名单走 _match_user_deny_rule()(:623)。人在回路的时间上界由 human_wait_ceiling()(:2391)给出——第 02 章的授权门超时就委托它,避免两处漂移。

六、路径与写入安全

工具体系里有两条独立的安全线,都与审批无关,是纯静态判定:

最后是终端后端工厂 _create_environment()(tools/terminal_tool.py:1756)。容器类后端被归成一个集合:tools/terminal_tool.py:1494: _CONTAINER_BACKENDS = frozenset({"docker", "singularity", "modal", "daytona", "vercel_sandbox"});这个集合被 _is_unusable_container_cwd()(:1497)用来判断「宿主 cwd 泄漏进容器」这类问题(:1492 列了 /Users/、/home/、C:\、C:/ 四种前缀)。后端的抽象基类是 tools/environments/base.py:595: class BaseEnvironment(ABC),实现有 local / docker / ssh / singularity / modal / managed_modal / daytona / vercel_sandbox 八个模块。

代码地图

机制 位置 要点
注册表类 tools/registry.py: ToolRegistry 第 426 行;_tools 与 _scoped_tools 两层表(:431 / :434)
全局单例 tools/registry.py: registry 第 1263 行;registry = ToolRegistry()
注册契约 tools/registry.py: register 第 737 行;12 个参数含 check_fn / requires_env / dynamic_schema_overrides / override / scope
条目类型 tools/registry.py: ToolEntry 第 204 行
遮蔽保护 tools/registry.py: shadows_global 分支 第 783-797 行;无 override 则拒绝,有则要求 allow_tool_override
自动发现 tools/registry.py: discover_builtin_tools 第 111 行;AST 扫描,只认模块级 registry.register(...)
注册调用识别 tools/registry.py: _is_registry_register_call 第 74 行;文本预筛 + ast.parse
调用分派 tools/registry.py: dispatch 第 1102 行;异步经 model_tools._run_async 桥接(:1123),异常归一为 tool_error
默认工具清单 toolsets.py: _HERMES_CORE_TOOLS 第 31 行;CLI 与所有平台共享
工具集分组 toolsets.py: TOOLSETS 第 107 行;tools + includes 递归组合
webhook 收窄清单 toolsets.py: _HERMES_WEBHOOK_SAFE_TOOLS 第 97 行;只给 4 个只读/低危工具,防 prompt injection
schema 位置 tools/file_tools.py: READ_FILE_SCHEMA 第 2652 行;同文件另有 WRITE_FILE_SCHEMA(:2666)、PATCH_SCHEMA(:2684)
审批模式归一 tools/approval.py: _normalize_approval_mode 第 3142 行;值域 manual / smart / off(_VALID_MODES 在第 3153 行)
审批决策值域 tools/approval.py: 2598 注释:"once" / "session" / "always" / "deny";:2983 补 'timeout'
hardline 命令 tools/approval.py: detect_hardline_command 第 601 行;「NEVER bypassable, even in YOLO mode」
用户黑名单 tools/approval.py: _match_user_deny_rule 第 623 行
YOLO 冻结 tools/approval.py: _YOLO_MODE_FROZEN 第 37 行,导入期冻结;短路点在 :4393、:3759、:5023
人工等待上界 tools/approval.py: human_wait_ceiling 第 2391 行;供并发授权门复用同一数值
终端审批落地 tools/terminal_tool.py: 3011-3049 pending_approval(:3017)、smart_approved(:3047)、denied 文案(:3031 起)
路径校验 tools/path_security.py: validate_within_dir 第 15 行;另有 has_traversal_component(:37)
写入安全 agent/file_safety.py: is_write_denied 第 200 行;另有 get_write_denied_error(:205)、is_write_approval_required(:219)
终端后端工厂 tools/terminal_tool.py: _create_environment 第 1756 行;容器集 _CONTAINER_BACKENDS 在 :1494
后端基类 tools/environments/base.py: BaseEnvironment 第 595 行;八个后端实现模块

关键取舍

工具定义靠 AST 扫描而不是中央 import 清单,代价是「注册必须写在模块级」这一隐式约束。
好处是新增工具只需一个文件、一行 registry.register(...),不需要改任何清单;坏处是这个约束没有类型系统保护——把注册写进函数里,工具会静默消失,只能靠 discover_builtin_tools 的 docstring 提示。这属于典型的「用约定换低耦合」。

register() 拒绝静默覆盖,代价是插件替换内置实现要过两道显式关卡。
跨 toolset 同名注册默认被 logger.error 掉,必须 override=True 且 operator 开了 allow_tool_override 才放行。收益是「插件偷偷换掉 terminal 实现」这类事故很难发生;代价是想做正当替换(例如把默认浏览器工具换成 headed-Chrome CDP)时要理解并显式打开两层开关。

toolset 刻意保持窄腰,代价是同一能力要在多处声明。
桌面 GUI 工具与 project 工具都不进 _HERMES_CORE_TOOLS,由各自的宿主按 session 来源启用。收益是 CLI / 消息平台 / cron 的 schema 不会被一堆用不上的工具塞满,也避免「环境变量一开,远程后端也跟着启用」的错误;代价是「某个工具为什么没出现」的排查路径变长,要同时看 check_fn、toolset 启用状态和宿主类型三处。

审批模式用 manual / smart / off 三值而不是布尔,代价是 YAML 语义陷阱与冻结逻辑。
off 在 YAML 1.1 里是布尔 False,因此需要一个专门的归一化函数;YOLO 模式又必须在导入期冻结成 _YOLO_MODE_FROZEN,否则中途改 env 会让已授权的运行失去授权。三值模型换来的表达力是「智能放行」这一中间态,代价是每一处判定都要写三路分支(:4393、:3759、:5023…)。

路径安全与审批被拆成两条独立线,代价是「谁负责拦」需要分别理解。
path_security / file_safety 是纯静态判定,不看用户是否在场;approval 是人在回路。一个写操作可能同时被两者影响(例如写入被 safe root 拒绝,或被标记需要审批)。收益是静态线可以在无人宿主(cron、batch)里独立生效并 fail closed;代价是阅读时必须分别检查这两处才能确定一个写操作是否真的会落地。

自测题

  1. discover_builtin_tools() 用 AST 而不是 import。请说明这个选择在「工具模块有较重依赖(如 browser、modal)」时的收益,以及它带来的那一条隐式书写约束是什么。
  2. toolsets.py 明确拒绝用「进程环境变量」判断是否启用桌面 GUI 工具。请还原它给出的理由,并说明如果用环境变量判断,在「桌面客户端连远程后端」时会错在哪。
  3. _normalize_approval_mode 里为什么要把布尔 False 映射成 "off"、把布尔 True 映射成 "manual"?如果不做这个映射,approvals.mode: off 会落到什么行为?
  4. 一个工具同时设置了 check_fn 与 requires_env。请描述这两者在「工具没出现在 schema 里」这一问题上的不同诊断含义,以及你会在哪一层加日志来区分它们。
  5. path_security.validate_within_dir 返回错误串而不是抛异常。这个设计在 skill_manager_tool、cronjob_tools 这类调用方上带来什么好处?如果改成抛异常,哪一类调用方需要额外改造?

进入 keel 阅读