第 12 课:写入工具 — FileEdit, FileWrite

1 阅读10分钟

模块四:工具系统 | 前置依赖:第 11 课 | 预计学习时间:75 分钟


学习目标

完成本课后,你将能够:

  1. 解释 FileEditTool 的精确字符串替换机制,包括唯一性检查和 replace_all 模式
  2. 描述花引号规范化和 API 标签反清洁化如何确保编辑准确命中
  3. 说明 FileWriteTool 的"先读后写"强制策略及其安全考量
  4. 理解文件修改时间戳追踪在并发编辑安全中的作用

12.1 写入工具 vs 只读工具

写入工具与只读工具的核心区别不仅是"是否修改文件",还体现在整个安全和状态管理层面:

┌──────────────────────────────────────────────────────┐
│         只读工具 vs 写入工具                          │
├──────────────────────────────────────────────────────┤
│                                                      │
│  只读工具                    写入工具                 │
│  ──────────                  ──────────              │
│  isReadOnly() = true         isReadOnly() 未定义     │
│  checkReadPermission         checkWritePermission    │
│  无文件状态追踪               readFileState 追踪     │
│  无 LSP 通知                 LSP didChange/didSave   │
│  无 undo 支持                fileHistory 备份        │
│  无 diff 生成                structuredPatch 输出    │
│  自动允许(多数模式)         需要用户确认            │
│                                                      │
└──────────────────────────────────────────────────────┘

12.2 FileEditTool — 精确字符串替换

文件位置

tools/FileEditTool/
├── FileEditTool.ts    # 核心逻辑(~550 行)
├── constants.ts       # 工具名称和常量
├── types.ts           # Zod schema 定义
├── utils.ts           # 编辑辅助函数(~776 行)
├── prompt.ts          # 提示词生成
└── UI.tsx             # Diff 渲染组件

输入参数

z.strictObject({
  file_path: z.string(),     // 要修改的文件绝对路径
  old_string: z.string(),    // 要替换的文本
  new_string: z.string(),    // 替换后的文本
  replace_all: z.boolean().default(false).optional(), // 是否替换所有匹配
})

这个设计看似简单,但其背后有大量的验证和安全逻辑。

完整的 validateInput 校验链

FileEditTool 的输入验证是所有工具中最严格的。下面是完整的校验链:

validateInput() 校验链
  │
  ├── 1. 秘密检查:checkTeamMemSecrets(fullFilePath, new_string)
  │       └── 防止向团队记忆文件写入敏感信息
  │
  ├── 2. 空操作检查:old_string === new_string ?
  │       └── "No changes to make" (errorCode: 1)
  │
  ├── 3. 路径权限检查:matchingRuleForInput(path, context, 'edit', 'deny')
  │       └── "File is in a denied directory" (errorCode: 2)
  │
  ├── 4. UNC 路径安全:startsWith('\\\\') || startsWith('//')
  │       └── 跳过文件系统操作防止 NTLM 泄漏
  │
  ├── 5. 文件大小检查:size > 1 GiB ?
  │       └── "File is too large to edit" (errorCode: 10)
  │
  ├── 6. 文件编码检测:UTF-8 或 UTF-16LE
  │       └── 支持 BOM 检测
  │
  ├── 7. 文件不存在 + old_string 非空:
  │       └── 建议相似文件名 (errorCode: 4)
  │
  ├── 8. 文件存在 + old_string 为空 + 文件有内容:
  │       └── "Cannot create new file - file already exists" (errorCode: 3)
  │
  ├── 9. Notebook 检查:.ipynb 后缀
  │       └── "Use NotebookEditTool instead" (errorCode: 5)
  │
  ├── 10. 先读后编辑检查:readFileState.get(fullFilePath)
  │       └── "File has not been read yet" (errorCode: 6)
  │
  ├── 11. 时间戳过期检查:lastWriteTime > readTimestamp
  │       └── "File has been modified since read" (errorCode: 7)
  │
  ├── 12. 字符串查找(含引号规范化):findActualString(file, old_string)
  │       └── "String to replace not found" (errorCode: 8)
  │
  ├── 13. 唯一性检查:matches > 1 && !replace_all
  │       └── "Found N matches, set replace_all to true" (errorCode: 9)
  │
  └── 14. Claude 设置文件校验:validateInputForSettingsFileEdit()
          └── 防止无效的设置文件修改

