π Pi Agent Study

04 默认工具学习记录

本节目标

本节目标是理解 coding agent 的默认工具如何从产品层定义进入 agent loop,以及 readwriteeditbash 四个核心工具各自的边界。

重点问题:

我们怎么学的

工具注册链路

默认工具不是直接散落在 loop 里,而是先以 ToolDefinition 的形式进入 coding-agent 的工具注册系统。

createAllToolDefinitions()
  -> _baseToolDefinitions
  -> _refreshToolRegistry()
  -> wrapToolDefinition()
  -> _toolRegistry
  -> setActiveToolsByName()
  -> agent.state.tools
  -> runLoop 执行 tool calls
flowchart LR
  D[ToolDefinition] --> W[wrapToolDefinition]
  W --> A[AgentTool]
  A --> S[tools schema 给模型]
  S --> C[assistant toolCall]
  C --> E[execute]
  E --> R[ToolResultMessage]
  R --> X[Context.messages]

关键文件:

工具就是本地函数

给新手的最小理解:

工具 = 暴露给模型选择的本地函数

模型不会自己读文件、写文件或跑 shell。它只会在 assistant message 里生成 toolCall,Pi 再在本地执行对应工具函数。

一个工具最少要讲清四件事:

闭环:

工具 schema 给模型
-> 模型生成 toolCall
-> Pi 本地 execute
-> 结果包装成 ToolResultMessage
-> 回填 Context.messages

ToolDefinition 与 AgentTool

AgentTool 是 agent core 关心的最小执行接口:

name
description
parameters
prepareArguments
executionMode
execute

它只关心:

ToolDefinition 是 coding-agent 产品层工具定义,信息更多:

label
promptSnippet
promptGuidelines
renderCall
renderResult
sourceInfo
extension context

它服务于:

所以两层设计可以记成:

ToolDefinition = 产品层完整工具定义
AgentTool      = agent core 执行工具接口
wrap           = 把 ToolDefinition 适配成 AgentTool

一句话:

ToolDefinition 给 pi 用,AgentTool 给 agent loop 用。

read

read 的 schema 很小:

{
	path: string;
	offset?: number;
	limit?: number;
}

执行路径:

read.execute()
  -> resolveReadPathAsync(path, cwd)
  -> ops.access() 检查可读
  -> detectImageMimeType()
  -> 如果是图片:读 buffer,必要时 resize,返回 text + image
  -> 如果是文本:读 buffer,utf-8 解码
  -> 按 offset/limit 截取
  -> truncateHead() 控制最大 2000 行 / 50KB
  -> 返回 { content, details }

read 不只是 fs.readFile。它把本地文件安全地、可分页地、可被模型继续消费地转成 tool result。

适合:

不适合:

为什么 read 要 offset/limit

因为模型上下文有限,文件可能很大,不能一次性全塞进去。

可以理解成:

offset/limit = 主动分页
truncateHead = 被动保护

如果文件没读完,截断提示会进入 tool result:

[Showing lines 1-2000 of 5000. Use offset=2001 to continue.]

这不是 UI 装饰,而是让模型下一轮知道可以继续读。

为什么 read 保留 head

文件开头通常更适合理解结构:

所以 read 使用 truncateHead() 保留开头,并提示下一次 offset。

write

write 的 schema 只有:

{
	path: string;
	content: string;
}

执行路径:

write.execute()
  -> resolveToCwd(path, cwd)
  -> dirname(absolutePath)
  -> withFileMutationQueue(absolutePath)
  -> mkdir(parentDir)
  -> writeFile(absolutePath, content)
  -> 返回 "Successfully wrote N bytes..."

write 的描述很直接:

Creates the file if it doesn't exist, overwrites if it does.

所以:

write = 全量写入
edit  = 精确修改

write 不会读旧内容,不会做 diff,也不会检查是不是只想改一小段。它收到什么 content,就把整个文件变成什么。

因此已有文件的小改用 write 风险大:模型如果漏掉旧文件的一段内容,就等于删掉了。

适合:

不适合:

file mutation queue

writeedit 都使用 withFileMutationQueue()

它解决的是文件资源层的并发写问题:

同一个 absolutePath 的 mutation 串行
不同文件仍可并行

这和 agent loop 的 sequential/parallel 不冲突:

sequential = agent 调度层顺序
fileMutationQueue = 文件资源层锁

即使 agent loop 允许 parallel:

write a.ts
edit a.ts

也会被同一个文件队列排队,避免互相覆盖。

另一个细节是,writeedit 不用 abort listener 直接 reject 来释放队列,而是在每个 await 后检查 signal.aborted。这样可以确保底层 filesystem 操作 settle 之后,队列才释放。

edit

edit 是“比 write 更安全的局部修改工具”。

schema:

{
	path: string;
	edits: [
		{
			oldText: string;
			newText: string;
		}
	]
}

执行路径:

