主题
Tool Contract 与 Schema
本课交付结果
你将交付一个 defineTool() 契约入口:校验工具名、说明、JSON Schema 与权限声明,并返回不共享可变 schema 引用的标准化工具定义。
完成后,模型看到的工具目录、Runtime 的输入校验和权限系统将使用同一份事实来源。工具不再只是“一个能调用的函数”,而是可发现、可验证、可审计的能力单元。
岗位问题
真实 Coding Agent 往往集成几十个工具。如果工具名随意、描述含糊、输入结构只存在于 TypeScript 类型里,模型会生成错误参数,Runtime 也无法在执行前阻断危险输入。TypeScript 类型在运行时会被擦除,不能替代边界校验。
岗位要求是把自然语言能力说明、机器可读 schema、权限需求和执行函数合在一个稳定合同中,并让非法合同在注册阶段尽早失败。
前置检查
前置知识快照
先确认 Week 1 的结构化 Action 与最小循环通过:
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 04
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 05你需要理解 JSON 对象、正则表达式和浅拷贝。本课只校验工具“定义”,具体工具输入的逐字段校验将在 Runtime 集成时接入 schema validator。
先把三个容易混淆的概念分开。TypeScript 类型服务于编译器,JSON Schema 服务于运行时数据,权限声明服务于执行策略。它们描述的是同一个工具,却承担不同责任:类型让实现者少犯错,Schema 阻止外部输入越界,权限让系统在副作用发生前作决定。只保留其中任意一个,边界都会出现空洞。
还要记住上一课的 Action 协议只回答“模型想调用什么”。本课合同回答“系统允许模型看到怎样的工具、参数长什么样、执行会产生哪类副作用、失败如何被机器继续处理”。两者拼接之后,自然语言意图才第一次进入可验证的工程通道。
原理拆解
一个可用 Tool Contract 至少包含四层:
name是稳定的 snake_case 标识,供模型和 Trace 引用。description解释何时使用、返回什么,不应只是重复名称。inputSchema是运行时可读取的对象结构。permissions显式声明 read、write、execute 或 network 能力。
defineTool() 是系统的窄腰:上游 Provider 可把合同转换成模型工具声明,下游 Registry 可按名称发现和执行,策略层可在执行前比较权限。
mermaid
flowchart LR
A[工具作者] --> B[defineTool]
B --> C[名称与说明]
B --> D[JSON Schema]
B --> E[权限声明]
C --> F[模型工具目录]
D --> G[运行时输入校验]
E --> H[策略与审批]
G --> I[结构化 ToolResult]
H --> I这里最重要的设计不是字段数量,而是“单一事实来源”。如果 Provider 手写一份工具描述、执行器再写一份参数类型、权限系统维护第三份名单,三份信息迟早漂移。模型可能看到已经删除的参数,执行器可能接受模型从未被告知的字段,策略也可能把写工具误判成只读。合同应当在启动时一次定义,再由各层投影成所需格式。
合同还承担错误语言的统一。普通异常只适合告诉开发者“程序出了问题”;Agent 需要知道错误代码、可读消息以及是否值得重试。ToolResult<T> 因此使用可辨识联合:成功分支携带数据,失败分支携带 code、message 和 retryable。循环不必解析异常文本,就能决定修正参数、换工具、退避重试或停止。
为什么要求名字是 snake_case?因为名称同时进入模型 token、日志索引、策略键和跨进程协议。宽松命名会制造大小写、空格与转义差异。read_file 比“Read File”少了一层规范化,也更容易出现在稳定快照中。名称不是展示文案;给人看的解释属于 description。
Schema 本身也属于不可信输入。工具作者可能把数组、日期甚至函数误传给入口;调用方也可能在注册后继续修改原对象。如果合同保存共享引用,模型目录已经发送旧版本,而校验器看到新版本,同一次执行就会出现自相矛盾。因此 Lab 用 structuredClone 建立隔离边界。它不是完整的不可变方案,却足以证明返回定义不受外部顶层或嵌套修改影响。
代码实验
失败实现
最诱人的实现是把输入断言成接口后原样返回:
ts
export function defineTool(input: unknown): ToolDefinition {
return input as ToolDefinition;
}这段代码在编辑器里很安静,却没有完成任何运行时验证。as 只是在要求编译器停止追问;当对象来自配置文件、插件或网络时,空名称、数组 Schema 和未知权限都会直接穿过边界。更隐蔽的问题是引用共享:注册完成后修改原 Schema,已经发布给模型的合同含义也随之改变。
正确实现要按“先确认对象,再逐字段收窄,最后复制”的顺序工作:
ts
const permissions = new Set<ToolPermission>(["read", "write", "execute", "network"]);
export function defineTool(input: unknown): ToolDefinition {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
throw new Error("工具定义必须是普通对象");
}
const value = input as Record<string, unknown>;
if (typeof value.name !== "string" || !/^[a-z][a-z0-9_]*$/.test(value.name)) {
throw new Error("工具名必须是 snake_case");
}
if (typeof value.description !== "string" || value.description.trim() === "") {
throw new Error("工具说明不能为空");
}
if (typeof value.inputSchema !== "object" || value.inputSchema === null || Array.isArray(value.inputSchema)) {
throw new Error("inputSchema 必须是对象");
}
if (typeof value.permission !== "string" || !permissions.has(value.permission as ToolPermission)) {
throw new Error(`未知权限:${String(value.permission)}`);
}
return { name: value.name, description: value.description.trim(),
inputSchema: structuredClone(value.inputSchema as Record<string, unknown>),
permission: value.permission as ToolPermission };
}注意 Lab 的字段是单个 permission,而总项目合同还包含 ToolContext、泛型 Tool 执行接口与更丰富的失败详情。课程故意先把最小概念做实,再迁移到生产形态;不要为了让 Lab 看起来“高级”而提前复制所有基础设施。
bash
pnpm --dir bootcamps/coding-agent/labs/06-tool-contract/starter test
pnpm --dir bootcamps/coding-agent/labs/06-tool-contract/solution testStarter 会以 EXERCISE_NOT_IMPLEMENTED:实现 Tool Schema 校验 暴露唯一练习缺口;Solution 应显示 4 项测试通过。测试覆盖合法合同、非法名称、空描述、非法权限与 schema 防御性复制。
测试应直接证明边界行为,而不是复述实现。下面的断言先定义合同,再修改调用方持有的原始 Schema;若定义中的嵌套值没有变化,才能说明隔离成立:
ts
it("隔离调用方对 schema 的后续修改", () => {
const schema = { type: "object", properties: { path: { type: "string" } } };
const tool = defineTool({ name: "read_file", description: "读取工作区文本文件",
inputSchema: schema, permission: "read" });
schema.properties.path.type = "number";
expect(tool.inputSchema).toEqual({
type: "object", properties: { path: { type: "string" } },
});
});关键实现讲解
不要依赖调用方“自觉”传入正确对象。入口先缩小类型,再验证字段:
ts
if (!/^[a-z][a-z0-9_]*$/.test(input.name)) throw new Error("工具名必须是 snake_case");
if (!allowedPermissions.has(permission)) throw new Error(`未知权限:${permission}`);返回时复制 inputSchema 和权限数组,避免注册之后调用方修改原对象,导致模型目录与执行策略悄悄漂移。生产实现还应冻结深层 schema,或在启动时编译成不可变 validator。
有意把合同校验放在注册之前,会让错误更便宜。启动失败只影响开发或部署;执行到一半才发现非法合同,可能已经消耗模型调用、修改文件并污染 Trace。这个原则也解释了为何错误消息需要带具体字段:配置作者应该看到“工具名必须是 snake_case”,而不是笼统的“invalid tool”。
权限枚举不是安全机制本身,它是一份可供安全机制消费的声明。如果写工具错误地声明 read,合同校验无法推断真实副作用。因此生产系统还要在实现审查、文件沙箱和操作系统权限上形成纵深防御。本课的价值,是让权限从口头约定变成可检查数据。
结果对象也不要混用“抛异常”和“返回失败”。参数错误、文件不存在、命令退出非零属于预期业务失败,应该转成 ToolResult;进程内不变量破坏或真正的程序缺陷可以抛出,再由 Registry 最外层捕获归一化。这样调用者永远只面对一种跨边界协议,同时日志仍保留异常堆栈供开发者定位。
运行与验证
真实运行输出
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 06
pnpm --filter @coding-agent/contracts test单课门禁应报告 observedTests: 4。再手工尝试 ReadFile、空白描述和 admin 权限,确认错误发生在注册之前,且消息能指出具体合同问题。
在未实现 Starter 中,运行结果为三项失败、一项通过;失败集中在未完成的 Schema 校验分支。切换到 Solution 后,四项测试全部通过。可把关键结果压缩成下面的审计记录:
text
starter: 3 failed, 1 passed
solution: 4 passed, 0 failed
覆盖: 防御性复制 / 名称与权限拒绝 / 说明与 Schema 拒绝 / 结构化结果不要只看绿色总数。逐个核对失败是否来自预期练习缺口,Solution 是否没有跳过测试,以及测试是否真的修改了原始 Schema。若只是对两个不同字面量做相等比较,绿色也不能证明防御性复制。
常见失败与排查
故障案例 1
类型正确,运行时合同却为空。
症状:TypeScript 编译通过,但插件加载后模型收到空名称或数组形式的 Schema。
根因:使用类型断言代替运行时收窄,把外部 JSON 当成了可信的静态对象。
定位:在 defineTool 入口检查原始值的类型、是否为数组,并用最小非法对象直接调用;若没有同步抛错,边界根本不存在。
修复:先检查普通对象,再逐字段验证字符串、枚举和 Schema 形态;为每类非法输入保留独立测试。
故障案例 2
注册后目录悄悄变化。
症状:启动时工具声明正确,运行一段时间后 validator 接受的参数与模型上下文不一致。
根因:定义保存了调用方的可变 Schema 引用,其他初始化代码随后修改了嵌套属性。
定位:注册后修改原始对象,再比较注册表快照;若同步变化,就是引用泄漏。不要只测顶层展开,浅拷贝保护不了嵌套对象。
修复:在合同入口深拷贝,生产环境进一步冻结或编译 Schema;所有消费者只读取合同快照。
故障案例 3
权限拼写让策略失效。
症状:工具声明 wirte 或 admin 后仍成功注册,审批层找不到对应规则,最终走了意外默认分支。
根因:权限被设计成任意字符串,或者策略对未知值采取放行。
定位:枚举所有已注册权限,与 allowlist 做差集;再检查策略的 default 分支是否拒绝未知值。
修复:合同入口使用封闭枚举并尽早失败,策略层保持默认拒绝;增加每个权限到审批行为的映射测试。
- 只写 TypeScript interface:运行时收到 JSON 后没有任何保护。
- 允许空描述:模型只能靠猜测决定何时调用。
- 未限制权限枚举:拼写错误会绕开策略比较。
- 原样返回 schema 引用:注册后被修改,工具目录和 validator 不一致。
- 在执行阶段才校验合同:错误发现太晚,启动门禁无法定位配置问题。
课后作业
为合同增加 outputSchema 与 version,写两项测试证明旧版本拒绝不兼容升级,并让返回合同在调用方修改嵌套 schema 后仍保持不变。说明你选择深拷贝、冻结还是编译 validator 的理由。
进阶要求是设计一份迁移策略:同名工具的小版本只能增加可选字段,大版本才允许删除字段;Trace 必须记录实际调用的合同版本。再写一个“权限声明与实现不一致”的评审清单,明确哪些问题能自动检查,哪些必须靠代码审查或沙箱验证。
验收 Rubric
| 维度 | 通过标准 | 常见扣分 |
|---|---|---|
| 命名 | 只接受稳定 snake_case 标识 | 名称含空格或大小写漂移 |
| Schema | 运行时确认对象结构并隔离引用 | 只依赖静态类型 |
| 权限 | 只接受 allowlist 中的能力 | 任意字符串都可注册 |
| 错误 | 注册阶段给出可定位消息 | 执行后才暴露问题 |
总项目增量
总项目包:@coding-agent/contracts
总项目路径:packages/contracts/src/tool.ts
总项目验证命令:pnpm --filter @coding-agent/contracts test
这一增量把 Week 1 的临时工具字典升级为跨 Provider、Runtime 和策略层共享的合同,为下一课 Registry 的发现与分发建立边界。
延伸阅读
方案对比与工程取舍
把合同放进一次完整调用来观察,会更容易理解每个字段为何存在。假设用户要求“读取配置并解释端口”。模型先从目录里看到 read_file,说明文字帮助它排除搜索与写入工具,Schema 让它生成只含相对路径的参数。Runtime 收到 Action 后先验证字段,再把 read 权限交给策略判断,最后调用实现。文件不存在时,工具返回稳定错误码;循环据此决定先列目录,而不是把一段堆栈重新喂给模型。整个过程中,没有任何一层需要猜测另一层的私有约定。
相反,如果描述只写“读文件”,模型不知道是否允许二进制文件、最大尺寸是多少,也不知道返回值是否包含行号。好的说明应短而具体,通常包含适用条件、重要限制和结果形态。例如“读取工作区内不超过一兆字节的 UTF-8 文本,返回内容与相对路径;目录请使用 list_files”。这类文字会占用更多 token,却显著减少误调用。说明不是营销文案,而是模型的操作手册。
Schema 的严格程度也需要取舍。允许额外字段能容忍模型生成无害冗余,却可能把拼写错误悄悄丢掉;禁止额外字段能尽快暴露错误,但模型版本变化时失败率可能上升。高风险写入工具应倾向严格并要求所有关键字段,高频只读工具可以在 Runtime 显式清理少量已知冗余。无论选择哪种,都要写进合同版本和评测,而不是依靠不同工具作者的个人习惯。
错误码应描述可行动的类别,而不是实现细节。FILE_NOT_FOUND 能提示 Agent 改路径,INVALID_INPUT 能提示重建参数,PERMISSION_DENIED 表示需要审批或停止;ENOENT、某个库的类名或完整本地路径则属于内部细节。对外消息既要足够定位,又不能泄露工作区外的路径、环境变量或命令输出。Trace 可保存受控的内部原因,模型只接收经过净化的失败结果。
可重试标志同样不能凭感觉设置。网络暂时失败、服务限流可能重试;Schema 不合法、权限拒绝和文件永久不存在通常不应原样重试。如果把所有错误都标成可重试,Agent 会在相同输入上烧掉预算;全部标成不可重试,又会让暂时故障过早终止。更稳妥的做法是由错误码映射默认策略,并允许循环结合剩余预算与已尝试次数收紧判断。
合同审查可以使用一份固定问题表:名称是否稳定且无实现版本号;说明是否明确使用时机与限制;必填字段是否真的必需;额外字段策略是否明确;权限是否覆盖全部副作用;返回能否序列化;错误码能否驱动下一步;是否包含秘密或绝对路径;Schema 是否经过隔离;升级是否提供兼容证据。每增加一个工具都回答这些问题,比故障发生后追查三层漂移便宜得多。
当工具数量增长时,还应生成合同目录快照并纳入代码评审。快照变化能直接显示某次提交新增了网络权限、删除了必填字段或修改了说明。对安全敏感工具,可以要求权限变化必须由指定负责人审批。这样合同不只是代码结构,也成为团队治理界面。
最后考虑可观测性。每次工具调用至少记录工具名、合同版本、输入摘要、权限决策、持续时间和结果码;敏感值按 Schema 标注脱敏。若 Trace 只记函数名和异常文本,版本升级后就无法解释历史调用为什么通过校验。稳定合同让线上行为可以回放,也让后续评测拥有可靠维度。
团队还可以把合同生成文档、测试夹具和模拟工具。文档用于人审,夹具用于 Provider 兼容测试,模拟工具让 Agent 循环在不接触真实文件与网络时完成回放。三种产物都从同一合同派生,才能避免“文档说一种、测试造一种、线上跑另一种”。当合同成为生成源,新工具的接入成本反而会随标准化程度下降。
判断本课是否真正完成,可以做一次盲测:只把合同目录交给未参与开发的人,让他解释每个工具何时调用、最小参数、可能副作用和失败后的动作。如果答案仍依赖阅读实现,说明合同的信息密度不足;如果任何人都能仅凭目录预测行为,边界才算站稳。
上线前再保存一组非法合同样本,覆盖空白说明、错误命名、数组 Schema、未知权限和嵌套对象变异。以后无论更换校验库还是调整类型,只要这些样本仍被稳定拒绝,合同边界就没有在重构中悄悄变宽。边界测试保护的不是某段实现,而是系统长期不变的安全承诺。
手写类型守卫依赖最少、错误消息最可控,适合本课的小合同,但字段多时容易漏分支。JSON Schema 是跨语言标准,能直接提供给模型 API,也便于缓存编译后的 validator;缺点是类型推导与错误格式需要额外封装。运行时 Schema 库拥有良好 TypeScript 体验,却需要转换成模型接受的 JSON Schema,并关注转换丢失的语义。
深拷贝、深冻结与编译 validator 也解决不同问题。深拷贝切断外部引用,深冻结阻止内部误改,编译把声明变成高效执行器。生产系统通常三者组合:启动时解析和复制,生成不可变目录,同时缓存 validator。课程 Lab 选择 structuredClone,因为它最直接地暴露“所有权”这一核心概念。
结构化结果比全抛异常多一些样板,却给 Agent 明确控制信号。反过来,若所有失败都吞成失败对象而没有 Trace 和堆栈,程序缺陷会被伪装成普通业务失败。合理边界是在 Registry 捕获未知异常、记录内部原因、向模型返回稳定且不泄密的错误。
工程上还要考虑 Schema 的规模。把几十个冗长合同每轮都塞给模型会显著占用上下文;过度压缩说明又会降低选对工具的概率。常见做法是保留简短、行动导向的说明,只公开必要字段,把详细示例放在按需加载的技能文档中。合同首先服务于正确选择和正确调用,不应成为产品手册。
版本管理不能只给字段加一个数字。兼容性规则必须能自动判定:新增可选字段通常向后兼容,新增必填字段、收紧枚举或改变返回语义通常不兼容。部署前比较旧新 Schema,并让旧 Trace 在新实现上回放,才能证明升级不会让正在运行的会话突然失效。
最后审视信任边界。内置工具由同一团队维护,启动期校验往往足够;第三方插件则需要把合同和实现都视为不可信,限制进程权限、网络和文件系统能力。声明 permission: "read" 不能阻止恶意实现发起网络请求。合同是策略输入,而不是沙箱替代品。
下一课衔接
合同只定义了能力,还没有回答几十个工具如何注册、查找和分发。下一课将实现 Registry:它必须拒绝重名、给模型稳定排序的定义快照、把未知工具变成结构化失败,并截住执行函数抛出的异常。你会看到本课的不可变合同如何成为注册表的唯一输入。
- JSON Schema 的对象、required 与 additionalProperties 语义。
- Design by Contract 与“尽早失败”原则。
- 不可变配置如何降低长运行 Agent 的状态漂移。
本课合同是应用层边界,不等同于操作系统沙箱;权限字符串只有接入强制执行策略后才真正产生安全效果。