Claude Code 源码:普通工具实现 Read / Write / Edit / TodoWrite

0 阅读21分钟

Claude Code 源码:普通工具实现 Read / Write / Edit / TodoWrite

导航

  • 📖 读文件?FileReadTool — 去重缓存与 cat -n 格式
  • ✍️ 写文件?FileWriteTool — 为什么必须先读再写
  • ✂️ 改文件?FileEditTool — 字符串匹配而非行号
  • 任务管理?TodoWriteTool — 让模型主动管理进度
  • 📦 结果太大?结果大小控制 — 持久化而非截断

目录


极限场景:一次编辑背后的完整链路

想象这样一个时刻:

用户说"把 getUserById 重命名为 fetchUserById,整个项目里所有地方都要改"。

模型的执行路径:

① GrepTool          → 找出所有 getUserById 的位置(并发安全)
        ↓
② FileReadTool × N  → 读取每个涉及的文件(并发安全,触发缓存记录)
        ↓
③ FileEditTool × N  → 对每个文件执行字符串替换(非并发,独占执行)
        ↓
④ TodoWriteTool     → 更新任务进度(标记完成)

这条路径里,每一步都在回答同样的问题:

  1. 这个工具的提示词说了什么? — 模型怎么知道该用哪个工具、传什么参数
  2. 执行前做了哪些验证? — 为什么 FileEditTool 必须先读文件
  3. 结果怎么展示给用户? — 终端里的 diff 视图从哪来
  4. 结果太大怎么办? — 50k 字符的阈值背后是什么逻辑

后面每一节,都是这个场景的一个答案。


工具的三个实现维度

每个工具都有三个实现维度,缺一不可:

┌─────────────────────────────────────────────────────┐
│                    工具实现                          │
│                                                     │
│  ① 提示词(Prompt)                                  │
│     inputSchema + description                       │
│     → 告诉 LLM 这个工具能做什么、参数是什么           │
│     → 直接影响模型是否会调用这个工具、怎么填参数       │
│                                                     │
│  ② UI 渲染                                          │
│     renderToolUseMessage / renderToolResultMessage  │
│     → 工具调用在终端/IDE 里如何展示                   │
│     → 用户看到的 diff 视图、进度提示都在这里           │
│                                                     │
│  ③ 执行逻辑(call)                                  │
│     validateInput + call()                          │
│     → 实际干活的代码                                 │
│     → 包含前置验证、核心操作、结果构建                 │
└─────────────────────────────────────────────────────┘

三个维度的关系:提示词决定模型行为,执行逻辑决定实际效果,UI 渲染决定用户感知。三者独立演化,互不耦合——改提示词不影响执行逻辑,改 UI 不影响模型看到的结果。

双向通信:工具如何影响模型决策

很多人以为工具系统是单向的:模型调用工具 → 工具返回数据。但实际上,工具可以通过 mapToolResultToToolResultBlockParam 主动塑造模型看到的反馈,从而影响模型的下一步决策。

// Tool 接口定义(简化)
interface Tool {
  inputSchema: ZodSchema           // ① 模型 → 工具:参数约束
  call(input, context): Output     // ② 工具执行:返回结构化数据
  mapToolResultToToolResultBlockParam(output, toolUseID): ToolResultBlock
                                   // ③ 工具 → 模型:文本反馈
}

关键点call() 返回的是结构化数据(给 UI 渲染用),但模型看到的是 mapToolResultToToolResultBlockParam 返回的文本。工具可以在这里注入提示、警告、引导,直接影响模型的下一轮推理。

案例 1:FileEditTool 的用户修改提醒

// src/tools/FileEditTool/FileEditTool.ts
mapToolResultToToolResultBlockParam(data, toolUseID) {
  const { filePath, userModified, replaceAll } = data
  const modifiedNote = userModified
    ? '.  The user modified your proposed changes before accepting them. '
    : ''
  
  return {
    tool_use_id: toolUseID,
    type: 'tool_result',
    content: `The file ${filePath} has been updated${modifiedNote}. ${replaceAll ? 'All occurrences were successfully replaced.' : ''}`,
  }
}

