Appearance
第37章 为未来留三个插孔——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(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:
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,具体要重写哪些东西?”
“第 31 章那张映射表就是你的清单。”老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 是写死本家的。每一条都是第 31 章定义的协议边界,不是改个构造参数能糊弄过去的。”
“那接口本身不用动?”小a追问。
“不用。这正是第 31 章隔离的意义。”老z说,”ModelClient 的签名不关心厂商,三家 Provider 共用同一个 runAgent、同一套 model-client.test.ts 的契约测试思路。真正的工作量全在 adapter 内部,接口负责把差异关在门后。”
老z又打开 model-client.ts:
ts
if (input === null || typeof input !== "object" || Array.isArray(input))
throw new Error("provider returned non-object tool input");“正常路径是 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:“那我想试第二家 Provider,不写真 adapter 能验证接口吗?”
“能——用 fake。这也是为什么接口里没有厂商字段。”老z现场写了一个:
ts
// 示意:为测试假想的"第二家 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?少一个不行吗?"
"不行,少了它 runAgent 会抛错。"老z说,"看 agent.ts:流结束后,如果 text === "" && calls.length === 0,就 throw new Error('model completed without text or tool calls')。你的 fake 只 yield text 不 yield done,流就提前结束了——但更重要的是,toModelEvents(见 model-client.ts)本身会校验:message_stop 没收到就抛 'stream ended before message_stop',stop_reason 缺失就抛 'did not provide a stop_reason'。done 不是装饰,它是'这一轮完整结束'的信号,缺了它循环要么抛错、要么 hang。 fake 要忠实模拟真实 provider 的契约,否则你测的是假场景。"
小a:"那 fake 和真 provider 的差距怎么控制?"
"两条原则。"老z说,"第一,fake 只比真协议简单,不比真协议宽松——done 必须有,事件顺序要合法,tool input 要是对象。第二,能离线构造的契约全测——第 31 章用构造的 MessageStreamEvent 把 toModelEvents 的每条错误路径都喂了一遍,那些测试不依赖任何网络。fake 的价值不在'像真',在'契约可执行'。"
| fake 的职责 | 真的能测 | 测不到 |
|---|---|---|
| 事件序列 | 循环的累积、分派、提交逻辑 | 真实 SDK 的时序与并发 |
| 合法/非法轮次 | 拒绝、失败、中止分支 | 网络中断、限流、超时 |
| 契约强制(done 必须) | model completed without text or tool calls 抛错路径 | provider 端实际行为 |
"而真正要交到真实服务手里的验证,是另一类测试——main.ts 的 AnthropicModelClient 在测试里从不实例化(源码注释写着),真实端到端留给人工受控验证。fake 覆盖逻辑,真实服务覆盖协议,两层缺一不可。"
Tool 是能力与副作用缝
“那加新工具呢?”小a问。
老z打开 types.ts:
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 而不是崩。schema 是说明书,执行时校验才是防线。"
"那新增一个工具,要过哪些检查才算完?"小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] 这种消息能让模型知道内容被截了。 而且并发尚未实现,工具不得假定同一路径写入已被队列保护。"
"工具失败会污染 session 吗?"小a追问。
"看 tool-failure-session.test.ts 怎么写的。"老z说,"工具抛错时,recordFailedCallAndSkippedCalls 把失败的调用记为 tool execution failed、后面的记为 not executed after an earlier failure——所有已声明的调用都要有收据,不能悬空。 同时原异常会向上抛给 runAgent,但敏感信息(测试里的 sensitive credential: top-secret-token)不会进 session 文件。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。"
"为什么不 hook 抛错就放行?"小a追问。
"因为审批缝的默认姿态是宁拒勿放。"老z说,"approval.ts 里 denyBashByDefault 对 bash 一律拒,agent.ts 对 hook 异常上抛而不是吞掉——两条路都不给'策略坏了就当没有策略'留口子。审批是安全语义,安全语义的默认值必须是拒绝,或者至少是显式失败。 这一点和第 31 章 stop_reason 的立场一致:不能判断的,不假装能判断。"
"审批能看 session 吗?比如'这个命令之前跑过'?"小a再问。
"现在不能。"老z说,"BeforeToolCall 的签名只收 call: ToolCall,没有任何上下文参数——它是个无状态谓词。要让策略参考历史,得改签名为 (call, context),还得定义 context 是什么、可信度多高。这是又一个'需求到了再长'的字段:现在 denyBashByDefault 只需要知道名字,其他策略等真实场景来提需求。"
尚未实现的需求触发条件
小a:“那 MCP、Skill、多 Agent 呢?什么时候才做?”
“它们都有明确的触发条件,不是’想起来就加’。”老z列了出来:
- MCP 只在需要跨宿主发现外部能力、并能承担 transport、生命周期和信任模型时设计;
- Skill 只在说明包的发现、版本、冲突和评测已成重复成本时设计;
- 多 Agent 只在子任务独立、状态隔离、取消和合并规则明确且单 Agent 已量化为瓶颈时设计。
| 愿望 | 触发条件 | 现在的失败证据 |
|---|---|---|
| MCP | 跨宿主发现能力,能承担 transport 与信任模型 | 无——还没有第二个服务要接 |
| Skill | 流程说明重复成本已成规模 | 无——发布流程还在你脑子里 |
| 多 Agent | 单 Agent 量化为瓶颈、子任务可独立 | 无——单 Agent 连完整会话都还没交付 |
“它们都没有本章可调用 API,下面仅是设计草图:McpClient 应隔离 transport 与工具适配;SkillCatalog 应返回版本化说明;TaskCoordinator 应拥有任务 ID、权限和取消传播。”老z合上屏幕,“不要把草图贴进 src 当作已交付功能。”
小a有点不甘心:“可我不留接口,将来不是要重构吗?”
“接口不是免费的。”老z说,“每加一条接口,你就要为它维护类型、文档、测试和失败语义——一个没人调用的接口,是负债,不是资产。我们这三条缝,都是 loop 真的在调用的:ModelClient 被 runAgent 每轮调用,Tool 被工具分派调用,BeforeToolCall 被审批分支调用。‘被调用并测试’才是接口,写在注释里的叫愿望。”
“那真正需要加的时候怎么办?”小a问。
“到那时,需求会告诉你接口长什么样——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 里的运行时校验才是防线;审批的默认姿态是宁拒勿放,策略抛错绝不当没策略。改写参数、参考历史这类能力,接口现在不支持,不是因为没想过,而是因为没有失败证据——等真实场景来的时候,需求的形状会告诉你该长什么样。
阶段验收
sh
cd examples/mini-agent
npm test
npm run typecheck新增一个 fake ModelClient 或只读 Tool,确保所有现有测试仍通过;不要为 MCP、Skill 或多 Agent 编写不存在的调用示例。