花引号规范化

一个极其精巧的功能。Claude 模型输出的引号总是直引号("'),但许多文件(特别是文档和文学作品)使用花引号(\u201C \u201D \u2018 \u2019)。

utils.ts 中的 findActualString() 处理这个不匹配:

export function findActualString(
  fileContent: string,
  searchString: string,
): string | null {
  // 第一步:精确匹配
  if (fileContent.includes(searchString)) {
    return searchString
  }

  // 第二步:规范化引号后匹配
  const normalizedSearch = normalizeQuotes(searchString)
  const normalizedFile = normalizeQuotes(fileContent)

  const searchIndex = normalizedFile.indexOf(normalizedSearch)
  if (searchIndex !== -1) {
    // 返回文件中的原始字符串(带花引号),而非规范化后的
    return fileContent.substring(searchIndex, searchIndex + searchString.length)
  }

  return null
}

当通过花引号规范化找到匹配后,preserveQuoteStyle() 会将 new_string 中的直引号也转换为花引号,保持文件的排版风格一致:

export function preserveQuoteStyle(
  oldString: string,
  actualOldString: string,
  newString: string,
): string {
  if (oldString === actualOldString) return newString  // 没有发生规范化

  // 检测文件中使用了哪种花引号
  const hasDoubleQuotes = actualOldString.includes('\u201C') || actualOldString.includes('\u201D')
  const hasSingleQuotes = actualOldString.includes('\u2018') || actualOldString.includes('\u2019')

  let result = newString
  if (hasDoubleQuotes) result = applyCurlyDoubleQuotes(result)
  if (hasSingleQuotes) result = applyCurlySingleQuotes(result)
  return result
}

花引号的应用使用了一个启发式规则区分开引号和闭引号:前面是空白、句首或开括号时是开引号,否则是闭引号。缩写词中的撇号(如 don't)特殊处理:

// 两边都是字母 → 缩写撇号 → 使用右单花引号
const prevIsLetter = prev !== undefined && /\p{L}/u.test(prev)
const nextIsLetter = next !== undefined && /\p{L}/u.test(next)
if (prevIsLetter && nextIsLetter) {
  result.push(RIGHT_SINGLE_CURLY_QUOTE)  // 缩写词
}

API 标签反清洁化

Claude API 会对某些 XML 标签进行清洁化处理(sanitization),将它们替换为简短别名。当模型在编辑中输出这些清洁化后的标签时,FileEditTool 需要还原:

const DESANITIZATIONS: Record<string, string> = {
  '<fnr>': '<function_results>',
  '<n>': '<name>',
  '</n>': '</name>',
  '<o>': '<output>',
  '</o>': '</output>',
  '<e>': '<error>',
  '</e>': '</error>',
  '<s>': '<system>',
  '</s>': '</system>',
  '\n\nH:': '\n\nHuman:',
  '\n\nA:': '\n\nAssistant:',
  // ...更多映射
}

call() 核心流程:原子性读改写

call() 执行流程
  │
  ├── 1. expandPath() — 路径规范化
  │
  ├── 2. discoverSkillDirsForPaths() — 发现技能目录
  │
  ├── 3. diagnosticTracker.beforeFileEdited() — LSP 诊断准备
  │
  ├── 4. mkdir(dirname) — 确保父目录存在
  │
  ├── 5. fileHistoryTrackEdit() — 备份原文件(undo 支持)
  │
  │  ┌─── 原子性临界区开始 ───┐
  │  │                        │
  ├──├── 6. readFileSyncWithMetadata() — 同步读取当前内容
  │  │
  ├──├── 7. 时间戳过期检查 — 二次确认
  │  │
  ├──├── 8. findActualString() — 花引号规范化查找
  │  │
  ├──├── 9. preserveQuoteStyle() — 保持引号风格
  │  │
  ├──├── 10. getPatchForEdit() — 生成 diff patch
  │  │
  ├──├── 11. writeTextContent() — 写入磁盘
  │  │                        │
  │  └─── 原子性临界区结束 ───┘
  │
  ├── 12. LSP 通知 — didChange + didSave
  │
  ├── 13. notifyVscodeFileUpdated() — VS Code 集成
  │
  ├── 14. readFileState.set() — 更新读取状态
  │
  └── 15. 日志和分析事件

源码注释特别强调了临界区的原子性:

Please avoid async operations between here and writing to disk to preserve atomicity

步骤 6-11 之间没有 await,防止并发编辑的交错。

新文件创建

old_string 为空且文件不存在时,FileEditTool 的行为等同于创建新文件:

// validateInput 中
if (old_string === '') {
  if (fileContent === null) {
    return { result: true }  // 文件不存在 + 空 old_string = 创建新文件
  }
  if (fileContent.trim() !== '') {
    return { result: false, message: 'Cannot create new file - file already exists.' }
  }
}

12.3 FileWriteTool — 完整文件覆写

文件位置

tools/FileWriteTool/
├── FileWriteTool.ts    # 核心逻辑(~300 行)
├── prompt.ts           # 提示词
└── UI.tsx              # 渲染组件

输入参数

z.strictObject({
  file_path: z.string(),  // 绝对路径
  content: z.string(),     // 完整文件内容
})

"先读后写" 强制策略

FileWriteTool 的提示词明确要求:

export function getWriteToolDescription(): string {
  return `Writes a file to the local filesystem.

Usage:
- This tool will overwrite the existing file if there is one at the provided path.
- If this is an existing file, you MUST use the Read tool first to read the file's contents.
  This tool will fail if you did not read the file first.
- Prefer the Edit tool for modifying existing files — it only sends the diff.
  Only use this tool to create new files or for complete rewrites.
- NEVER create documentation files (*.md) or README files unless explicitly requested.`
}

这个策略在 validateInput 中强制执行:

const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
if (!readTimestamp || readTimestamp.isPartialView) {
  return {
    result: false,
    message: 'File has not been read yet. Read it first before writing to it.',
    errorCode: 2,
  }
}

FileWriteTool vs FileEditTool 的选择

┌──────────────────────────────────────────────────────┐
│         何时用 FileEdit vs FileWrite                  │
├──────────────────────────────────────────────────────┤
│                                                      │
│  FileEditTool(首选)                                │
│  ├── 修改现有文件的一部分                             │
│  ├── 只发送 diff,token 消耗低                       │
│  ├── 用户在权限弹窗中看到精确的变更                   │
│  └── old_string 为空 + 文件不存在 = 创建新文件        │
│                                                      │
│  FileWriteTool                                       │
│  ├── 创建全新文件(首选方式)                         │
│  ├── 完全重写文件内容                                │
│  ├── 发送完整内容,token 消耗高                      │
│  └── 用户在权限弹窗中看到完整文件 diff               │
│                                                      │
└──────────────────────────────────────────────────────┘

时间戳过期检测

FileWriteTool 和 FileEditTool 都使用同样的时间戳机制防止覆盖用户的外部修改:

// 文件存在的情况
const fileStat = await fs.stat(fullFilePath)
const fileMtimeMs = fileStat.mtimeMs

// 检查 read 时间戳
const readTimestamp = toolUseContext.readFileState.get(fullFilePath)

// 文件在 Read 之后被修改过
if (fileMtimeMs > readTimestamp.timestamp) {
  // Windows 特殊处理:云同步、杀毒软件可能改变时间戳但不改内容
  const isFullRead = readTimestamp.offset === undefined && readTimestamp.limit === undefined
  if (isFullRead && currentContent === readTimestamp.content) {
    // 内容未变,安全继续
  } else {
    return { result: false, message: FILE_UNEXPECTEDLY_MODIFIED_ERROR }
  }
}

这个双重检查(时间戳 + 内容比较)的设计值得注意:

时间戳变化?
  │
  ├── 否 → 安全,继续编辑
  │
  └── 是 → 内容也变化了?
        │
        ├── 否(Windows 误报)→ 安全,继续编辑
        │
        └── 是 → 阻止!"File has been unexpectedly modified"

12.4 NotebookEditTool 简介

NotebookEditTool 专门处理 Jupyter Notebook (.ipynb) 文件的编辑:

z.strictObject({
  notebook_path: z.string(),
  cell_id: z.string().optional(),      // 要编辑的 cell ID
  new_source: z.string(),              // 新的 cell 内容
  cell_type: z.enum(['code', 'markdown']).optional(),
  edit_mode: z.enum(['replace', 'insert', 'delete']).optional(),
})

三种编辑模式:

  • replace:替换指定 cell 的内容
  • insert:在指定 cell 之后插入新 cell
  • delete:删除指定 cell

FileEditTool 的 validateInput 会检测 .ipynb 文件并引导使用 NotebookEditTool:

if (fullFilePath.endsWith('.ipynb')) {
  return {
    result: false,
    message: `File is a Jupyter Notebook. Use the ${NOTEBOOK_EDIT_TOOL_NAME} to edit this file.`,
    errorCode: 5,
  }
}

12.5 readFileState — 读写状态追踪

readFileState 是一个 Map<string, ReadFileStateEntry>,由 ToolUseContext 管理,贯穿所有文件操作工具:

┌──────────────────────────────────────────────────────┐
│              readFileState 生命周期                    │
├──────────────────────────────────────────────────────┤
│                                                      │
│  FileReadTool.call()                                 │
│  └── readFileState.set(path, {                       │
│        content: "文件内容",                           │
│        timestamp: mtimeMs,                           │
│        offset: 1,      // Read 设置行号              │limit: undefined                              │
│      })                                              │
│                                                      │
│  FileEditTool.call()                                 │
│  └── readFileState.set(path, {                       │
│        content: "编辑后内容",                         │
│        timestamp: newMtimeMs,                        │
│        offset: undefined,  // Edit 设置 undefined    │limit: undefined                              │
│      })                                              │
│                                                      │
│  FileWriteTool.call()                                │
│  └── readFileState.set(path, {                       │
│        content: "写入后内容",                         │
│        timestamp: newMtimeMs,                        │
│        offset: undefined,                            │
│        limit: undefined                              │
│      })                                              │
│                                                      │
│  FileReadTool 去重检查                                │
│  └── existingState.offset !== undefined ?            │
│        // 只有 Read 设置了 offset,                  │// Edit/Write 的 offset=undefined 不会去重    │// 防止指向过时的 pre-edit 内容               │
│                                                      │
└──────────────────────────────────────────────────────┘

这个设计的精妙之处在于:Read 和 Edit/Write 对 offset 字段的不同设置,使得去重机制只对 Read-Read 重复生效,不会错误地去重 Edit 后的 Read。


12.6 Diff 生成与展示

FileEditTool 使用 diff 库的 structuredPatch 生成 diff:

export function getPatchForEdit({
  filePath, fileContents, oldString, newString, replaceAll = false,
}): { patch: StructuredPatchHunk[]; updatedFile: string } {

  // 应用编辑
  let updatedFile = applyEditToFile(fileContents, oldString, newString, replaceAll)

  // 转换 tab 为空格(仅用于展示)
  const patch = getPatchFromContents({
    filePath,
    oldContent: convertLeadingTabsToSpaces(fileContents),
    newContent: convertLeadingTabsToSpaces(updatedFile),
  })

  return { patch, updatedFile }
}

Diff snippet 的生成有大小限制:

const DIFF_SNIPPET_MAX_BYTES = 8192  // 8KB

源码注释解释了原因:

Format-on-save of a large file previously injected the entire file per turn (observed max 16.1KB, ~14K tokens/session). 8KB preserves meaningful context while bounding worst case.


12.7 编辑等价性检查

FileEditTool 实现了 inputsEquivalent() 方法,用于 speculation(乐观更新)中判断两次编辑是否产生相同结果:

export function areFileEditsEquivalent(
  edits1: FileEdit[],
  edits2: FileEdit[],
  originalContent: string,
): boolean {
  // 快速路径:字面量相同
  if (edits1.length === edits2.length &&
      edits1.every((e1, i) => {
        const e2 = edits2[i]
        return e2 && e1.old_string === e2.old_string &&
               e1.new_string === e2.new_string &&
               e1.replace_all === e2.replace_all
      })) {
    return true
  }

  // 语义比较:应用两组编辑,比较最终结果
  const result1 = getPatchForEdits({ filePath: 'temp', fileContents: originalContent, edits: edits1 })
  const result2 = getPatchForEdits({ filePath: 'temp', fileContents: originalContent, edits: edits2 })

  return result1.updatedFile === result2.updatedFile
}

这支持了一个重要的优化场景:当 Claude 在流式输出中先产生一个编辑预测,后来确认相同的编辑时,系统不需要重新执行。


课后练习

练习 1:花引号边缘案例

考虑以下文件内容(使用花引号):

She said \u201CHello\u201D and he replied \u201CHi\u201D.

Claude 输出的 old_string 是 She said "Hello"(直引号)。

  1. findActualString() 如何找到匹配?
  2. 如果 new_string 是 She whispered "Goodbye"preserveQuoteStyle() 的输出是什么?

练习 2:并发编辑安全分析

两个 Agent(主 Agent 和子 Agent)同时编辑同一个文件。Agent A 在 t=1 读取文件,Agent B 在 t=2 读取文件,Agent A 在 t=3 编辑文件。

  1. Agent B 在 t=4 尝试编辑时会发生什么?
  2. 如果 Agent B 是部分读取(isPartialView=true),行为有何不同?

练习 3:验证 13 步校验链

阅读 FileEditTool 的 validateInput 源码,找到以下 errorCode 对应的错误条件:

  • errorCode 1, 3, 6, 8, 9 然后思考:为什么某些错误使用 behavior: 'ask' 而不是直接拒绝?

练习 4:设计一个原子性测试

设计一个测试场景来验证 FileEditTool 的临界区原子性。提示:在步骤 6(读取文件)和步骤 11(写入文件)之间,如果另一个进程修改了文件,会发生什么?


本课小结

要点内容
FileEditTool精确字符串替换,13 步验证链,花引号规范化,API 标签反清洁化
FileWriteTool完整文件覆写,强制"先读后写",preferEdit 策略
NotebookEditToolJupyter Notebook 专用,三种编辑模式(replace/insert/delete)
readFileState跨工具共享的文件状态追踪,Read 和 Edit/Write 通过 offset 字段区分
原子性保证临界区内无 await,时间戳 + 内容双重检查,Windows 误报容错
Diff 生成structuredPatch + 8KB snippet 限制,tab 转空格仅用于展示

下一课预告

第 13 课:BashTool — Shell 执行与安全防线 — BashTool 是整个工具系统中最大、最复杂的工具(主文件 1143 行,安全模块 2592 行,权限模块 2621 行)。我们将深入分析命令安全检查、危险命令检测、沙箱机制、超时管理和后台任务执行。