当用户在 diff 视图里手动修改了模型的编辑建议,userModified 会被设为 true,模型会在 tool_result 里看到 "The user modified your proposed changes before accepting them"——这会触发模型重新读取文件,确认最终状态。

案例 2:TodoWriteTool 的验证提醒

// src/tools/TodoWriteTool/TodoWriteTool.ts
mapToolResultToToolResultBlockParam({ verificationNudgeNeeded }, toolUseID) {
  const base = `Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress.`
  const nudge = verificationNudgeNeeded
    ? `\n\nNOTE: You just closed out 3+ tasks and none of them was a verification step. Before writing your final summary, spawn the verification agent (subagent_type="verification"). You cannot self-assign PARTIAL by listing caveats in your summary — only the verifier issues a verdict.`
    : ''
  return { tool_use_id: toolUseID, type: 'tool_result', content: base + nudge }
}

当模型一次性完成 3+ 任务但没有验证步骤时,工具会在 tool_result 里注入一段强制提醒,要求模型调用 verification agent——这是一个结构化提示(structural nudge),在关键时刻动态注入上下文相关的引导。

设计哲学:不是在 system prompt 里写死规则(容易被遗忘),而是在工具执行的关键节点动态注入提示,让模型在最需要的时候看到最相关的引导。这是 Claude Code 工具系统的神经反射机制——工具不只是被动执行,还会主动塑造模型的推理路径。

为什么不写在 System Prompt? 因为 System Prompt 是"静态的惩罚",写多了模型会变笨(过度约束导致创造力下降);而 Tool Result 是"动态的诱导",只有在模型真的做错(或漏掉步骤)时才出现。这叫 Just-in-time Prompting(即时提示),是目前最前沿的 Agent 调优技巧——在模型最需要纠正的时刻,精准注入最小化的提示,而不是在初始 prompt 里堆砌大量规则。


FileReadTool:读文件

提示词

FileReadTool 的 description 是发给模型的"使用说明",直接决定模型什么时候会调用它、怎么填参数:

// src/tools/FileReadTool/prompt.ts
export const MAX_LINES_TO_READ = 2000

export function renderPromptTemplate(...): string {
  return `Reads a file from the local filesystem. You can access any file directly by using this tool.
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.

Usage:
- The file_path parameter must be an absolute path, not a relative path
- By default, it reads up to ${MAX_LINES_TO_READ} lines starting from the beginning of the file
- You can optionally specify a line offset and limit (especially handy for long files)
- Results are returned using cat -n format, with line numbers starting at 1
- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.
- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs.
- This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool.`
}

几个值得注意的设计:

"假设路径有效"If the User provides a path to a file assume that path is valid. 这句话防止模型在用户给出路径时反复确认,直接尝试读取,读不到再报错。

"可以读不存在的文件"It is okay to read a file that does not exist; an error will be returned. 这让模型不会因为"文件可能不存在"而犹豫,直接调用,错误信息会告诉它下一步怎么做。

cat -n 格式:返回带行号的内容,是为了让 FileEditTool 能精确定位——模型看到 42→ const x = 1 这样的格式,就知道第 42 行是什么内容。

inputSchema 的字段:

z.strictObject({
  file_path: z.string(),          // 💡 必须是绝对路径
  offset: z.number().optional(),  // 从第几行开始读
  limit: z.number().optional(),   // 读多少行
  pages: z.string().optional(),   // PDF 专用,如 "1-5"
})

执行逻辑:去重缓存

FileReadTool 最有趣的设计是去重缓存。如果同一个文件在同一次对话里被读了两次,且文件内容没有变化,第二次不会重新读文件,而是返回一个占位符:

// src/tools/FileReadTool/prompt.ts
export const FILE_UNCHANGED_STUB =
  'File unchanged since last read. The content from the earlier Read tool_result in this conversation is still current — refer to that instead of re-reading.'

判断逻辑:

// src/tools/FileReadTool/FileReadTool.ts(简化)
async call({ file_path, offset, limit }, context) {
  const fullFilePath = expandPath(file_path)

  // 检查是否已读过这个文件(完整读取,非分页)
  const readTimestamp = context.readFileState.get(fullFilePath)
  if (readTimestamp && !readTimestamp.isPartialView) {
    const stat = await fs.stat(fullFilePath)
    if (stat.mtimeMs === readTimestamp.timestamp) {
      // 💡 文件未修改,返回占位符,不重新读
      return { data: { type: 'file_unchanged', file: { filePath: file_path } } }
    }
  }

  // 正常读取文件...
  const content = readFileWithLineNumbers(fullFilePath, offset, limit)

  // 记录读取时间戳(供 FileEditTool / FileWriteTool 验证用)
  context.readFileState.set(fullFilePath, {
    timestamp: stat.mtimeMs,
    isPartialView: offset !== undefined || limit !== undefined,
  })

  return { data: { type: 'text', file: { content, ... } } }
}

这个设计的价值:长对话里模型可能多次读同一个文件(比如每次修改前都读一遍确认),去重缓存避免了重复的 token 消耗。FILE_UNCHANGED_STUB 告诉模型"你之前读过,内容没变,直接用那次的结果"。

量化收益:在一次涉及 12 个文件的重构任务中,通过 FILE_UNCHANGED_STUB 去重,避免了约 6.5k 输入 Token 的重复消耗(假设平均文件 500 tokens)。在长程任务中,这种缓存机制直接决定了 Agent 是否会陷入"重复读盘幻觉"。

readFileState 还有另一个用途:FileEditTool 和 FileWriteTool 在执行前会检查这个 Map(注:readFileState 是一个存储文件读取时间戳的 Map 结构,key 是文件路径,value 是 { timestamp, isPartialView }),确认文件已经被读过——这是"必须先读再写"约束的实现基础。

提示词何时加载?

FileReadTool 没有设置 shouldDefer: true,也没有 alwaysLoad——它属于默认加载工具,schema 在每次会话的初始 prompt 里就发给模型,不需要 ToolSearch 触发。

对比一下三类工具的加载时机:

工具shouldDeferalwaysLoad加载时机
FileReadTool初始 prompt(默认)
FileEditTool初始 prompt(默认)
FileWriteTool初始 prompt(默认)
TodoWriteTooltrue需要 ToolSearch 触发
NotebookEditTooltrue需要 ToolSearch 触发

文件读写工具是最高频的操作,放在初始 prompt 里让模型第一轮就能用,不需要额外的 ToolSearch 往返。TodoWriteTool 设置了 shouldDefer: true,因为不是每个任务都需要任务管理,延迟加载节省初始 prompt 的 token。

工具属性

isConcurrencySafe() { return true },   // 💡 只读,可并行
isReadOnly() { return true },
maxResultSizeChars: Infinity,          // 💡 豁免持久化(避免循环:Read → 持久化文件 → Read)

为什么 maxResultSizeChars 是 Infinity?

FileReadTool 是唯一豁免结果大小限制的工具。如果它也受 50k 阈值约束,会产生循环依赖:读取大文件 → 结果超过 50k → 持久化到磁盘 → 模型用 FileReadTool 读持久化文件 → 又超过 50k → 再持久化 → 无限循环。

所有工具的结果大小声明对比:

工具maxResultSizeChars 声明实际生效阈值说明
FileReadToolInfinity豁免避免持久化循环
FileEditTool100_00050_000被全局默认值覆盖
FileWriteTool100_00050_000被全局默认值覆盖
TodoWriteTool100_00050_000被全局默认值覆盖

除非工具显式声明 Infinity,否则实际生效阈值永远不超过全局默认值 DEFAULT_MAX_RESULT_SIZE_CHARS = 50_000,这是为了防止单次工具调用占据过多上下文。


FileWriteTool:写文件

提示词

