教程整体目标
通过逐章可运行的增量,理解 Agent 的模型、上下文、任务状态、规划、工具、Skills 与 Agent Loop 等关键组件,并从零构建一个由 Harness 约束、不依赖 Agent 框架的可用 Agent。
修改代码
通过补丁完成可审查、可追踪的文件修改。
预计 35 分钟本页目录
Agent 已经能够搜索和读取仓库文件。
src/tools/apply-patch.tssrc/types.tssrc/execute-tool.tssrc/prompt.ts
Step 1实现带 hash 前置条件的补丁工具
先扩展 src/tool.ts。写入租约是 Executor 注入的运行时依赖,不能在工具中凭空读取一个尚未定义的字段:
export type ToolEffect = "read" | "write" | "execute";export interface WriteLease { runExclusive<Result>(key: string, operation: () => Promise<Result>): Promise<Result>;}export class InMemoryWriteLease implements WriteLease { private queue: Promise<void> = Promise.resolve(); runExclusive<Result>(_key: string, operation: () => Promise<Result>): Promise<Result> { const result = this.queue.then(operation, operation); this.queue = result.then( () => undefined, () => undefined, ); return result; }}export interface ToolContext { cwd: string; signal: AbortSignal; writeLease: WriteLease;}同时给 RegisteredTool 和 defineTool 的定义参数加入 effect: ToolEffect,并在返回对象中保留它;现有 read_file、search_text 都补上 effect: "read"。然后让 execute-tool.ts 创建一个进程内共享的 InMemoryWriteLease,在调用 tool.run 时连同 cwd、signal 一起传入。此处先串行化所有写入,语义正确后再优化成按规范化路径分组。
接着创建 src/tools/apply-patch.ts。不要把截断后的读取结果当作完整旧文件,也不要整文件替换;输入显式区分 create、update 和 delete,更新与删除必须携带完整文件的 SHA-256。
const inputSchema = z.discriminatedUnion("operation", [ z.object({ operation: z.literal("create"), path: z.string().min(1), content: z.string() }), z.object({ operation: z.literal("update"), path: z.string().min(1), expectedSha256: z.string().regex(/^[a-f0-9]{64}$/), edits: z.array(z.object({ search: z.string().min(1), replace: z.string() })).min(1), }), z.object({ operation: z.literal("delete"), path: z.string().min(1), expectedSha256: z.string().regex(/^[a-f0-9]{64}$/), }),]);export interface PatchResult { operation: "create" | "update" | "delete"; path: string; sha256?: string;}export const applyPatchTool = defineTool({ name: "apply_patch", description: "Create, patch, or delete one workspace file under a hash precondition", effect: "write", schema: inputSchema, async execute(input, context) { return context.writeLease.runExclusive(input.path, async () => { const path = await resolvePatchTarget(context.cwd, input.path, input.operation); if (input.operation === "create") { await writeFile(path, input.content, { encoding: "utf8", flag: "wx", mode: 0o600 }); return { operation: "create", path: input.path, sha256: sha256(input.content) }; } const current = await readFile(path, "utf8"); // never use truncated read_file output if (sha256(current) !== input.expectedSha256) throw new Error("file_changed"); if (input.operation === "delete") { await unlink(path); return { operation: "delete", path: input.path }; } const next = applyUniqueTextEdits(current, input.edits); await atomicReplace(path, next, input.expectedSha256); return { operation: "update", path: input.path, sha256: sha256(next) }; }); },});resolvePatchTarget 对已有文件复用上一章的 realpath 检查;创建文件时真实解析父目录并拒绝符号链接逃逸:
async function resolvePatchTarget( cwd: string, inputPath: string, operation: "create" | "update" | "delete",): Promise<string> { if (operation !== "create") return resolveExistingWorkspacePath(cwd, inputPath); const root = await realpath(resolve(cwd)); const candidate = resolve(root, inputPath); const realParent = await realpath(dirname(candidate)); assertInside(root, realParent); return join(realParent, basename(candidate));}async function atomicReplace(path: string, content: string, expectedSha256: string): Promise<void> { const temporary = join(dirname(path), `.agent-patch-${crypto.randomUUID()}.tmp`); const handle = await open(temporary, "wx", 0o600); try { await handle.writeFile(content, "utf8"); await handle.sync(); } finally { await handle.close(); } if (sha256(await readFile(path, "utf8")) !== expectedSha256) { await unlink(temporary); throw new Error("file_changed"); } await rename(temporary, path); await fsyncDirectory(dirname(path));}applyUniqueTextEdits 要求每个 search 在当前文本中恰好出现一次。atomicReplace 写入同目录临时文件、fsync 后再次核对目标 hash,再原子 rename。
单写者租约保证所有 Harness 写入遵守同一临界区。普通 POSIX 文件 API 无法对不合作的外部编辑器提供真正的原子 compare-and-swap;若允许人与 Agent 同时编辑,仍需仓库级 lease、平台特定 CAS 或在 rename 前后检测 inode/hash 并把冲突交给人工处理,不能宣称绝对无竞态。
Step 2记录所有变更文件
前一步已经给工具增加 effect。当补丁成功时,由 Harness 更新最小 RunLedger 的变更序号、文件 hash 和文件集合,而不是让工具自行修改全局状态。
if (tool.effect === "write" && observation.ok) { const path = getChangedPath(observation.output); if (!state.changedFiles.includes(path)) state.changedFiles.push(path); state.mutationRevision += 1; state.changedFileHashes[path] = observation.output.sha256 ?? "deleted";}Step 3要求模型先读再写
创建 src/prompt.ts,加入确定的执行约束:修改前读取完整内容与 hash;改动保持最小;不编辑生成目录;补丁冲突后重新调查,不能反复提交旧 hash。
export const instructions = [ "修改文件前读取完整内容并记录 SHA-256,不要用截断内容生成补丁。", "只修改完成任务所需的最小范围。", "不要编辑 node_modules、dist 或构建产物。", "file_changed 后重新读取,不要重复同一 hash 的调用。",].join("\n");