LinOnward / Agent TutorialAgent Tutorial
章节7 / 18
教程整体目标

通过逐章可运行的增量,理解 Agent 的模型、上下文、任务状态、规划、工具、Skills 与 Agent Loop 等关键组件,并从零构建一个由 Harness 约束、不依赖 Agent 框架的可用 Agent。

06

修改代码

通过补丁完成可审查、可追踪的文件修改。

本页目录
本章任务让 Agent 把 README 第一行改成 # Hello Agent,并记录变更文件。
章节目标Agent 能用局部补丁创建、更新或删除工作区文件,并用 hash 与单写者租约避免静默覆盖旧版本。
开始之前

Agent 已经能够搜索和读取仓库文件。

本章涉及文件
  • src/tools/apply-patch.ts
  • src/types.ts
  • src/execute-tool.ts
  • src/prompt.ts

Step 1实现带 hash 前置条件的补丁工具

先扩展 src/tool.ts。写入租约是 Executor 注入的运行时依赖,不能在工具中凭空读取一个尚未定义的字段:

typescript
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;}

同时给 RegisteredTooldefineTool 的定义参数加入 effect: ToolEffect,并在返回对象中保留它;现有 read_filesearch_text 都补上 effect: "read"。然后让 execute-tool.ts 创建一个进程内共享的 InMemoryWriteLease,在调用 tool.run 时连同 cwdsignal 一起传入。此处先串行化所有写入,语义正确后再优化成按规范化路径分组。

接着创建 src/tools/apply-patch.ts。不要把截断后的读取结果当作完整旧文件,也不要整文件替换;输入显式区分 createupdatedelete,更新与删除必须携带完整文件的 SHA-256。

typescript
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 检查;创建文件时真实解析父目录并拒绝符号链接逃逸:

typescript
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 和文件集合,而不是让工具自行修改全局状态。

typescript
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。

typescript
export const instructions = [  "修改文件前读取完整内容并记录 SHA-256,不要用截断内容生成补丁。",  "只修改完成任务所需的最小范围。",  "不要编辑 node_modules、dist 或构建产物。",  "file_changed 后重新读取,不要重复同一 hash 的调用。",].join("\n");