// src/tools/FileWriteTool/prompt.ts
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 by the User.
- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.`
}

两个关键约束直接写在提示词里:

"必须先读再写"you MUST use the Read tool first — 这不只是建议,是强制要求,违反会报错(见下文 validateInput)。

"优先用 Edit"Prefer the Edit tool for modifying existing files — it only sends the diff. — 这是 token 效率的考量。Write 会把整个文件内容发给 API,Edit 只发变更的字符串。对大文件来说,差距可能是几千 token。

执行逻辑:三重验证

FileWriteToolvalidateInput 做了三重验证,任何一关不过都会报错:

🟢 读取检查 → 🟡 修改检查 → 🔴 写入执行
   └─ readFileState ─┘   └─ mtime 乐观锁 ─┘
// src/tools/FileWriteTool/FileWriteTool.ts(简化)
async validateInput({ file_path, content }, toolUseContext) {
  const fullFilePath = expandPath(file_path)

  // 验证 1:文件是否已读过
  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,  // ERR_FILE_NOT_READ
    }
  }

  // 验证 2:文件是否在读取后被外部修改(如 linter 自动格式化)
  const lastWriteTime = Math.floor(fileStat.mtimeMs)
  if (lastWriteTime > readTimestamp.timestamp) {
    return {
      result: false,
      message: 'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.',
      errorCode: 3,  // ERR_FILE_MODIFIED_SINCE_READ
    }
  }

  return { result: true }
}

错误码速查

  • errorCode: 2 (ERR_FILE_NOT_READ) — 文件未被 Read 过
  • errorCode: 3 (ERR_FILE_MODIFIED_SINCE_READ) — 文件在读取后被外部修改(如 linter 格式化)

验证 2 解决了一个实际问题:模型读完文件后,编辑器的 linter 可能自动格式化了文件(比如 Prettier 在保存时触发)。如果不检查 mtime,模型写入的内容会覆盖 linter 的格式化结果,产生冲突。

乐观锁机制:这本质上是分布式系统中的乐观锁(Optimistic Locking)——模型读取时记录文件的 mtime 快照,写入时检查快照是否仍然有效。如果文件在读写之间被修改(mtime 变化),写入操作会被拒绝,要求模型重新读取最新版本。这避免了"丢失更新"(Lost Update)问题。

新建文件的特殊路径:如果文件不存在(stat 抛 ENOENT),validateInput 直接返回 { result: true },跳过所有验证——新建文件不需要"先读"。


FileEditTool:精确编辑

提示词

// src/tools/FileEditTool/prompt.ts
export function getEditToolDescription(): string {
  return `Performs exact string replacements in files.

Usage:
- You must use your \`Read\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.
- Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.`
}

inputSchema:

z.strictObject({
  file_path: z.string(),
  old_string: z.string(),    // 💡 要替换的原始字符串
  new_string: z.string(),    // 替换后的字符串
  replace_all: z.boolean().default(false).optional(),  // 是否替换所有匹配
})

为什么用字符串匹配而不是行号?

这是 FileEditTool 最核心的设计决策。

行号方案的问题:模型读完文件后,如果在同一轮对话里对同一个文件做了多次编辑,每次编辑都会改变后续行的行号。第一次编辑在第 42 行插入了 3 行,第二次编辑想改"第 50 行",但实际上那行已经变成第 53 行了。

字符串匹配方案:old_string 是文件内容的一个片段,只要内容不变,位置怎么漂移都能找到。多次编辑不会互相干扰。

唯一性检查

// src/tools/FileEditTool/FileEditTool.ts(validateInput 简化)
const matches = file.split(actualOldString).length - 1

if (matches > 1 && !replace_all) {
  return {
    result: false,
    message: `Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${old_string}`,
    errorCode: 9,  // ERR_MULTIPLE_MATCHES
  }
}

如果 old_string 在文件里出现了多次,且 replace_all 为 false,工具会报错并告诉模型有几个匹配。模型收到这个错误后,通常会扩大 old_string 的范围(加上更多上下文)来唯一定位目标。

错误即提示词:Claude Code 的错误信息不是用来"挂起"程序的,而是用来"调优"模型的。每个 errorCode 都对应一个精心构造的 message,引导模型在下一轮自动修正参数——这是工具系统的自愈能力(Self-healing)。比如 errorCode: 9 的错误信息明确告诉模型两种解决方案:要么设置 replace_all: true,要么提供更多上下文。

引号规范化

