Skip to content

训练营地图与任务契约

本课交付结果

想象你把一句“帮我修复登录 Bug”交给 Coding Agent。它很快修改了三个文件,顺手升级了依赖,又访问公网查了一段代码,最后告诉你“问题已经解决”。表面上动作很多,真正追问时却没有一个关键问题得到回答:它应该在哪个仓库工作?允许修改哪些路径?所谓“解决”由哪条测试证明?能否访问网络?依赖文件是否属于任务范围?如果这些边界只存在于提问者脑中,Agent 就只能猜。猜对一次不等于系统可靠,猜错一次却可能污染工作区、泄露内容或产生无法审查的巨大 Diff。

本课先不调用模型,也不实现工具。你要交付的是整个 Runtime 的第一块地基:parseTaskSpec(value)。它把外部输入收缩为一个不可变的 TaskSpec,其中包含非空任务描述、绝对工作区路径和字符串约束列表。解析成功意味着后续组件拿到的是经过验证的事实;解析失败则应在任何模型调用、文件读取和进程执行之前终止。

完成本课后,你不仅会写出一个对象校验函数,还会建立训练营贯穿 40 课的思考方式:先定义可观察的合同,再实现会产生副作用的能力;先说明允许做什么,再讨论模型能做什么;先设计失败证据,再接受“完成”这个结论。这个顺序是 Coding Agent 与普通聊天机器人最根本的区别之一。

岗位问题

自然语言适合表达意图,却不适合直接充当执行协议。“修复登录 Bug”可能指修复前端表单、后端会话、数据库迁移,也可能只是修正错误提示。一个经验丰富的工程师会在动手前补齐上下文:仓库位置、复现步骤、允许路径、验收测试、不可触碰区域和时间预算。Coding Agent 没有默认拥有这些组织知识;如果 Runtime 不把它们显式化,模型会把缺失信息填成自己认为合理的答案。

这类问题不是简单的“Prompt 写得不够好”。Prompt 是给模型看的上下文,任务契约是 Runtime 用来约束所有组件的结构化事实。即使模型忽略提示,文件工具仍然只能在 workspace 内解析路径;即使模型建议访问网络,权限层仍然可以依据约束拒绝;即使模型声称完成,评测器仍然会检查验收命令。契约因此位于信任边界上:它接受不可信输入,输出后续代码可以依赖的不变量。

缺少任务契约通常会产生四种工程后果。第一,作用域漂移:Agent 从一个文件扩展到整个仓库,Diff 越来越大。第二,完成条件漂移:最初要求测试通过,最后退化为“代码看起来合理”。第三,环境漂移:相对路径随启动目录变化,同一命令在本地和 CI 操作不同仓库。第四,责任漂移:出错后无法判断是用户输入不完整、模型推理错误,还是工具突破了边界。一个小小的解析函数,实际承担的是把这些模糊责任切开。

真正的岗位能力不是把所有信息塞进一个巨型接口,而是选择当前阶段必须稳定的最小字段。第 1 课只保留 taskworkspaceconstraints,因为它们足以建立任务身份、资源边界和显式限制。允许路径、预算、测试命令会在后续课程进入更专门的合同。这样既避免一开始设计无法验证的“万能任务格式”,也为后续演进留下清晰位置。

前置检查

前置知识快照

你需要熟悉四个基础概念。第一,TypeScript 的 unknown 表示“调用方可以给任何值,但使用前必须缩窄”,它比 any 更适合信任边界。第二,类型声明只存在于编译期,来自 JSON、命令行或网络的数据不会因为写了接口就自动可信。第三,Node.js 的 path.isAbsolute() 可以判断路径形式是否为绝对路径,但它还不负责判断路径是否存在或是否逃出符号链接边界。第四,Object.freeze() 是浅冻结;若对象包含数组,需要分别冻结数组和外层对象。

先确认环境和课程清单可读:

bash
node --version
pnpm --version
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 01