edit.execute()
  -> validateEditInput()
  -> resolveToCwd(path, cwd)
  -> withFileMutationQueue(path)
  -> access(R_OK | W_OK)
  -> readFile()
  -> stripBom()
  -> detectLineEnding()
  -> normalizeToLF()
  -> applyEditsToNormalizedContent()
  -> restoreLineEndings()
  -> writeFile()
  -> generateDiffString()
  -> generateUnifiedPatch()
  -> 返回 success + diff/patch

oldText 必须唯一

edit 不会找到第一处就替换,因为那样很容易改错代码。

例如文件中有很多:

return null;

如果模型只给这一行作为 oldText,pi 不知道它想改哪一处。

所以 edit 要求:

找不到:失败
不唯一:失败
重叠:失败
结果没变化:失败

这会逼模型提供更多上下文。

一句话:

edit 宁愿失败,也不要猜模型想改哪一处。

适合:

不适合:

多个 edits 基于原始文件

多个 edits 不是逐个增量匹配:

edit 1 改完
再基于改完后的文件跑 edit 2

而是:

所有 oldText 都先在原始文件中定位
确认不重叠
再从后往前应用

从后往前应用是为了避免前面的替换改变后面文本的 index。

保留文件风格

edit 会:

stripBom()
detectLineEnding()
normalizeToLF()
restoreLineEndings()

内部统一用 LF 做匹配和 diff,写回时恢复原来的换行风格,并保留 BOM。

bash

bash 的核心不是“执行命令”,而是把一个可能长时间运行的命令变成可观察、可截断、可中止的工具执行。

schema:

{
	command: string;
	timeout?: number;
}

执行路径:

bash.execute()
  -> commandPrefix 拼接
  -> spawnHook 调整 command/cwd/env
  -> OutputAccumulator 开始收集输出
  -> ops.exec(command, cwd, { onData, signal, timeout, env })
  -> stdout/stderr chunk 进入 output.append()
  -> scheduleOutputUpdate()
  -> onUpdate({ partial output })
  -> 命令结束 finishOutput()
  -> exitCode 非 0 则抛错
  -> 返回 { content, details }

partial update

bash 和其他工具最大不同是执行中会不断发 partial update:

tool_execution_start
tool_execution_update
tool_execution_update
tool_execution_end
tool_result message

partial update 是给 UI/事件流看的,不是给模型继续推理用的稳定结果。

真正回填给模型的是命令结束后的最终结果。

原因:

所以:

partial update = 给 UI/JSON 观察进度
final ToolResultMessage = 给模型继续推理

bash 输出截断

bash 使用 OutputAccumulator。它不会把无限输出都留在内存里:

内存里保留 tail
超过 2000 行 / 50KB 后标记 truncation
完整输出写到 /tmp/pi-bash-xxxx.log
最终 tool result 提示 Full output 路径

bash 更适合保留 tail,因为命令输出尾部通常更重要:

bash 失败会给模型什么

如果 exit code 非 0,bash.execute() 会抛错:

if (exitCode !== 0 && exitCode !== null) {
	throw new Error(appendStatus(outputText, `Command exited with code ${exitCode}`));
}

agent loop 捕获异常后,会转成 error tool result:

{
	content: [{ type: "text", text: error.message }],
	isError: true
}

所以模型看到的不是只有 “bash failed”,而是:

...已有 stdout/stderr 尾部输出...

Command exited with code N

timeout 类似:

...已有输出...

Command timed out after N seconds

abort 类似:

...已有输出...

Command aborted

适合:

不适合:

工具输出如何回传模型

工具输出回传模型的链路是:

模型生成 assistant toolCall
  -> agent loop 执行 tool.execute()
  -> tool 返回 AgentToolResult
  -> createToolResultMessage()
  -> 追加到 currentContext.messages
  -> 下一轮 streamAssistantResponse(currentContext)
  -> provider 把 toolResult 转成 OpenAI/Anthropic 等 API payload
  -> 模型看到工具结果,继续生成

核心点:

tool.execute() 的返回值
不是直接塞给模型
而是先包装成 ToolResultMessage

结构大致是:

{
	role: "toolResult",
	toolCallId: "call_xxx",
	toolName: "read",
	content: [{ type: "text", text: "..." }],
	isError: false
}

然后这一条消息进入 Context.messages。下一次调用模型时,provider 会把它翻译成对应厂商的 tool result 格式。

顺序与稳定性

一次模型同时调用 readeditbash 时,要分两层看。

agent loop 层:

保证一批 tool call 的调度和回填稳定

工具内部层:

保证具体资源操作稳定

如果并行执行完成顺序是:

bash 先结束
read 后结束
edit 最后结束

回填给模型的 ToolResultMessage 仍按 assistant 原始 tool call 顺序:

result A: read
result B: edit
result C: bash

工具自身再保证资源安全:

一句话:

loop 管 tool call 的生命周期;
tool 管自己资源的安全边界。

本节完成度

本节已经完成:

下一节建议进入 Session

03/04 看的是一次 prompt 怎么跑;
05 看的是多轮对话、历史、恢复和压缩怎么维持。

自测题

Source: 04-default-tools-notes.md