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 只是一个「哪些名字归为一组」的分组表,不是定义表。
这一章回答:
- 一个工具从「模块级一行调用」到「出现在模型 schema 里」经过了什么;
register()的 12 个参数各自解决什么,为什么会有override与dynamic_schema_overrides;dispatch()如何处理异步 handler 与异常;- toolset 的「窄腰」是怎么被刻意维持的;
- 审批链的三种模式、四个决策值,以及路径与写入安全的两道独立防线。
一、单例注册表与 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,
):
几个非显然参数的语义:
check_fn:运行时门控。工具是否出现在 schema 里,取决于这个函数在当时的返回值。toolsets.py:77的注释给了实例——Home Assistant 工具「gated onHASS_TOKENviacheck_fn」;toolsets.py:79-82的 kanban 工具则「only in schema when the agent is spawned as a kanban worker(HERMES_KANBAN_TASKset)or the current profile explicitly enables the kanban toolset」。requires_env:声明式依赖(如某个 API key),用于可用性提示,与check_fn互补。max_result_size_chars:单工具的结果体积上限,配合「超大结果落临时文件」的机制。dynamic_schema_overrides:允许在运行时改写 schema(例如按可用后端裁剪枚举值)。override:插件替换内置实现的显式开关。注释(:755-759)说明:没有它,跨 toolset 的同名注册会被拒绝而不是静默覆盖。
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(...)。它做了两件内核级的事:
- 异步桥接。注释(
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 对调用方而言是同步的,桥接由注册表承担,而不是每个调用点各自处理。
- 异常归一。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 关系表。真正有意思的是两条「故意不放进去」的注释:
toolsets.py:36-42:桌面 GUI 专用工具(read_terminal、open_preview等)不在核心清单里,只由 GUI 网关按「session SOURCE 是桌面应用」启用,并且注释明确拒绝「按进程环境变量判断」——因为环境变量对「桌面客户端连远程/云端后端」这种拓扑是瞎的。toolsets.py:64-68:project_list/create/switch同理,只活在projecttoolset,保持「narrow waist」。toolsets.py:97: _HERMES_WEBHOOK_SAFE_TOOLS是另一套刻意收窄的清单,理由写得很直白:webhook 事件来自不可信第三方内容(如公开 PR 标题),默认只给web_search/web_extract/vision_analyze/clarify,避免 prompt injection 触发本地文件或系统执行。
五、审批链:三种模式、四个决策值
审批的唯一真源是 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):
pending_approval:返回一个status: "pending_approval"的 JSON 载荷(:3017-3029),附带approval_pending、pattern_key、smart_denied、allow_permanent等字段,等宿主补一个批准。smart_approved::3047的elif approval.get("smart_approved"):分支——smart模式下被模型判定为安全,直接放行。denied::3031之后的兜底文案,建议用户用审批提示放行或改写命令。
两条绕过路径需要单独记: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 章的授权门超时就委托它,避免两处漂移。
六、路径与写入安全
工具体系里有两条独立的安全线,都与审批无关,是纯静态判定:
- 路径越界:
tools/path_security.py:15: validate_within_dir(path, root)用resolve()+relative_to()判断路径是否逃出允许目录,失败返回错误串而非抛异常;:37: has_traversal_component(path_str)做..的快速预检。文件头注释说明这两个函数是从skill_manager_tool、skills_tool、skills_hub、cronjob_tools、credential_files五处重复实现里抽出来的。 - 写入拒绝:
agent/file_safety.py:200: is_write_denied(path)命中写入黑名单或HERMES_WRITE_SAFE_ROOT之外的「safe root」;:205: get_write_denied_error()生成面向模型/用户的错误文案;:219: is_write_approval_required(path)单独处理一类「不算凭据、也不算硬拒,但写入必须人工确认」的路径(当前示例是~/.ssh/config,理由是ProxyCommand能影响进程执行)。
最后是终端后端工厂 _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;代价是阅读时必须分别检查这两处才能确定一个写操作是否真的会落地。
自测题
discover_builtin_tools()用 AST 而不是 import。请说明这个选择在「工具模块有较重依赖(如 browser、modal)」时的收益,以及它带来的那一条隐式书写约束是什么。toolsets.py明确拒绝用「进程环境变量」判断是否启用桌面 GUI 工具。请还原它给出的理由,并说明如果用环境变量判断,在「桌面客户端连远程后端」时会错在哪。_normalize_approval_mode里为什么要把布尔False映射成"off"、把布尔True映射成"manual"?如果不做这个映射,approvals.mode: off会落到什么行为?- 一个工具同时设置了
check_fn与requires_env。请描述这两者在「工具没出现在 schema 里」这一问题上的不同诊断含义,以及你会在哪一层加日志来区分它们。 path_security.validate_within_dir返回错误串而不是抛异常。这个设计在skill_manager_tool、cronjob_tools这类调用方上带来什么好处?如果改成抛异常,哪一类调用方需要额外改造?