本课的独立 Lab 不依赖公网和真实 API Key。Starter 已经声明公开接口,但会抛出 EXERCISE_NOT_IMPLEMENTED;你的任务不是改变测试,也不是删除错误,而是在同一个接口后补上验证逻辑。Solution 是参考实现,不是要求逐字复制。阅读它时要能解释每一条分支保护了哪个不变量。

还要区分三个容易混淆的词。任务描述回答“要解决什么问题”;约束回答“解决时不能突破什么边界”;验收标准回答“用什么证据判断完成”。本课把前两者写入最小合同,测试暂时承担验收标准。后续课程会把测试、预算、权限和 Trace 逐渐变成一等公民。

原理拆解

任务契约的转换过程可以画成一条窄化管道:

mermaid
flowchart LR
  A[外部 unknown 输入] --> B{必须是普通对象}
  B -->|否| X[拒绝并给出字段错误]
  B -->|是| C{task 非空}
  C -->|否| X
  C -->|是| D{workspace 为绝对路径}
  D -->|否| X
  D -->|是| E{constraints 为字符串数组}
  E -->|否| X
  E -->|是| F[复制并冻结约束]
  F --> G[冻结 TaskSpec]
  G --> H[交给 Runtime 其他组件]

这条管道体现了“解析,而不是断言”的区别。类型断言 value as TaskSpec 只是告诉编译器停止检查,运行时没有任何事情发生。解析函数则逐项证明输入满足合同,并创建一个新的、规范化的值。task.trim() 消除首尾空白带来的等价输入;复制 constraints 避免调用方保留原数组引用后再修改;冻结结果让下游无法意外改变任务边界。

验证顺序也有含义。先排除 null、数组和原始值,才能安全读取字段;再验证任务和工作区,最后检查约束集合。每个错误都指向一个具体字段,而不是统一抛出“参数错误”。可定位错误不是锦上添花:CLI 会把它展示给用户,Trace 会记录失败类型,评测器还可以据此判断是任务配置错误还是 Agent 执行错误。

为什么 workspace 必须是绝对路径?相对路径的含义依赖进程当前目录。开发者从仓库根目录运行时,./demo 可能正确;测试框架从包目录启动时,同一字符串可能指向另一个位置。把解析时刻的环境差异消除,后续文件工具才能围绕一个稳定根目录工作。注意,绝对路径只是第一层不变量;第 8 课还会处理 ..、符号链接和真实路径包含关系。

为什么本课不验证约束文本的语义?因为“不得访问网络”和“只修改 src”仍是人类语言,当前函数无法可靠理解所有句式。它只保证约束是稳定的字符串集合,后续权限和工具层再把可执行的约束结构化。把无法兑现的语义承诺塞进解析器,会让接口看似强大、实际没有防护。

合同如何贯穿一次 Agent 运行

任务契约不是解析完成后就被丢掉的启动参数。它应当成为一次运行的身份骨架。CLI 从用户输入创建它;Runtime 以 workspace 组装文件工具和进程执行器;模型上下文从 task 提取目标,但模型不能改写工作区;权限层把 constraints 转化为可执行决策;Trace 在 run_started 事件中记录经过脱敏的任务摘要;Evaluation 最后用同一任务目标检查结果。如果每层各自读取原始命令行或环境变量,就会形成多个彼此矛盾的“真相来源”。

这种单向数据流还有一个重要收益:更容易测试。解析器测试只验证输入窄化;工具测试接收已经有效的根目录,不必重复测试空任务;评测测试接收稳定任务标识,不必启动 CLI。每个组件信任上游已经证明的部分,同时验证自己新接触的不可信边界。这样既不会在所有函数里复制同一组检查,也不会把所有安全责任寄托在最外层。