一个隐藏的细节:模型输出的文本里可能包含直引号(" '),但文件里用的是弯引号(" " ' ')。直接字符串匹配会失败。

FileEditTool 在匹配前做了引号规范化:

// src/tools/FileEditTool/utils.ts
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 里的直引号转换成文件原有的弯引号风格,保持排版一致性。

鲁棒性哲学:这种容错机制体现了"鲁棒的原子工具是减少 Agent 幻觉的第一道防线"。当工具本身能像资深工程师一样处理"弯引号"这种琐碎差异时,LLM 就能把有限的注意力(Token)集中在复杂的业务逻辑重构上,而不是被"找不到字符串"的错误反复打断。很多 Agent 框架失败的原因,就是"编辑"工具太脆弱——因为一个空格不匹配就崩溃,导致模型陷入无限重试循环。

UI 渲染:diff 视图

FileEditTool 的 renderToolResultMessage 会生成 diff 视图,让用户直观看到变更:

  42   const getUserById = async (id: string) => {
- 43     return db.users.findOne({ id })
+ 43     return db.users.findById(id)
  44   }

这个 diff 是通过 structuredPatch(来自 diff 库)生成的,包含 hunk 信息(变更位置、行数、内容),UI 层负责把 hunk 渲染成带颜色的终端输出。

工具属性

isConcurrencySafe() { return false },  // 💡 修改文件系统,必须串行执行
isReadOnly() { return false },
maxResultSizeChars: 100_000,

为什么不并发安全?

对同一个文件的多个编辑必须串行执行,否则第二个编辑的 old_string 可能在第一个编辑完成后就已失效(内容已变)。因此工具系统会将 FileEditTool 调用放入串行队列(详见 002 文章的并发调度章节)。


TodoWriteTool:任务管理

为什么 Todo 是一个工具?

传统 Agent 框架通常在外部维护任务状态——框架决定什么时候创建任务、什么时候标记完成。Claude Code 的做法相反:把任务管理暴露成工具,让模型自己决定什么时候该更新进度。

这和 EnterPlanModeTool 的设计哲学一致(注:EnterPlanMode 是一种强制模型"只看不动手"的架构模式,详见 004 文章):把决策权交给模型,框架只负责执行

数据结构

// src/utils/todo/types.ts
type TodoItem = {
  content: string      // 💡 命令式描述,如 "Run tests"
  status: 'pending' | 'in_progress' | 'completed'
  activeForm: string   // 💡 进行时描述,如 "Running tests"
}

contentactiveForm 是同一个任务的两种表述形式。content 用于任务列表展示,activeForm 用于任务执行时的状态提示("正在运行测试...")。提示词里明确要求模型同时提供两种形式:

IMPORTANT: Task descriptions must have two forms:
- content: The imperative form describing what needs to be done (e.g., "Run tests")
- activeForm: The present continuous form shown during execution (e.g., "Running tests")

提示词:何时使用

TodoWriteTool 的提示词花了大量篇幅说明何时该用、何时不该用

## When to Use This Tool
1. Complex multi-step tasks - When a task requires 3 or more distinct steps
2. Non-trivial and complex tasks
3. User explicitly requests todo list
4. User provides multiple tasks
5. After receiving new instructions - Immediately capture user requirements as todos
6. When you start working on a task - Mark it as in_progress BEFORE beginning work
7. After completing a task - Mark it as completed

## When NOT to Use This Tool
1. There is only a single, straightforward task
2. The task is trivial
3. The task can be completed in less than 3 trivial steps
4. The task is purely conversational or informational

这种"正反两面都说清楚"的提示词设计,是为了防止模型滥用——不是每次操作都需要创建 todo,过度使用反而会增加噪音。

执行逻辑:全量替换

// src/tools/TodoWriteTool/TodoWriteTool.ts(简化)
async call({ todos }, context) {
  const todoKey = context.agentId ?? getSessionId()
  const oldTodos = appState.todos[todoKey] ?? []

  // 💡 全部完成时清空列表,而不是保留已完成项
  const allDone = todos.every(_ => _.status === 'completed')
  const newTodos = allDone ? [] : todos

  // 检查是否需要验证提醒(三个必要条件)
  const verificationNudgeNeeded =
    todos.filter(t => t.status === 'completed').length >= 3 &&    // ① 一次性完成 ≥3 个任务
    !todos.some(t => t.content.toLowerCase().includes('verif')) && // ② 无验证步骤
    process.env.VERIFICATION_AGENT === 'true'                     // ③ Feature Flag 开启

  context.setAppState(prev => ({
    ...prev,
    todos: { ...prev.todos, [todoKey]: newTodos },
  }))

  return { data: { oldTodos, newTodos: todos, verificationNudgeNeeded } }
}

TodoWriteTool 是全量替换而不是增量更新——每次调用都传入完整的 todo 列表,覆盖之前的状态。这简化了并发控制(不需要处理"同时添加两个 todo"的冲突),代价是每次更新都要传完整列表。

todoKeyagentId 区分子 Agent 的任务列表——父 Agent 和子 Agent 各自维护独立的 todo,但都存在同一个全局 todos Map 里。

UI 渲染

renderToolUseMessage() {
  return null  // 💡 不在对话转录里显示
}

TodoWriteTool 的工具调用不显示在对话里,但 todo 列表会在 UI 的独立面板里实时更新。这是有意为之的设计:任务管理是"元操作",不应该污染对话流。


工具结果的大小控制

问题:工具结果可能很大

FileReadTool 读一个 10000 行的文件,结果可能有几十万字符。如果直接塞进 API 请求,会消耗大量 token,甚至超出上下文限制。

Claude Code 的解决方案:持久化而非截断

两层阈值

// src/constants/toolLimits.ts

// 单个工具结果的阈值(默认)
export const DEFAULT_MAX_RESULT_SIZE_CHARS = 50_000

// 单条消息内所有工具结果的总阈值
export const MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 200_000

每个工具可以声明自己的 maxResultSizeChars(FileReadTool、FileEditTool、FileWriteTool、TodoWriteTool 都是 100_000),但系统会用 Math.min(declaredMax, DEFAULT_MAX_RESULT_SIZE_CHARS) 取较小值——除非工具声明 maxResultSizeChars: Infinity(豁免截断,如 FileReadTool 自身)。

持久化逻辑

// src/utils/toolResultStorage.ts(简化)
export function getPersistenceThreshold(toolName, declaredMaxResultSizeChars): number {
  // Infinity = 豁免,不持久化(FileReadTool 自己读文件,不需要把结果再存一遍)
  if (!Number.isFinite(declaredMaxResultSizeChars)) {
    return declaredMaxResultSizeChars
  }
  // 取工具声明值和全局默认值的较小值
  return Math.min(declaredMaxResultSizeChars, DEFAULT_MAX_RESULT_SIZE_CHARS)
}

超出阈值时,工具结果被写入 .claude/sessions/{sessionId}/tool-results/ 目录下的文件,模型收到的是一个包含文件路径的预览:

<persisted-output>
Result saved to: /path/to/tool-results/result-abc123.txt
Preview (first 1000 chars):
...
</persisted-output>

二次消费逻辑:模型看到这个预览后,如果需要完整内容,会自动用 FileReadTool 读取那个持久化文件。这是一种按需加载(Lazy Loading)策略——不是一次性吞掉 200k token 导致注意力分散(Attention Drift),而是让模型在真正需要时再去读取。

预览消息里通常会包含一段指令:"The output is too large. If you need the full content, use FileReadTool on this specific path." 这是工具系统的动态上下文管理——强制模型进行分段消费,而不是被海量结果淹没。

消息级预算

除了单工具阈值,还有消息级预算:同一批并发工具的结果总大小不能超过 200_000 字符。如果超出,最大的几个结果会被持久化,直到总大小降到阈值以下。

这防止了"10 个工具并发,每个结果 40k,合计 400k"的情况——并发执行的效率优势不应该以上下文爆炸为代价。


本系列后续文章

003 是普通工具的实现地图。每类特殊工具对应后续的深度文章:

工具类型对应文章
Plan 模式工具(EnterPlanMode)004 PlanMode:只读模式的实现
问答工具(AskUserQuestion)005 AskUserQuestion:人机协作的接口
Agent 工具(Agent/Skill)006 AgentTool:子 Agent 的生命周期
MCP 工具007 MCP 工具:外部能力的接入协议
Skill 工具008 SkillTool:可复用工作流的实现
Team 工具(TeamCreate/TeamDelete)009 TeamTool:多 Agent 协作编排

系列导航

常见问题 FAQ

Q:TodoWriteTool 和 queryLoop 是什么关系?模型怎么知道还有任务没完成?

TodoWriteTool 不直接影响 queryLoop 的循环逻辑——queryLoop 继续还是停止,取决于模型是否还在输出 tool_use block,而不是 todo 列表的状态。

但 todo 列表通过提示词注入间接影响模型行为。attachments.ts 里有一个 todo reminder 机制:如果模型连续几轮没有调用 TodoWriteTool,系统会在下一轮的 user message 里附加一条提醒,把当前 todo 列表注入上下文:

// attachments.ts(简化)
if (turnsSinceLastTodoWrite >= TURNS_SINCE_WRITE &&
    turnsSinceLastReminder >= TURNS_BETWEEN_REMINDERS) {
  // 把当前 todo 列表作为 attachment 注入下一轮 user message
  return [{ type: 'todo_reminder', content: todos }]
}

模型看到这条提醒后,会意识到还有未完成的任务,继续调用工具——这才触发了 queryLoop 的下一轮。所以 todo 列表是通过"提示词压力"而不是"代码控制"来驱动循环的。


Q:TodoWriteTool 全部完成后会清空列表,那 allDone → [] 的逻辑是什么意思?

const allDone = todos.every(_ => _.status === 'completed')
const newTodos = allDone ? [] : todos

当模型把所有 todo 都标记为 completed 时,call() 不会把这个"全部完成"的列表存进 AppState,而是存空数组 []

原因:已完成的 todo 对后续对话没有价值,保留它们只会占用上下文空间。清空后,下一轮的 todo reminder 不会再触发(列表为空),模型也不会再被提醒"还有任务"。

注意:call() 的返回值 newTodos: todos 仍然包含完整列表(含 completed 项),这是给 UI 渲染用的——用户能看到"所有任务已完成"的状态,但 AppState 里已经是空的了。

Verification Nudge:如果模型一次性完成了 3+ 个任务,但没有一个任务是"验证"步骤(task content 里不包含 verif 关键词),call() 会在 tool_result 里附加一条提醒:

// mapToolResultToToolResultBlockParam(简化)
const nudge = verificationNudgeNeeded
  ? `\n\nNOTE: You just closed out 3+ tasks and none of them was a verification step. Before writing your final summary, spawn the verification agent (subagent_type="verification"). You cannot self-assign PARTIAL by listing caveats in your summary — only the verifier issues a verdict.`
  : ''
return { content: base + nudge }

这是一个结构化提示(structural nudge)——在模型即将退出循环的时刻(所有任务完成),强制插入一条验证提醒,防止模型跳过验证步骤直接声称"完成"。这个 nudge 只在主线程 Agent 触发(子 Agent 不触发),且需要 VERIFICATION_AGENT feature flag 开启。


Q:FileEditTool 的 old_string 匹配失败,错误信息里说"找不到字符串",但我明明看到文件里有这段内容?

最常见的原因是缩进不匹配FileReadTool 返回的是 cat -n 格式,行号前缀是 行号 + tab(或 空格 + 行号 + 箭头,取决于配置)。如果模型把行号前缀也包含进了 old_string,匹配就会失败。

提示词里明确说了:

ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix.
Never include any part of the line number prefix in the old_string or new_string.

另一个原因是弯引号 vs 直引号。文件里用了 " " 这样的弯引号,但模型输出的是直引号 "findActualString 会尝试规范化后重新匹配,但如果规范化后仍然找不到,就会报错。


Q:FileWriteTool 和 FileEditTool 都要求"先读再写",但新建文件不需要先读,这个判断在哪里?

两个工具的 validateInput 都检查 readFileState,但对新建文件有特殊处理:

  • FileWriteToolstat 抛 ENOENT(文件不存在)时,直接 return { result: true },跳过所有验证。
  • FileEditToolold_string === '' 且文件不存在时,视为新建文件,允许执行(old_string 为空 = 在空文件里插入内容)。

两者的区别:FileWriteTool 新建文件是"写入全部内容",FileEditTool 新建文件是"在空文件里追加内容"(old_string 为空字符串,new_string 是要写入的内容)。