Skip to content

Tool Registry 与 Dispatcher

本课交付结果

你将交付 ToolRegistry:注册工具合同、拒绝同名覆盖、按名称列出目录,并通过 dispatch(name, input) 执行工具。未知工具和工具异常会被转换为稳定的结构化失败结果。

这个组件把 Agent Loop 从“直接持有函数对象”解耦出来,使 Provider 发现、权限审批、Trace 和 Eval 都能围绕同一个工具身份工作。

岗位问题

生产系统中,工具来自内置模块、插件、MCP Server 或用户项目。如果多个来源悄悄注册同名工具,后加载者可能劫持前者;如果工具异常直接冒泡,循环会因为一个适配器故障整体崩溃。

岗位实现必须回答三个问题:有哪些工具、某个名称唯一对应谁、所有执行结果如何进入统一协议。Registry 是能力控制面,Dispatcher 是执行数据面。

前置检查

前置知识快照

先验证上一课合同:

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 06

理解 Map 的唯一键语义、异步异常边界和 discriminated union。测试会要求工具发现顺序稳定,避免模型提示和快照因注册顺序之外的因素抖动。

上一课已经把名称、Schema、权限与结果变成稳定合同。本课默认注册入口只接收合法工具,不重复承担全部 Schema 校验;它要解决的是集合级不变量。单个工具合法,不代表一组工具能安全共存。两个合法的 read_file 仍会形成冲突,任意迭代顺序也会让模型提示与缓存键持续变化。

Map 提供按名称查找和唯一键,但它不会自动拒绝覆盖。调用 set 两次会无声替换旧值,因此唯一性必须由 Registry 主动检查。还要区分公开目录和内部执行对象:前者供 Provider 与界面读取,后者包含可执行函数,绝不能通过列表接口泄漏给调用方。

原理拆解

Registry 的职责保持克制:

  1. 注册时验证唯一名称,不允许静默覆盖。
  2. list() 返回合同视图,不泄漏可直接绕过策略调用的内部引用。
  3. dispatch() 先查找,再执行,并把结果归一化。
  4. 未知名称返回 TOOL_NOT_FOUND;执行异常返回 TOOL_EXECUTION_FAILED
mermaid
flowchart LR
  A[注册合法 Tool] --> B{名称已存在?}
  B -- 是 --> C[启动期拒绝]
  B -- 否 --> D[保存不可变定义与执行器]
  E[Action name + input] --> F{按名查找}
  F -- 未找到 --> G[TOOL_NOT_FOUND]
  F -- 找到 --> H[execute]
  H --> I[结构化成功或业务失败]
  H -- 抛异常 --> J[TOOL_EXECUTION_FAILED]

控制面与数据面的区分很实用。注册、去重、排序和生成模型目录属于控制面,通常在启动期发生;按名称分发调用属于数据面,会在每一轮 Agent 循环发生。控制面应严格失败,让配置问题阻止启动;数据面应结构化失败,让单次工具故障不拖垮会话。

稳定排序不是美观需求。工具目录常被序列化进模型请求、快照测试和缓存键。若排序依赖插件发现顺序,同样的工具集合可能产生不同提示,从而破坏缓存、增加评测噪声,甚至让模型选择发生无关变化。Lab 的 definitions() 按名称排序并返回克隆;总项目在注册时也复制定义,进一步阻断外部修改。

Dispatcher 还是一条异常防火墙。工具可以返回预期失败,也可能因程序缺陷抛出异常。Registry 不应把两者混成一种内部状态:前者原样保留稳定错误码,后者记录诊断后转为 TOOL_EXECUTION_FAILED。上层 Agent Loop 始终得到 ToolResult,不必给每个插件写一套 try/catch

稳定错误码比自由文本更重要:CLI 可以映射退出码,Trace 可以聚合失败率,Eval 可以精确判断错误类别;message 则用于向人解释。

代码实验

失败实现

下面这种对象字典看似足够,却同时引入覆盖、原型键和引用泄漏:

ts
const tools: Record<string, Tool> = {};
export function register(tool: Tool): void {
  tools[tool.definition.name] = tool;
}
export function list(): Tool[] {
  return Object.values(tools);
}

更可靠的最小注册表明确守住唯一性,并只公开合同副本:

ts
export class ToolRegistry {
  readonly #tools = new Map<string, Tool>();

  register(tool: Tool): void {
    const name = tool.definition.name;
    if (this.#tools.has(name)) throw new Error(`工具重复注册:${name}`);
    this.#tools.set(name, tool);
  }

  definitions(): ToolDefinition[] {
    return [...this.#tools.values()]
      .map((tool) => structuredClone(tool.definition))
      .sort((a, b) => a.name.localeCompare(b.name));
  }
}

私有字段阻止普通调用方直接拿到 Map,却不是安全沙箱。真正的保证来自 API 设计:只允许注册、列合同和分发,任何执行都必须经过统一入口。若另外暴露 get(name).execute(),权限、Trace 和错误归一化都可能被绕开。

打开本课 Lab

bash
pnpm --dir bootcamps/coding-agent/labs/07-tool-registry/starter test
pnpm --dir bootcamps/coding-agent/labs/07-tool-registry/solution test

Starter 已能发现工具并拒绝重复名称,但 dispatch 分支以 EXERCISE_NOT_IMPLEMENTED:实现工具分发 失败。Solution 应通过 4 项测试,证明成功、未知工具和执行异常都进入统一结果。

测试要观察“没有调用”这样的负行为。未知名称不只是返回正确错误码,还必须保证任意已注册工具的执行函数都未运行:

ts
it("未知工具返回结构化失败且不执行其他工具", async () => {
  const execute = vi.fn(async () => ({ ok: true as const, data: "wrong" }));
  const registry = new ToolRegistry();
  registry.register(makeTool("read_file", execute));
  const result = await registry.dispatch("missing_tool", {});
  expect(result).toMatchObject({ ok: false, error: { code: "TOOL_NOT_FOUND" } });
  expect(execute).not.toHaveBeenCalled();
});

关键实现讲解

分发边界应捕获不可信工具代码的异常,而不是吞掉整个进程:

ts
const tool = tools.get(name);
if (!tool) return fail("TOOL_NOT_FOUND", `未知工具:${name}`);
try {
  return await tool.execute(input);
} catch (error) {
  return fail("TOOL_EXECUTION_FAILED", safeMessage(error));
}

不要把堆栈、密钥或绝对路径原样送回模型。生产版 safeMessage 应执行脱敏,并把完整诊断只写入受控 Trace。

总项目的执行接口还接收 ToolContext,其中包含工作区根目录和取消信号。Context 由 Runtime 创建,不能让模型通过 input 伪造;Registry 负责把可信 Context 与不可信参数分开传入。这个边界为后续文件沙箱、超时和取消提供了统一注入点。

错误捕获要包住 await。如果只在调用函数前写同步 try/catch 却不等待 Promise,异步拒绝会逃出边界。也不要在 catch 中返回原始 Error 对象,它通常不可稳定序列化,并可能携带本机路径。对模型返回安全消息,对 Trace 记录受控的原始原因,是两条不同的数据通道。

运行与验证

真实运行输出

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 07
pnpm --filter @coding-agent/tools test

单课门禁应报告 observedTests: 4。额外检查重复注册在第二次注册时立即失败,未知名称不会调用任何工具,工具抛错后进程仍能继续分发下一次请求。

实际执行 Starter 时,两项注册与发现测试通过,两项分发测试失败;Solution 四项全部通过。结果应记录为:

text
starter: 2 failed, 2 passed
solution: 4 passed, 0 failed
验证: 重名拒绝 / 稳定目录 / 未知工具 / 异常归一化

继续做串行探针:先分发一个会抛错的工具,再分发正常工具。第二次仍成功,才证明异常被限制在单次调用,而不是破坏 Registry 状态。随后改变注册顺序,definitions() 的结果应完全相同。

常见失败与排查

故障案例 1

后加载插件悄悄替换核心工具。

症状:相同构建在不同加载顺序下行为不同,read_file 有时来自内置模块,有时来自插件。

根因:注册使用无条件 set 或对象赋值,同名键采用最后写入者获胜。

定位:记录注册来源和顺序,交换两个模块后重跑目录快照;若执行目标变化,唯一性不变量缺失。

修复:注册前检查 has 并立即失败;需要共存时使用显式命名空间,禁止模糊短名自动选择。

故障案例 2

工具异常击穿 Agent Loop。

症状:某插件 Promise reject 后整个 CLI 退出,Trace 没有结构化工具结果,后续步骤全部丢失。

根因:Dispatcher 没有在 await execute 外建立异常边界,或返回 Promise 前就离开了同步 catch。

定位:注册一个延迟后抛错的假工具;若调用拒绝而不是返回 TOOL_EXECUTION_FAILED,异步异常未被捕获。

修复:在 try 块内 await,内部记录完整诊断,对外返回脱敏失败对象;再验证下一次正常分发不受影响。

故障案例 3

工具目录与内部状态被调用方改写。

症状:页面为了给工具排序,调用 sort 后线上目录顺序或合同内容永久变化。

根因:列表接口返回内部数组、Map 或共享定义引用,读取接口实际上拥有写能力。

定位:获取目录后修改数组和嵌套 Schema,再次读取;若变化保留,公开快照没有隔离。

修复:每次返回稳定排序的合同副本,注册时也复制定义;不要公开可执行对象和内部容器。

课后作业

给 Registry 增加 namespace,例如 core.read_fileplugin.read_file,并写测试证明短名冲突不会静默选择一个。再加入一次性中间件,在 dispatch 前记录开始时间、结束时间和错误码。

进阶作业是加入注销与版本升级,但要先定义会话一致性:运行中的会话继续使用启动快照,新会话才看到新版本。写一个并发测试,证明目录更新不会让一次调用查到旧合同却执行新实现。最后为每个注册来源生成清单,列出名称、版本、权限和来源摘要。

验收 Rubric

维度通过标准常见扣分
唯一性同名注册立即失败后加载工具覆盖前者
发现列表稳定且不泄漏内部容器调用方可直接修改 Map
分发成功输入传给唯一目标工具绕过合同或调用错误实例
失败语义未知与执行失败有稳定错误码所有错误都抛异常或变成空值

总项目增量

总项目包:@coding-agent/tools

总项目路径:packages/tools/src/registry.ts

总项目验证命令:pnpm --filter @coding-agent/tools test

总项目现在拥有统一工具入口。下一课会把第一个真实能力——文件访问——放进 Registry,同时建立不能绕过的工作区边界。

延伸阅读

方案对比与工程取舍

硬编码 switch 最直观,也能得到穷尽检查,适合工具固定且很少的原型;每增加插件都必须修改核心模块,无法支持运行时扩展。对象字典代码短,却容易遭遇静默覆盖、原型键和无序公开问题。Map Registry 在动态注册、唯一性与遍历之间取得平衡,是本课程的选择。完整依赖注入容器还能管理生命周期与依赖图,但对单一按名分发场景通常过重。

Registry 与 Service Locator 外形相似,关键区别在使用方向。若任意业务代码都从全局 Registry 拉取依赖,测试会隐式依赖全局状态;如果只有 Agent Runtime 持有 Registry,并通过构造参数向需要者传入,它仍是清晰的边界组件。不要把方便查找误用成无处不在的全局变量。

命名空间能解决来源冲突,却会增加模型选择成本。全部暴露 vendor.plugin.version.read_file 会浪费 token,也让模型更易拼错;只暴露短名又可能歧义。实用方案是在内部使用全限定稳定标识,在单一无冲突时给模型短别名,一旦冲突就要求明确选择,并把解析结果记录到 Trace。别名决策必须确定,不能依赖加载顺序。

中间件适合横切关注点,例如计时、权限检查、输入校验与 Trace,但顺序就是语义。先记 Trace 再审批,能看到被拒绝尝试;先审批再记 Trace,审计会缺失。超时应包住执行,脱敏应在对外返回之前。把中间件顺序声明为固定流水线,并用一项集成测试锁住,避免插件自行包裹导致行为漂移。

一次调用的完整生命周期可以这样阅读:Runtime 从模型 Action 取得名称,Registry 进行精确查找,策略根据注册合同决定是否允许,validator 检查 input,执行器接收可信 Context,结果或异常被归一化,Trace 记录合同版本与耗时,最后 Agent Loop 只看到可序列化结果。Registry 本身不必实现每一步,但它是保证这些步骤不会被绕开的唯一入口。

未知工具为什么返回结果而不是抛错?因为模型生成了不存在的名称属于可预期边界失败,循环可以把可用工具清单与错误反馈给模型,让它修正一次。重复注册为什么反而抛错?因为这是部署配置错误,不应靠模型自愈。区分启动期不变量和运行期输入,能让错误处理既严格又有韧性。

异常消息也要分层。面向模型的消息可写“工具执行失败,请检查参数或稍后重试”,并附稳定代码;面向开发者的 Trace 保存工具来源、异常类别、受控堆栈和关联标识;面向用户的界面则根据风险给出可操作说明。把同一原始字符串送往三处,要么泄密,要么无法诊断。

工具执行可能永不返回,所以生产 Dispatcher 还需要取消与超时。最理想的接口是把 AbortSignal 放进可信 Context,让实现主动停止;外层竞速只能让 Runtime 不再等待,却不能终止后台进程或网络请求。注册规范应要求工具尊重取消,并在测试中验证取消后没有残留写入。

并发会进一步考验生命周期。如果 Registry 允许热更新,调用开始与执行之间发生替换,就可能出现合同和实现错配。简单可靠的策略是启动后冻结 Registry;需要热更新时,为每个会话捕获不可变快照,或使用带版本的原子条目。课程采用启动期注册,因为它最容易推理,也符合多数 CLI Agent 的进程模型。

目录排序建议使用明确、跨平台稳定的规则。localeCompare 在不同区域设置下可能产生细微差别,若名称仅限小写英文、数字和下划线,普通字典比较更容易复现。Lab 使用名称排序表达核心要求;正式项目若把目录哈希用于缓存与供应链证明,应锁定比较函数和序列化格式。

供应链场景还需要来源证明。Registry 可在内部保存插件包名、版本、内容哈希和签名状态,却不必全部暴露给模型。发生同名冲突或安全事件时,这些元数据能回答“执行的是哪一份代码”。只记录展示名称无法支持事故追溯。

性能通常不是 Registry 的瓶颈,Map 查找相对模型与文件操作几乎可忽略。不要为了微小速度把错误边界、克隆与审计删掉。真正需要优化的是重复生成庞大工具目录:注册后计算不可变目录和哈希,只有集合变化时重建,同时确保调用方拿不到可变引用。

测试策略应覆盖四层:单元测试锁住重名、排序和错误码;契约测试让每个工具满足相同执行接口;集成测试证明权限、validator、Trace 顺序;回放测试用真实历史 Action 验证升级兼容。仅有“注册一个工具并调用成功”不足以保护生产不变量。

做一次具体的设计评审。假设内置模块注册 read_file,插件也希望提供增强版本。静默覆盖会让安全审查失效;自动给后者改名会让插件作者误以为模型正在使用它;按优先级选择则把行为藏在配置里。更诚实的做法是启动失败并列出两个来源,要求操作者显式保留一个,或把它们命名为不同全限定标识。冲突是需要决定的信息,不是 Registry 应擅自修复的噪声。

再看列表接口。Provider 只需要名称、说明和 Schema,不需要执行函数;权限界面还需要权限字段;管理后台可能需要来源元数据。与其返回万能内部对象,不如给 Registry 提供只读合同快照,再由上层映射成不同视图。视图越窄,未来替换执行器或迁移插件协议时越少调用方受到影响。

对返回快照做深拷贝会有成本,但通常发生在启动或生成模型请求时,远小于一次模型调用。如果目录很大,可以在注册完成后冻结并缓存序列化结果,而不是退回共享可变对象。优化前先测量:几十个小 Schema 的复制几乎不会成为用户可感知延迟,错误目录导致的模型重试却昂贵得多。

Dispatcher 的泛型类型也值得谨慎。内部可以让每个工具拥有精确输入输出类型,但按运行时名称分发时,编译器无法知道具体工具,边界仍然是 unknown。试图用庞大条件类型假装运行时已知,往往把复杂度转移给调用者。可靠方案是在工具内部用 Schema 收窄输入,Registry 只承诺返回统一 ToolResult<unknown>,在可辨识结果之上保持简单。

重试不属于 Registry。它不知道调用是否幂等、剩余预算和用户意图;若在分发层自动重试写工具,可能重复修改文件。Registry 只报告稳定错误与可重试提示,Agent Loop 或专门策略结合工具合同决定是否重试。类似地,用户审批属于策略层,Registry 的责任是确保所有调用经过那个策略,而不是自己展示界面。

日志也不能替代结果。开发者可能在 catch 中打印异常并返回成功空值,以为系统“没有崩”;模型随后会把空值当作有效证据继续推理,产生更难追踪的错误。结果必须忠实表示失败,日志则补充内部诊断。任何“为了流程继续而伪造成功”的做法都破坏了 Agent 的事实基础。

当错误来自工具返回的失败对象时,Registry 应保留其业务错误码,而不是统一覆盖为执行失败。FILE_NOT_FOUND 与程序抛异常的恢复策略不同:前者可列目录修正路径,后者可能需要换工具或停止。只有抛出的未知异常才归一化为 Registry 级错误,这条分界要通过测试锁定。

Registry 状态最好具有明确阶段:构建、冻结、使用。构建阶段允许注册,冻结后生成目录哈希,使用阶段拒绝新增和删除。这样一次会话内工具集合不会突变,模型看到的目录与实际可调用集合保持一致。如果产品必须动态启用工具,应创建新会话快照,而不是原地改动旧集合。

验收时可以模拟一次插件故障演练:注册两个正常工具和一个会异步抛错的工具,随机顺序请求一百次,断言正常工具结果不受影响、失败都有相同错误码、目录哈希不变、Trace 中每次调用都有结束事件。这个小演练同时检查隔离、确定性与可观测性,比单个快乐路径更接近线上负载。

团队维护层面,应把新增工具视为公开接口变更。评审不仅看执行代码,还要看名称冲突、合同说明、权限、错误码和注册来源。删除工具前统计历史 Trace 的使用量,并保留一段清晰迁移期;模型提示或用户自定义技能可能仍在引用旧名称。Registry 能发现未知名称,却无法替团队决定兼容承诺。

最后明确“不做什么”同样重要。Registry 不解析自然语言、不修改模型 Action、不猜测近似工具名、不自动批准权限,也不直接操作文件。它越克制,越容易用确定性测试证明正确。模糊名称修正可以在模型反馈层完成,但真正执行前仍必须回到精确、唯一的 Registry 查找。

下一课衔接

Registry 已经保证“找到并调用唯一工具”,但工具实现仍可能读取工作区之外的文件。下一课会把 read_filewrite_filelist_files 放入注册表,并同时检查绝对路径、上跳片段、符号链接和文件尺寸。届时可信 ToolContext.workspaceRoot 会真正发挥作用:模型只能提供相对路径,边界由 Runtime 而不是提示词强制执行。

  • Registry、Service Locator 与 Dependency Injection 的差异。
  • 错误码、用户消息和内部诊断三层错误模型。
  • 插件命名空间与 supply-chain 冲突防护。

Registry 负责“找到并调用”,不天然提供隔离;不可信插件仍需进程或容器级边界。

从零实现 Mini Code Agent Runtime