合同还帮助我们区分“配置错误”和“执行失败”。相对工作区、空任务、错误约束类型应在运行开始前失败,用户修正输入即可;模型超时、工具报错、测试失败则发生在一个有效任务内部,需要进入 Trace 和恢复策略。如果两类错误混在一起,Agent 可能对配置错误进行无意义重试,或者把可恢复的工具问题直接甩给用户。稳定的入口错误分类,是后续 Failure Taxonomy 的前提。

为什么错误信息也是合同的一部分

很多实现者只关心成功返回值,把错误文字当作可以随意修改的细节。但在命令行产品里,错误信息同时被用户、测试、日志和上层错误分类器消费。“task 必须是非空字符串”比“invalid input”多提供了字段、期望和修复方向;它让用户无需查看栈就能纠正任务,也让自动测试能确认失败发生在正确边界。

这并不意味着永远把底层异常原样暴露。路径、密钥和内部栈可能包含敏感信息。入口解析错误应由我们主动构造,保持稳定且不包含本机数据;文件系统或网络错误则在更靠后的层被归一化和脱敏。本课的错误简单,是因为解析器不执行外部副作用。保持这种纯度,会让失败既清晰又安全。

还要避免一次报告所有字段错误的冲动。表单界面可能适合聚合错误,CLI 和程序接口则常采用遇到第一个不变量破坏就停止。这样控制流短、错误优先级稳定,也不会在对象形状尚未证明时继续读取危险字段。若未来产品需要批量诊断,可以返回结构化问题数组,但应作为明确的新合同设计,而不是随手把字符串拼在一起。

代码实验

打开本课 Lab

先运行 Starter:

bash
pnpm --dir bootcamps/coding-agent/labs/01-course-map/starter test

失败实现

最危险的“实现”不是直接抛错,而是用类型断言伪装完成:

ts
export function parseTaskSpec(value: unknown): TaskSpec {
  return value as TaskSpec;
}

这段代码能通过 TypeScript 编译,却会接受 null、相对路径、数字约束以及可被调用方继续修改的数组。它没有建立任何运行时不变量,只是把不确定性推给每一个下游组件。另一种常见失败是使用真值判断,例如 if (!input.task);它会漏掉只包含空格的字符串,也没有告诉读者期望的类型。

参考实现从普通对象检查开始:

ts
export function parseTaskSpec(value: unknown): TaskSpec {
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
    throw new Error("任务契约必须是对象");
  }
  const input = value as Record<string, unknown>;
  if (typeof input.task !== "string" || input.task.trim() === "") {
    throw new Error("task 必须是非空字符串");
  }
  if (typeof input.workspace !== "string" || !isAbsolute(input.workspace)) {
    throw new Error("workspace 必须是绝对路径");
  }
  if (!Array.isArray(input.constraints)
    || !input.constraints.every((item) => typeof item === "string")) {
    throw new Error("constraints 必须是字符串数组");
  }
  const constraints = Object.freeze([...input.constraints]);
  return Object.freeze({
    task: input.task.trim(),
    workspace: input.workspace,
    constraints,
  });
}

测试从外部行为而不是内部写法验收合同:

ts
it("accepts a complete task and freezes its constraints", () => {
  const parsed = parseTaskSpec({
    task: "修复解析器",
    workspace: "/workspace/demo",
    constraints: ["不访问网络", "先运行测试"],
  });
  expect(parsed).toEqual({
    task: "修复解析器",
    workspace: "/workspace/demo",
    constraints: ["不访问网络", "先运行测试"],
  });
  expect(Object.isFrozen(parsed)).toBe(true);
  expect(Object.isFrozen(parsed.constraints)).toBe(true);
});

这个测试同时证明规范化结果、外层冻结和嵌套数组冻结。其余测试分别拒绝空任务、相对工作区和非字符串约束,使每个失败分支都有可观察证据。

关键实现讲解

第一处关键选择是入口类型必须为 unknown。如果写成 TaskSpec,调用方在编译期似乎被约束,但 JSON 解析、JavaScript 调用或错误断言仍能送入坏数据,函数内部也失去了显式缩窄的压力。把不可信边界标成 unknown,会迫使实现者逐项证明。

