实现篇第8章 为未来留三个插孔——Extensions

本章导读: 这一步不写新功能,而是盘点三条已被循环调用、已被测试覆盖的扩展缝:
ModelClient、Tool、BeforeToolCall。核心纪律:预留接口是未维护的承诺,先有失败证据再扩展。
Agent 基本成型了。小A坐在工位上,脑子里全是更宏大的计划:“老Z,我想加第二家模型、接 MCP、再加 Skill,最好还能搞多 Agent!”
老Z没有泼冷水,只是把键盘接过来:“先别急着堆功能。先分清‘已经存在的接口’和‘未来需求’。这个产物当前只有 ModelClient、Tool、BeforeToolCall 三条真实扩展缝,其余的只谈触发条件和草图。”
小A:“三条缝听着不少了,怎么还说‘别急着堆’?”
“因为这三条缝是 loop 真的在调用的,不是你画在纸上的。”老Z说,“ModelClient 被 runAgent 每轮调用(见 agent.ts 的 options.client.stream);Tool 被 runAgent 在分派时调用(见 agent.ts 的 tool.execute);BeforeToolCall 被审批分支调用(见 agent.ts 的 options.beforeToolCall?.(call))。有调用方、有测试覆盖的才叫接口,写在 TODO 里的叫愿望。你想加的 MCP、Skill、多 Agent,现在哪一条有调用方?没有。所以这一章只盘点已实现的三条缝,其余的留到触发条件成熟。”
小A:“被调用和只是被 import,怎么分?”
“看调用链里有没有决策。”老Z说,“options.client.stream 是 runAgent 每轮真正走的分支,少了它循环跑不起来;tool.execute 是工具分派的核心,beforeToolCall 是审批分支的入口,三者都长在 agent.ts 的控制流里。反过来,一条缝如果只被 import、没有任何分支依赖它,删掉它测试照样绿,那它就是装饰。验证也便宜:把那条缝的代码注释掉,跑一遍 npm test,看测试红不红。红的才是接口。”
“这三条缝各自的调用点,画出来就是一张图。”老Z把 runAgent 每个 turn 的流程画了下来:
runAgent 每个 turn(agent.ts)
│
├─► options.client.stream({ system, messages, tools, signal })
│ │ 每轮必然进入,产出 ModelEvent
│ ▼
│ for await (event of stream)
│ │ text → 累积;tool_call → 收集
│ ▼
│ 分派:for call of calls
│ │
│ ├─► options.beforeToolCall?.(call) ← 审批缝
│ │ └─ approved:false → 记录拒绝,continue
│ ▼
│ ├─► toolsByName.get(call.name) ← 工具查找
│ │ └─ 查不到 → 记录 unknown tool,continue
│ ▼
│ └─► tool.execute(call.input, signal) ← 工具缝
│ └─ 结果以 tool message 记录
│
▼
下一轮 / 返回“三条缝画在图上的位置各不相同:ModelClient 在每轮最开头,BeforeToolCall 在分派前,Tool 在分派后。扩展它们就是扩展这三段控制流,接入点精确到调用点。测试覆盖也从这三个点出发:agent.test.ts 的 fake client 替掉 ModelClient,agent-branches.test.ts 的 fake 轮次和 beforeToolCall 打满审批分支,工具行为由 createWorkspaceTools 单测盯着。”
已实现边界
| 接口 | 调用者期待 | 扩展者必须保证 | 测试重点 |
|---|---|---|---|
ModelClient | 异步事件流 | 事件顺序、tool input 对象、取消传播 | fake 流、空响应、解析错误 |
Tool | spec 加 execute | schema 与执行结果一致、错误可读 | 非法输入、路径/副作用 |
BeforeToolCall | 单次审批结果 | 明确 allow/deny 和原因 | 拒绝记录、默认策略 |
小A:“扩展的时候,直接写实现就行吧?”
“顺序反了。”老Z说,“这三条缝共用同一条纪律:先确认失败证据,再改类型,再写实现,最后补回归测试,每一步都有测试变红变绿的节点。三条缝各自有一条最硬的测试守着:ModelClient 有 toModelEvents 的契约测试,Tool 有 agent-branches.test.ts 的非法输入与路径逃逸,BeforeToolCall 有 agent-branches.test.ts 的拒绝记录与 tool-failure-session.test.ts 的抛错收据。改动前先跑一遍测试记住基线,改动后任何一条红,就是你的改动破坏了哪条已承诺的边界。”
ModelClient 是协议适配缝
“先看 ModelClient。”老Z打开 types.ts:
export interface ModelClient { stream(request: {
system: string; messages: Message[]; tools: ToolSpec[]; signal?: AbortSignal;
}): AsyncIterable<ModelEvent>; }“第二家 Provider 必须把它 SDK 的流映射成 text、tool_call、done。这不是换个 URL 就完事:消息角色、工具 schema、增量 JSON、取消和错误都要转换。当前 Anthropic adapter 对非对象 tool input 抛错,并在 content_block_stop 解析累计 JSON;另一个 API 的 partial JSON 语义不同,必须用 fake stream 和真实服务的受控测试验证。”
小A:“换一家 Provider,具体要重写哪些东西?”
“实现篇第2章那张映射表就是你的清单。”老Z说,“四个翻译点。消息角色:Anthropic 把 tool result 塞进 role: user 的内容数组,别的 SDK 可能单独有 tool 角色,toAnthropicMessages 整个要重写。工具 schema:Anthropic 要 input_schema,别家可能叫 parameters。增量 JSON:partial_json 的语义、事件名、闭合方式都不同,toModelEvents 状态机要重推。停止语义:end_turn/tool_use 是 Anthropic 的名词,stop_reason 的定义每家一套,supportedStopReason 是写死本家的。每一条都是实现篇第2章定义的协议边界,改个构造参数糊弄不过去。”
“接口本身不用动?”小A追问。
“不用,这正是实现篇第2章隔离的意义。”老Z说,“ModelClient 的签名不关心厂商,三家 Provider 共用同一个 runAgent、同一套 model-client.test.ts 的契约测试思路。真正的工作量全在 adapter 内部,接口负责把差异关在门后。”
老Z又打开 model-client.ts:
// src/model-client.ts,toolInput
if (input === null || typeof input !== "object" || Array.isArray(input)) {
throw new Error("tool input must be an object");
}“这个错误在 toModelEvents 里会被包成 tool_use block N returned non-object input 的协议错误,实现篇第2章讲过。”
“正常路径是 adapter 产出合法事件,loop 继续;失败路径是 provider 返回坏输入或网络抛错,runAgent 向上失败(除非 signal 已中止)。没有 Provider 注册表、重试或模型目录。”
小A:“为什么没有注册表?加一个 { name, client } 的 Map 不难吧?”
“不难,但没人查它。”老Z说,“runAgent 接收的是 client: ModelClient(见 agent.ts),拿来就用,不查注册表。你加了注册表,就得有人决定这次用哪个:是 main.ts 根据环境变量选,还是模型自己选?mini-agent 现在只有 AnthropicModelClient(见 main.ts 的 new AnthropicModelClient({ apiKey, model })),一家 provider,没有选择的需求。注册表解决的是多家 provider 怎么选,选的问题不存在,它就是一段空跑的代码。等你真有第二家、真需要在运行时切换再回来加,那时候你才知道切换的依据是什么,成本、延迟还是能力,接口也才知道该接什么参数。”
小A:“不写真 adapter,能不能先验证接口?”
“能,用 fake。这也是为什么接口里没有厂商字段。”老Z现场写了一个:
// 示意:为测试假想的"第二家 Provider"写的最小 client
class FakeSecondProvider implements ModelClient {
async *stream(request): AsyncIterable<ModelEvent> {
yield { type: "text", text: "我先看看工作区" };
yield { type: "tool_call", call: { id: "c1", name: "read", input: { path: "config.ts" } } };
yield { type: "done" };
}
}“把它传给 runAgent,循环完全不知道背后是 Anthropic 还是别的。接口能这样便宜地测,跟它能随便换,是同一件事。阶段验收让你‘新增一个 fake ModelClient 确保现有测试仍通过’,就是这个意思:扩展缝好不好用,用最小代价试一遍就知道。”
小A有点意外:“fake 里 done 事件为什么建议 yield?少一个会怎样?”
“少了 done 本身不会让 runAgent 抛错或挂起。”老Z说,“看 agent.ts:流结束后,只有 text === "" && calls.length === 0 才会 throw new Error('model completed without text or tool calls');如果 fake 已 yield 文本或工具调用,缺少 done 也能走完当前实现。另一头,Anthropic 适配器 toModelEvents 会校验自己的源事件:message_stop 没收到就抛 'stream ended before message_stop',stop_reason 缺失就抛 'did not provide a stop_reason'。所以要分清两层:内部接口建议产出 done,fake 也应忠实模拟协议收尾;但不要断言 runAgent 强制要求 done。”
小A:“fake 和真 provider 的差距怎么控制?”
“两条原则。”老Z说,“第一,fake 只比真协议简单,不比真协议宽松:done 必须有,事件顺序要合法,tool input 要是对象。第二,能离线构造的契约全测:实现篇第2章用构造的 MessageStreamEvent 把 toModelEvents 的每条错误路径都喂了一遍,那些测试不依赖任何网络。fake 的价值不在像真,在契约可执行。”
| fake 的职责 | 真的能测 | 测不到 |
|---|---|---|
| 事件序列 | 循环的累积、分派、提交逻辑 | 真实 SDK 的时序与并发 |
| 合法/非法轮次 | 拒绝、失败、中止分支 | 网络中断、限流、超时 |
| 契约强制(done 必须) | model completed without text or tool calls 抛错路径 | provider 端实际行为 |
“真正要交到真实服务手里的验证,是另一类测试。main.ts 的 AnthropicModelClient 在测试里从不实例化(源码注释写着),真实端到端留给人工受控验证。”
Tool 是能力与副作用缝
“加新工具呢?”小A问。
老Z翻出 types.ts 里的另一个接口:
export interface Tool { readonly spec: ToolSpec;
execute(input: unknown, signal?: AbortSignal): Promise<ToolResult>; }“新增工具必须同时定义模型可见 schema 和执行时校验,schema 不是运行时防线。返回 isError 而不是吞掉错误,下一轮模型才能看到失败。测试至少覆盖 schema、有效调用、恶意输入、取消和副作用边界。并发尚未实现,工具不得假定同一路径写入已被队列保护。”
小A:“schema 不是运行时防线?schema 不就是校验输入吗?”
“schema 校验的是模型该传什么,不是代码实际收到什么。”老Z说,“看 tools.ts:read 工具的 schema 声明 path 是 string、required,但 execute 里第一行还是 const path = stringField(input, 'path'); if (!path) return { content: 'invalid read input: path must be a string', isError: true }。两层校验各管一段:schema 给模型看,运行时 stringField 给代码兜底。模型可能不遵守 schema,那是软约束;也可能哪里出了 bug,让畸形的 input 绕过 SDK 校验直接落到 execute。测试 records an invalid tool parameter as a tool error(见 agent-branches.test.ts)验证的就是这条:fake client 发了个 read 带 null input,工具返回 isError 而不是崩。”
小A:“新增一个工具,要过哪些检查才算完?”
“四关,各有归属。”老Z数着:
| 检查 | 谁做 | 失败长什么样 |
|---|---|---|
| schema 声明 | 模型侧 | 模型看到的输入约束 |
execute 里字段校验 | 工具实现 | invalid read input: path must be a string(isError) |
| 路径边界 | workspacePath | path escapes workspace(isError) |
| 副作用安全 | 工具实现 + 审批 | 写入被拦或返回错误 |
“最后两关多说一句:read 的路径校验不只看类型,还要 workspacePath 解析真实路径、防 symlink 逃逸,agent.test.ts 的 reads, truncates, writes nested paths, and rejects symlink escapes 专门造了外部目录和符号链接来打。工具的错误也不只要返回 isError,还要可定位,[truncated N characters] 这种消息能让模型知道内容被截了。”
小A:“工具失败会污染 session 吗?”
“看 tool-failure-session.test.ts 怎么写的。”老Z说,“工具抛错时,recordFailedCallAndSkippedCalls 把失败的调用记为 tool execution failed、后面的记为 not executed after an earlier failure,所有已声明的调用都要有收据,不能悬空。原异常同时会向上抛给 runAgent,但敏感信息(测试里的 sensitive credential: top-secret-token)不会进 session 文件。”
BeforeToolCall 是策略缝
小A:“审批呢?是不是也能加规则?”
“BeforeToolCall 只接收 ToolCall 并返回批准与原因,适合按名称、参数、用户确认或预算决定一次调用。”老Z说,“但它不能改写参数、审计所有进程,也构成不了权限系统。要加这些能力,得先重新设计类型与失败语义,再写实现和测试。”
小A:“不能改写参数?我想把 bash 命令里的危险词删掉再放行,不行吗?”
“现在的类型不支持。”老Z说,“BeforeToolCall 的返回类型是 { approved: boolean; reason?: string }(见 types.ts),它只能说‘行’或‘不行’,说不了‘行,但把参数改成这样’。想改写参数,得把返回类型扩成 { approved: true; rewrittenInput?: ... } | { approved: false; reason?: string }。这就不是加个字段的事:改写后的 input 要不要重新校验?要不要记进 session?模型看到的 tool result 对应原 input 还是改写后的?每一个问题都是新的失败语义。所以纪律还是那条:先确认改写需求真的存在,而不只是‘万一要用’,再设计类型,再写测试。denyBashByDefault(见 approval.ts)现在的做法是要么全放、要么全拒,简单,但安全。改写语义没想清之前,别动它。”
小A:“这条缝现在的失败路径有哪些?”
“三条,agent.ts 里分别处理。”老Z说,“一是策略抛错:beforeToolCall 本身 throw 了,循环把已声明的调用记为 tool approval failed,后续记为 not executed after an earlier failure,然后原异常上抛,tool-failure-session.test.ts 有覆盖。二是策略返回拒绝:approved: false,记录 reason ?? 'tool call rejected',跳过该调用继续。三是审批期间信号中止:recordAbortedCalls 把剩余调用记为 aborted before execution,返回 aborted。”
小A:“为什么 hook 抛错的时候不放行?”
“因为审批缝的默认姿态是宁拒勿放。”老Z说,“approval.ts 里 denyBashByDefault 对 bash 一律拒,agent.ts 对 hook 异常上抛而不是吞掉,两条路都不给‘策略坏了就当没有策略’留口子。审批是安全语义,安全语义的默认值必须是拒绝,或者至少是显式失败。这一点和实现篇第2章 stop_reason 的立场一致:不能判断的,不假装能判断。”
小A:“审批能看 session 吗?比如‘这个命令之前跑过’?”
“现在不能。”老Z说,“BeforeToolCall 的签名只收 call: ToolCall,没有任何上下文参数,它是个无状态谓词。要让策略参考历史,得把签名改成 (call, context),还得定义 context 是什么、可信度多高。这又是一个需求到了再长的字段:denyBashByDefault 现在只需要知道名字,其他策略等真实场景来提需求。”
尚未实现的需求触发条件
小A:“MCP、Skill、多 Agent 呢?什么时候才做?”
“它们都有明确的触发条件,不是想起来就加。”老Z把条件和失败证据放进同一张表:
| 愿望 | 触发条件 | 现在的失败证据 |
|---|---|---|
| MCP | 需要跨宿主发现外部能力,并能承担 transport、生命周期和信任模型 | 无:还没有第二个服务要接 |
| Skill | 说明包的发现、版本、冲突和评测已成重复成本 | 无:发布流程还在你脑子里 |
| 多 Agent | 子任务独立、状态隔离、取消和合并规则明确,且单 Agent 已量化为瓶颈 | 无:单 Agent 连完整会话都还没交付 |
“它们都没有本章可调用的 API,下面只是设计草图:McpClient 应隔离 transport 与工具适配,SkillCatalog 应返回版本化说明,TaskCoordinator 应拥有任务 ID、权限和取消传播。”老Z合上屏幕,“草图不要贴进 src 当作已交付功能。”
小A有点不甘心:“可我不留接口,将来不是要重构吗?”
“接口不是免费的。”老Z说,“每加一条接口,你就要为它维护类型、文档、测试和失败语义,没人调用的接口是负债。开头那三条缝能立住,靠的就是 loop 真的在调用它们。”
小A:“真到要加的时候怎么办?”
“到那时,需求会告诉你接口长什么样。”老Z说,“MCP 需要 transport 和信任模型,你自然会知道 McpClient 该有哪些方法;Skill 需要版本和冲突处理,你自然会知道 SkillCatalog 该返回什么。”
小A:“那我怎么判断‘需求真来了’,而不是我又想多了?”
“看有没有失败证据。”老Z说,“失败证据长这样:你或者用户真遇到了一个具体场景,现有三条缝解决不了,并且这个场景重复出现。比如 Skill,不是你‘觉得’需要,而是你发现自己第三次把同一段发布流程塞进 system prompt,第三次被上下文挤掉,第三次手动重写。MCP 的失败证据是:你有第二个服务的能力想接,直接写 fetch 已经让你维护了两套认证逻辑。多 Agent 的失败证据是:单 Agent 在某个子任务上量化为瓶颈,不是‘感觉慢’,是‘这一步占了 80% token’。没到这步,就是想多了。”
小结
这一章的三条扩展缝,ModelClient、Tool、BeforeToolCall,都已经被 loop 调用、被测试覆盖,是三条真实的契约。扩展它们就是扩展运行时行为,每一处都有明确的接入点。加第二家 Provider,必须把它的 SDK 流映射成 text、tool_call、done 三种内部事件,还要处理消息角色转换、工具 schema 适配、partial JSON 拼接、取消和错误语义,改个 URL 搞不定。加新工具,要实现 Tool 接口的 spec 和 execute,schema 校验在执行之前完成。
“被调用”和“被 import”是两回事,判断标准是调用链里有没有决策:options.client.stream 每轮必然进入,tool.execute 是分派核心,beforeToolCall 是审批入口,注释掉任何一个,测试就红。扩展的顺序因此清楚:先有失败证据证明需求存在,再增加类型、实现和回归测试。契约的验证主要落在 fakes 上,它们让契约变成可执行的东西,能离线覆盖拒绝、失败、中止分支;真实 provider 的验证是另一层,留给受控的人工端到端。
没有调用方和测试的接口会腐化,和实际行为脱节之后,就成了误导后来者的陷阱,所以别预先造一堆“以防万一”的抽象。产品里没有 Provider 注册表、没有重试机制、没有模型目录,这些缺位和已实现的部分一样,都是经过权衡的设计决策。
三条缝的边界也要分清:ModelClient 关厂商差异,Tool 管能力与副作用,BeforeToolCall 是无状态审批谓词。schema 是说明书,execute 里的运行时校验才是防线;审批的默认姿态是宁拒勿放,策略抛错绝不当没策略。改写参数、参考历史这类能力接口现在不支持,不是因为没想过,而是没有失败证据。
阶段验收
cd examples/mini-agent
npm test
npm run typecheck新增一个 fake ModelClient 或只读 Tool,确保所有现有测试仍通过;不要为 MCP、Skill 或多 Agent 编写不存在的调用示例。