第二处选择是拒绝数组。JavaScript 中数组的 typeof 也是 object,如果只检查对象类型,[] 会穿过第一道门。null 同样具有这个历史行为,因此三个条件必须同时存在。通过后把值视为 Record<string, unknown> 是局部且有证据的断言:我们只知道它是可索引对象,尚未假设字段正确。

第三处选择是复制再冻结。直接 Object.freeze(input.constraints) 会改变调用方传入的数组,形成意外副作用;只写 Object.freeze({ constraints: input.constraints }) 又无法阻止数组内容变化。[...input.constraints] 先取得所有权,随后冻结新数组和返回对象,调用双方的生命周期被切开。

第四处选择是保留原始绝对路径,而不是在这里调用 realpath。解析函数应该是纯同步验证,不要求目标已经存在。创建新项目时,工作区可能尚未生成;测试 fixture 也可能使用逻辑路径。文件系统存在性和符号链接解析属于工具执行时的资源检查,过早耦合会降低合同复用性。

最后要理解“不可变”不是绝对安全。Object.freeze 只保护当前对象图的一层,而且 TypeScript 的 readonly 主要保护编译期调用方式。这里 constraints 只有字符串,复制和双层冻结已经足够;若未来加入嵌套策略对象,需要递归冻结、深拷贝或使用真正的不可变值结构。合同设计必须跟随数据复杂度,而不是迷信某个 API。

运行与验证

实现完成后依次运行:

bash
pnpm --dir bootcamps/coding-agent/labs/01-course-map/solution test
pnpm --filter @coding-agent/contracts test
pnpm --dir bootcamps/coding-agent/checkpoints/week-01-minimal-agent smoke

真实运行输出

本课 Solution 的真实稳定摘要如下;时间和绝对路径属于环境噪声,因此不写进验收合同:

text
✓ ../tests/lab.test.ts (3 tests)
Test Files  1 passed (1)
Tests       3 passed (3)

第 1 周检查点还会给出累计证据:

text
第 1 周检查点通过:最小 Agent 完成 echo 后结束
{"week":1,"ok":true,"summary":"最小 Agent 完成 echo 后结束","evidence":["echo:hello"]}

不要把“Solution 测试绿了”理解为整个 Agent 已经安全。本课只证明任务输入的最小合同。真实系统仍需要路径包含、权限策略、命令限制、预算、Diff 审查和评测。课程采用逐层证明:每课只对自己新增的不变量负责,周检查点再证明这些能力能够组合。

常见失败与排查

故障案例 1

症状:传入 { task: "修复", workspace: "./demo", constraints: [] } 后,本地运行正常,CI 却修改了错误目录。根因:实现接受相对路径,路径含义取决于启动进程的当前目录。定位:在失败环境打印解析前的 cwd 和任务工作区,比较解析结果;检查是否缺少 isAbsolute 分支。修复:在合同入口拒绝相对路径,由 CLI 在更了解 cwd 的地方先解析为绝对路径。

故障案例 2

症状:任务创建后,调用方给原始 constraints 数组追加“允许访问网络”,下游读到的合同也随之变化。根因:返回对象复用了调用方数组,只冻结外层对象。定位:写一个测试,在解析后修改原数组,再检查结果;分别调用 Object.isFrozen 检查两层。修复:用展开语法复制数组,冻结复制结果,再冻结外层对象。

故障案例 3

症状task: " " 被接受,模型收到一个没有目标的任务并开始自行探索仓库。根因:实现只检查字符串类型或简单真值,没有检查 trim() 后长度。定位:补充空字符串、纯空格和带首尾空格三个边界用例。修复:验证 input.task.trim() !== "",返回时保存规范化后的文本。

还有一种排查误区:看到 TypeScript 报错就把输入改成 any。这会让编译器安静,却没有改变运行时数据。边界验证失败时应先问“哪个不变量没有被证明”,而不是“怎样让类型系统别提示”。

课后作业

第一组作业是增加可选 acceptance 字段,其中每一项仍是非空字符串。要求保持向后兼容:字段缺失时返回空数组,字段存在但类型错误时给出明确错误,并证明返回数组不可变。不要把测试命令直接执行;这里只负责合同。

第二组作业是设计一个 TaskSpecV2,加入 allowedPathsdeniedPaths。写出至少四个冲突例子,例如同一路径同时允许和拒绝、允许路径位于工作区之外、空路径、重复路径。说明哪些冲突应在解析时拒绝,哪些需要等到 realpath 可用时处理。

第三组作业是把一次真实开发需求改写成任务契约。必须包含:一句可验证任务描述、绝对工作区、三条明确约束、两条验收证据。让另一位同学只看合同判断哪些改动属于越界;如果双方理解不同,继续收窄措辞。

知识检查:为什么接口声明不能代替运行时解析?为什么复制后冻结优于冻结调用方数组?绝对路径解决了什么,又没有解决什么?哪些字段应该留给权限层而不是塞进任务解析器?

验收 Rubric

维度通过标准常见扣分
输入边界拒绝 null、数组、原始值与错误字段类型使用 as TaskSpec 跳过验证
任务语义拒绝空白任务并保存 trim 后文本只检查 truthy
工作区只接受绝对路径,并说明尚未处理 realpath接受依赖 cwd 的相对路径
约束集合每项为字符串,复制并冻结数组复用调用方引用
可诊断性错误指出具体字段与期望统一抛出“参数错误”
证据Starter 按声明失败、Solution 三项测试通过修改或跳过测试

总项目增量

总项目包:@coding-agent/contracts

总项目路径:packages/contracts/src/task.ts

总项目测试:packages/contracts/tests/contracts.test.ts

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

本课把 task-contract 能力映射到第 1 周检查点。独立 Lab 使用 workspace 字段,总项目进一步命名为 workspaceRoot,强调它是所有资源解析的根。后续实现不应从任意工具参数重新决定仓库位置,而应从这个合同向下传递统一根目录。

本课增量虽小,却决定了所有后续能力的方向:CLI 负责从用户输入创建合同,Provider 接收由合同派生的任务上下文,工具和 Sandbox 以工作区为边界,Evaluation 用任务与约束判断结果。如果入口事实不稳定,后面每一层都会在不同假设上工作。

延伸阅读

方案对比与工程取舍

自由文本 Prompt 的优势是表达灵活、启动成本低,缺点是 Runtime 无法可靠执行边界;它适合补充背景,不适合独自承担授权。完整工作流 DSL 可以精确描述依赖、步骤和回滚,却会把第 1 课变成工作流引擎设计,用户也要承担更高填写成本。最小类型化合同位于两者之间:只结构化必须稳定的事实,把开放语义留在任务文本中,把专业约束交给后续组件。

JSON Schema 或运行时校验库能减少手写分支,并生成更标准的错误;手写解析器则让初学者清楚看到每个不变量,也不增加依赖。本训练营先用手写实现建立模型,生产项目可在合同复杂后迁移到成熟 schema 工具。迁移时应保持外部错误语义和测试,而不是让库的默认报错泄漏到用户界面。

下一课衔接

现在我们拥有稳定任务,但它仍只是内存中的对象。下一课要解决“谁创建它、程序如何启动、错误怎样变成退出码”的问题:构建 Runtime 骨架与 CLI composition root。你会看到命令行解析并不是把字符串拆成数组那么简单,它是把不稳定的人机输入转换成第 1 课合同的第一个产品入口。

建议继续阅读 Node.js path 文档中绝对路径与解析函数的区别,并复习 TypeScript unknown 的缩窄方式。阅读时始终问一句:这项保证发生在编译期还是运行期,它能否被测试观察?

从零实现 Mini Code Agent Runtime