一、引言:工具的作用
上一篇文章拆解了 agent loop 的循环骨架,但有一个环节我们没有展开------executeToolCalls。循环里每次 LLM 返回带 toolCall 的消息,就把工具调用丢给这个函数执行,拿回 ToolResultMessage 回填到 context,然后继续下一轮。
这个黑盒里发生了什么?agent 是怎么"做事"的?
LLM 本身只能生成文本------它不能读文件、不能执行命令、不能改代码。工具就是 LLM 伸出去的手:agent loop 把 LLM 决定调用的工具名和参数交给 executeToolCalls,后者查到对应的工具实现、校验参数、执行、返回结构化结果。LLM 在下一轮看到结果后,决定是继续调工具还是回复用户。
pi 内置了 7 个工具,分两组:
| 集合 | 工具 | 能力 |
|---|---|---|
| coding tools | read / bash / edit / write |
读文件、执行命令、编辑代码、写文件 |
| read-only tools | read / grep / find / ls |
读文件、搜索内容、查找文件、列目录 |
但工具体系不止这 7 个------pi 提供了 AgentTool 接口,任何人都能实现自己的工具加到 agent 里。本章从接口定义、工具注册、到执行链逐层拆解,再用 read 工具走一遍完整路径,最后介绍其余 6 个工具各自的特点。
二、接口定义与工具注册
1. 接口定义
pi 的工具体系跨三层,每一层加一点能力:
ai 层:Tool (packages/ai/src/types.ts:338)
typescript
interface Tool {
name: string; // 工具名,LLM 调用时用这个名字
description: string; // 一行描述,LLM 根据它决定是否调用
parameters: TSchema; // JSON Schema,LLM 根据它生成参数
}
这是最小定义------只有描述信息,没有执行逻辑。parameters 用 TypeBox 的 TSchema(编译期类型安全 + 运行期 JSON Schema),LLM 看到的是它生成的 JSON Schema。
agent 层:AgentTool extends Tool (packages/agent/src/types.ts:361)
typescript
interface AgentTool<TParameters, TDetails> extends Tool {
label: string; // UI 显示名(如 "Read File")
prepareArguments?: (args: unknown) => Static<TParameters>; // 参数预处理
execute: ( // 执行逻辑
toolCallId: string,
params: Static<TParameters>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TDetails>, // 流式进度回调
) => Promise<AgentToolResult<TDetails>>;
executionMode?: "sequential" | "parallel"; // 执行策略
}
四个新增字段的设计意图:
label:name给 LLM 看("read"),label给人看("Read File")。分离是因为 LLM 更适合短标识符,UI 需要可读名称。prepareArguments:LLM 生成的参数可能不完美(字段名不匹配、类型不对)。这个钩子在 schema 校验前做一次预处理,兼容 LLM 的"粗心"。execute:核心。签名是(toolCallId, params, signal?, onUpdate?) → Promise<AgentToolResult>。onUpdate让工具能流式推送中间状态(如 bash 的实时输出),UI 不用等执行完才显示。executionMode:标记这个工具能不能和其他工具并行执行。bash和edit通常sequential(避免冲突),grep和find可以parallel。
agent 层:AgentToolResult (packages/agent/src/types.ts:345)
typescript
interface AgentToolResult<T> {
content: (TextContent | ImageContent)[]; // 给 LLM 看的(回填到 toolResult message)
details: T; // 给 UI / 日志看的(结构化数据)
terminate?: boolean; // 提前终止提示
}
content 和 details 的分离是关键设计:content 是 LLM 能理解的文本/图片,details 是结构化数据(如文件路径、命令退出码),UI 可以用它渲染富展示。两者各走各的路,互不干扰。
agent 层:AgentToolCall (packages/agent/src/types.ts:47)
typescript
type AgentToolCall = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
// 等价于:
interface AgentToolCall {
type: "toolCall";
id: string;
name: string;
arguments: Record<string, any>;
}
这是从 AssistantMessage.content 里提取的工具调用块------LLM 在回复里产生的"我要调用 echo 工具,参数是 {text: 'hello'}"。
coding-agent 层:具体工具
ReadTool / BashTool / EditTool / WriteTool 都 implements AgentTool。第 4 章以 ReadTool 为例展开,第 5 章讲其余三个。
2. 工具注册
pi 的 AgentHarness 用 Map<string, AgentTool> 持有工具集合(agent-harness.ts:191):
typescript
class AgentHarness {
private tools = new Map<string, TTool>();
private activeToolNames: string[]; // 当前激活的工具子集
constructor(options) {
// 构造时校验工具名唯一性
this.validateUniqueNames(
(options.tools ?? []).map((t) => t.name),
"Duplicate tool name(s)",
);
// 逐个注册
for (const tool of options.tools ?? []) {
this.tools.set(tool.name, tool); // name → tool
}
// 激活工具子集(默认全部激活)
this.activeToolNames = options.activeToolNames
? [...options.activeToolNames]
: (options.tools ?? []).map((t) => t.name);
}
// 运行时动态替换工具集
async setTools(tools: TTool[], activeToolNames?: string[]) {
const nextTools = new Map(tools.map((t) => [t.name, t]));
this.validateToolNames(activeToolNames ?? this.activeToolNames, nextTools);
this.tools = nextTools;
this.activeToolNames = [...(activeToolNames ?? this.activeToolNames)];
this.emitOwn({ type: "tools_update", ... }); // 通知 UI
}
}
两个关键设计:
tools Map vs activeToolNames 列表 :Harness 区分"已注册"和"已激活"。注册的工具在 Map 里,但只有 activeToolNames 里的才会传给 LLM。这让 agent 能在运行时动态切换工具集------比如从 coding 模式切到 read-only 模式,不需要重启 agent。
setTools 运行时替换 :整个工具集可以在 agent 运行时被替换。如果 agent 正忙(phase !== "idle"),变更写入 pendingSessionWrites,等当前任务完成后才生效。这是 pi "扩展无需 fork" 的基础------Extension API 可以通过 setTools 注入自定义工具。
AgentContext.tools 是 Harness 构造 context 时从 activeToolNames 解析出来的:
typescript
const activeTools = this.activeToolNames
.map((name) => this.tools.get(name))
.filter((tool): tool is TTool => tool !== undefined);
const context = {
systemPrompt,
messages,
tools: activeTools, // 只有激活的工具传给 agent loop
};
agent loop 拿到 context.tools 后,在调 LLM 时把它转成 OpenAI 的 function schema 格式一起发送------LLM 看到的就是这些工具的 name / description / parameters。
三、executeToolCalls 执行链
文章 3 的循环骨架里,executeToolCalls 是个黑盒------"执行工具,拿回结果"。本章拆开它。
pi 把工具执行拆成三段:prepare(准备)→ execute(执行)→ finalize(收尾)。这不是过度设计------每一段都有独立的职责,且支持对应的钩子。我们用搭积木方式逐层加。
完整执行链的流程如下:
1. 最小版:直接执行
先看最简单的版本------没有 prepare/finalize 拆分,没有钩子,直接查工具、执行、构造结果:
ts
async function executeToolCallsSequential(context, toolCalls, signal, emit):
messages = []
allTerminate = true
for toolCall in toolCalls:
emit(tool_execution_start, toolCall.id, toolCall.name, toolCall.arguments)
# 查工具
tool = context.tools.find(t => t.name == toolCall.name)
if not tool:
result = errorToolResult("Tool {toolCall.name} not found")
isError = true
else:
# 直接执行
result = await tool.execute(toolCall.id, toolCall.arguments, signal)
isError = false
emit(tool_execution_end, toolCall.id, toolCall.name, result, isError)
# 构造 ToolResultMessage
toolResultMessage = {
role: "toolResult",
toolCallId: toolCall.id,
toolName: toolCall.name,
content: result.content,
details: result.details,
isError,
timestamp: now()
}
emit(message_start, toolResultMessage)
emit(message_end, toolResultMessage)
messages.push(toolResultMessage)
if not result.terminate:
allTerminate = false
if signal.aborted: break
return { messages, terminate: messages.length > 0 and allTerminate }
这能跑,但缺三个能力:参数校验(LLM 可能生成垃圾参数)、执行前拦截(某些场景需要禁止特定工具调用)、执行后修改结果(如脱敏、加日志)。pi 用三段拆分解决。
2. 加 prepare:参数校验 + beforeToolCall 钩子
prepareToolCall(agent-loop.ts:562-626)在执行前做两件事:校验参数、调用 beforeToolCall 钩子。
ts
async function prepareToolCall(context, toolCall, config, signal):
tool = context.tools.find(t => t.name == toolCall.name)
if not tool:
return { kind: "immediate", # 不执行,直接返回错误
result: errorToolResult("Tool not found"),
isError: true }
try:
# 参数预处理
preparedToolCall = prepareArguments(tool, toolCall)
# schema 校验
validatedArgs = validateToolArguments(tool, preparedToolCall)
# beforeToolCall 钩子
if config.beforeToolCall:
beforeResult = await config.beforeToolCall({
assistantMessage, toolCall, args: validatedArgs, context
}, signal)
if beforeResult.block: # 拦截
return { kind: "immediate",
result: errorToolResult(beforeResult.reason),
isError: true }
return { kind: "prepared", # 放行执行
tool, args: validatedArgs }
catch error:
return { kind: "immediate", # 校验失败
result: errorToolResult(error.message),
isError: true }
关键设计是 kind: "immediate" | "prepared" 二分:
immediate:不执行,直接返回一个错误结果(工具没找到 / 参数校验失败 / 被 beforeToolCall 拦截)。跳过 execute 和 finalize。prepared:参数准备好了,放行到 execute 阶段。
这个二分让 sequential 循环的逻辑很干净:
ts
for toolCall in toolCalls:
preparation = await prepareToolCall(...)
if preparation.kind == "immediate":
finalized = { toolCall, result: preparation.result, isError: preparation.isError }
else:
executed = await executePreparedToolCall(preparation, signal, emit)
finalized = await finalizeExecutedToolCall(...)
emitToolExecutionEnd(finalized)
messages.push(createToolResultMessage(finalized))
3. 加 execute:实际调用 + onUpdate 流式更新
executePreparedToolCall(agent-loop.ts:628-663)调 tool.execute,同时处理 onUpdate 流式回调:
typescript
// 真实代码(agent-loop.ts:635-663)
const result = await prepared.tool.execute(
prepared.toolCall.id,
prepared.args as never,
signal,
(partialResult) => { // onUpdate 回调
updateEvents.push(
Promise.resolve(
emit({
type: "tool_execution_update",
toolCallId: prepared.toolCall.id,
toolName: prepared.toolCall.name,
args: prepared.toolCall.arguments,
partialResult,
}),
),
);
},
);
await Promise.all(updateEvents); // 等所有 update 事件发完
return { result, isError: false };
onUpdate 让工具能边执行边推送中间状态。bash 工具用它推送命令的实时输出------UI 不用等命令跑完才显示。updateEvents 用 Promise 数组收集异步 emit,执行完后 Promise.all 确保所有 update 事件都发完再继续。
错误处理走 try-catch,不抛异常------失败时返回 { result: errorToolResult(...), isError: true },让 finalize 阶段统一处理。
4. 加 finalize:afterToolCall 钩子合并结果
finalizeExecutedToolCall(agent-loop.ts:665-708)在执行后做最后一道工序:
typescript
// 真实代码(agent-loop.ts:673-707)
let result = executed.result;
let isError = executed.isError;
if (config.afterToolCall) {
try {
const afterResult = await config.afterToolCall(
{
assistantMessage,
toolCall: prepared.toolCall,
args: prepared.args,
result, // 执行后的原始结果
isError,
context: currentContext,
},
signal,
);
if (afterResult) {
// 字段级覆盖,不深合并
result = {
content: afterResult.content ?? result.content,
details: afterResult.details ?? result.details,
terminate: afterResult.terminate ?? result.terminate,
};
isError = afterResult.isError ?? isError;
}
} catch (error) {
result = createErrorToolResult(error.message);
isError = true;
}
}
afterToolCall 的合并语义是字段级覆盖 ------content / details / terminate / isError 各自独立替换,不提供就保留原值。这让钩子可以只改一个字段(如只替换 content 做脱敏,保留 details 和 isError)。
典型用途:敏感信息脱敏(把 content 里的密码替换成 ***)、统一日志(记录每次工具调用的 args 和 result)、错误重试(isError 为 true 时换一种方式重试)。
5. 加 parallel:并发执行
executeToolCallsParallel(agent-loop.ts:451-561)和 sequential 的区别在于:prepare 阶段顺序执行,execute 阶段并发执行。
ini
async function executeToolCallsParallel(context, toolCalls, config, signal, emit):
finalizedCalls = []
# 阶段 1:顺序 prepare(含 beforeToolCall)
for toolCall in toolCalls:
emit(tool_execution_start, ...)
preparation = await prepareToolCall(...)
if preparation.kind == "immediate":
finalizedCalls.push(resolved(finalization))
else:
# 不立即 execute,包成一个 async 函数推入数组
finalizedCalls.push(async () => {
executed = await executePreparedToolCall(preparation, signal, emit)
finalized = await finalizeExecutedToolCall(...)
emitToolExecutionEnd(finalized)
return finalized
})
if signal.aborted: break
# 阶段 2:并发 execute + finalize
orderedFinalized = await Promise.all(
finalizedCalls.map(entry =>
typeof entry == "function" ? entry() : Promise.resolve(entry)
)
)
# 阶段 3:顺序构造 ToolResultMessage
messages = []
for finalized in orderedFinalized:
toolResultMessage = createToolResultMessage(finalized)
emit(message_start, toolResultMessage)
emit(message_end, toolResultMessage)
messages.push(toolResultMessage)
return { messages, terminate: shouldTerminate(orderedFinalized) }
为什么 prepare 顺序、execute 并发?因为 beforeToolCall 钩子可能依赖前一个工具的 prepare 结果(如检查"这一批工具调用里有没有危险组合"),顺序执行能保证钩子看到完整的上下文。execute 本身是无副作用的纯计算(参数已经校验过),并发安全。
executeToolCalls(agent-loop.ts:373-388)的分派逻辑很简单:
ts
function executeToolCalls(context, message, config, signal, emit):
toolCalls = message.content.filter(type == "toolCall")
hasSequentialTool = toolCalls.some(tc =>
context.tools.find(t => t.name == tc.name)?.executionMode == "sequential"
)
if config.toolExecution == "sequential" or hasSequentialTool:
return executeToolCallsSequential(...)
return executeToolCallsParallel(...)
只要有一个工具标记了 executionMode: "sequential",整批就走 sequential 路径。这是保守策略------混批时宁可慢,不要冲突。
四、read 工具:从定义到执行
前三章讲了工具接口、注册和执行链。本章以 read 工具为例,展示一个真实工具从 AgentTool 接口落地到具体实现。read 是 pi 最基础的工具------LLM 用它读文件,是几乎所有编码任务的第一步。
0. read 工具结构
read 工具的组成:schema 定义参数、ReadOperations 抽象文件 IO(可替换为 SSH 等远程实现)、TruncationResult 记录截断信息。renderCall / renderResult 是 TUI 渲染钩子,不属于 agent loop 执行链,这里不展开。
0.1 execute 内部流程
两条路径在 execute 入口处分流:图片走左侧(读 + 缩放 + base64),文本走右侧(读 + 切片 + 截断 + continue 提示)。最终都汇合到 resolve({ content, details })。
1. schema 定义
typescript
// coding-agent/src/core/tools/read.ts:20-24
const readSchema = Type.Object({
path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
});
三个参数:
path:文件路径,相对或绝对。LLM 从 system prompt 里的Current working directory推断相对路径。offset:起始行号(1-indexed)。大文件不能一次读完,LLM 用 offset 分段读取。limit:最多读几行。和 offset 配合,LLM 可以精准读取特定区域。
Type.Object 来自 TypeBox,编译期生成 TypeScript 类型和运行期 JSON Schema------LLM 看到的是后者,validateToolArguments 用前者校验。
2. 工具定义
typescript
// coding-agent/src/core/tools/read.ts:203-358
export function createReadToolDefinition(cwd: string, options?: ReadToolOptions): ToolDefinition {
const ops = options?.operations ?? defaultReadOperations; // 文件操作接口,可替换
return {
name: "read",
label: "read",
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp).
Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines
or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files.
When you need the full file, continue with offset until complete.`,
parameters: readSchema,
async execute(_toolCallId, { path, offset, limit }, signal?, _onUpdate?, ctx?) {
// ... 见下方
},
};
}
description 不是随便写的------它直接告诉 LLM 截断规则(2000 行 / 50KB)和应对策略(用 offset 继续)。LLM 读到这段描述后,遇到大文件会主动分段调用。
ops 是 ReadOperations 接口,默认用本地 fs.readFile,但可以替换成 SSH 远程读取------这是 pi 的可插拔设计。
3. execute 实现------文本路径
typescript
// read.ts:236-326(简化,省略 abort 处理)
const absolutePath = await resolveReadPathAsync(path, cwd); // 解析路径
await ops.access(absolutePath); // 检查可读
const buffer = await ops.readFile(absolutePath); // 读文件
const textContent = buffer.toString("utf-8");
const allLines = textContent.split("\n");
const totalFileLines = allLines.length;
// offset:1-indexed 输入 → 0-indexed 数组访问
const startLine = offset ? Math.max(0, offset - 1) : 0;
if (startLine >= allLines.length) {
throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);
}
// limit:用户指定的行数限制优先
let selectedContent: string;
if (limit !== undefined) {
const endLine = Math.min(startLine + limit, allLines.length);
selectedContent = allLines.slice(startLine, endLine).join("\n");
} else {
selectedContent = allLines.slice(startLine).join("\n");
}
// 截断------防止大文件爆 context
const truncation = truncateHead(selectedContent);
let outputText: string;
if (truncation.firstLineExceedsLimit) {
// 单行就超过字节限制(如压缩文件)→ 告诉 LLM 用 bash sed 读
outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit.
Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
} else if (truncation.truncated) {
// 被截断了 → 告诉 LLM 接下来用哪个 offset 继续
outputText = truncation.content + `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay}
of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
} else {
outputText = truncation.content;
}
content = [{ type: "text", text: outputText }];
核心逻辑三层:
- 路径解析 + 权限检查 :
resolveReadPathAsync把相对路径转绝对路径,ops.access检查文件可读。 - offset/limit 切片:LLM 通过 offset/limit 精准读取文件的某一段。这两个参数让 LLM 能"翻页"大文件。
- 截断 :即使有 limit,单次读取的内容也可能太大。
truncateHead做最后一道保护。
4. truncateHead 截断逻辑
typescript
// coding-agent/src/core/tools/truncate.ts:78-160
export function truncateHead(content: string, options?: TruncationOptions): TruncationResult {
const maxLines = options?.maxLines ?? DEFAULT_MAX_LINES; // 2000 行
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES; // 50KB
const totalBytes = Buffer.byteLength(content, "utf-8");
const lines = splitLinesForCounting(content);
const totalLines = lines.length;
// 不需要截断
if (totalLines <= maxLines && totalBytes <= maxBytes) {
return { content, truncated: false, ... };
}
// 单行就超过字节限制(如压缩文件、minified JS)
const firstLineBytes = Buffer.byteLength(lines[0], "utf-8");
if (firstLineBytes > maxBytes) {
return {
content: "", // 返回空内容
truncated: true,
firstLineExceedsLimit: true, // read.ts 据此建议 LLM 用 bash sed
...
};
}
// 逐行收集,同时检查行数和字节限制
const outputLinesArr: string[] = [];
let outputBytesCount = 0;
let truncatedBy: "lines" | "bytes" = "lines";
for (let i = 0; i < lines.length && i < maxLines; i++) {
const lineBytes = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0); // +1 for \n
if (outputBytesCount + lineBytes > maxBytes) {
truncatedBy = "bytes"; // 字节限制先到
break;
}
outputLinesArr.push(lines[i]);
outputBytesCount += lineBytes;
}
return {
content: outputLinesArr.join("\n"),
truncated: true,
truncatedBy, // "lines" 或 "bytes"------告诉调用方是哪个限制触发的
outputLines: outputLinesArr.length,
...
};
}
两个独立限制,谁先到听谁的:
- 行限制(默认 2000 行):防止行数太多撑爆 context window
- 字节限制(默认 50KB):防止单行特别长(如 minified JS)绕过行限制
关键设计------不返回半行:逐行收集,如果加下一行会超字节限制就停。这保证 LLM 看到的都是完整行,不会因为半个 JSON / 半个函数定义而困惑。
唯一的例外是 firstLineExceedsLimit------第一行就超字节限制(如压缩文件),连一行都放不下。这时返回空内容 + firstLineExceedsLimit: true,read.ts 据此告诉 LLM:"这行太大了,用 bash: sed -n 'Xp' file | head -c 50396 读"。
5. execute 实现------图片路径
typescript
// read.ts:247-274(简化)
const mimeType = await ops.detectImageMimeType(absolutePath);
if (mimeType) {
// 是图片
const buffer = await ops.readFile(absolutePath);
if (autoResizeImages) {
const resized = await resizeImage(buffer, mimeType); // 缩放到 2000x2000 以内
content = [
{ type: "text", text: `Read image file [${resized.mimeType}]` },
{ type: "image", data: resized.data, mimeType: resized.mimeType },
];
} else {
content = [
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: buffer.toString("base64"), mimeType },
];
}
}
read 工具不仅读文本------它检测文件类型,如果是图片就用 ImageContent 块返回(base64 编码)。autoResizeImages 默认开启,把图片缩放到 2000x2000 以内,防止大图撑爆 context。
如果当前模型不支持图片(model.input 不含 "image"),加一条提示:
arduino
[Current model does not support images. The image will be omitted from this request.]
这就是 AssistantMessage.content 为什么是 (TextContent | ThinkingContent | ToolCall)[] 而不是 string------一条工具结果可能同时携带文本说明和图片数据。
五、其余 6 个工具
前面三章讲了 prepare/execute/finalize 三段拆分。这 6 个工具的 prepare 和 finalize 都走标准路径------prepareToolCall 查工具 + 校验参数 + beforeToolCall 钩子,finalizeExecutedToolCall 跑 afterToolCall 钩子合并结果。差异全在 execute 里。本章只贴每个工具的 schema 和 execute 核心逻辑。
1. bash------执行命令
typescript
// coding-agent/src/core/tools/bash.ts:24-26
const bashSchema = Type.Object({
command: Type.String({ description: "Bash command to execute" }),
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
});
typescript
// bash.ts:282-340(简化,省略 abort 细节)
async execute(_toolCallId, { command, timeout }, signal?, onUpdate?) {
const spawnContext = resolveSpawnContext(command, cwd, spawnHook);
const output = new OutputAccumulator({ tempFilePrefix: "pi-bash" });
// 节流推送------防止高频输出淹没 UI
const emitOutputUpdate = () => {
if (!onUpdate || !updateDirty) return;
const snapshot = output.snapshot({ persistIfTruncated: true });
onUpdate({
content: [{ type: "text", text: snapshot.content || "" }],
details: {
truncation: snapshot.truncation.truncated ? snapshot.truncation : undefined,
fullOutputPath: snapshot.fullOutputPath, // 完整输出存临时文件
},
});
};
const scheduleOutputUpdate = () => {
updateDirty = true;
updateTimer ??= setTimeout(() => {
updateTimer = undefined;
emitOutputUpdate();
}, BASH_UPDATE_THROTTLE_MS); // 节流间隔
};
// 启动子进程
const child = spawn(spawnContext.command, { cwd, ... });
child.stdout.on("data", (data) => { output.append(data); scheduleOutputUpdate(); });
child.stderr.on("data", (data) => { output.append(data); scheduleOutputUpdate(); });
// 等待退出
const exitCode = await waitForChild(child, timeout, signal);
const finalSnapshot = await output.finish();
return {
content: [{ type: "text", text: formatOutput(finalSnapshot, exitCode) }],
details: {
exitCode,
truncation: finalSnapshot.truncation,
fullOutputPath: finalSnapshot.fullOutputPath,
},
};
}
bash 的设计亮点:
onUpdate节流 :命令可能秒级输出大量文本,不能每次 data 事件都 emit。BASH_UPDATE_THROTTLE_MS控制最小推送间隔,UI 看到的是节流后的快照。fullOutputPath:输出太长时写临时文件,content里只放截断后的快照 + 临时文件路径。LLM 知道完整输出在哪,但不撑爆 context。spawnHook:BashSpawnContext允许外部注入 spawn 前的逻辑(如命令白名单检查、环境变量注入)。这是 pi 的安全边界。
2. edit------编辑文件
typescript
// coding-agent/src/core/tools/edit.ts:33-50
const replaceEditSchema = Type.Object({
oldText: Type.String({ description: "Text to find in the file..." }),
newText: Type.String({ description: "Replacement text for this targeted edit." }),
});
const editSchema = Type.Object({
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
edits: Type.Array(replaceEditSchema, { description: "Array of text replacements to apply" }),
});
typescript
// edit.ts:308-361(简化)
async execute(_toolCallId, { path, edits }, signal?) {
const absolutePath = resolveToCwd(path, cwd);
// withFileMutationQueue------文件级互斥锁,防止并发编辑冲突
return withFileMutationQueue(absolutePath, async () => {
// 检查存在 + 读文件
await ops.access(absolutePath);
const buffer = await ops.readFile(absolutePath);
const rawContent = buffer.toString("utf-8");
// BOM 处理 + 行尾检测 + 归一化为 LF
const { bom, text: content } = stripBom(rawContent);
const originalEnding = detectLineEnding(content);
const normalizedContent = normalizeToLF(content);
// 应用所有 edits(oldText → newText)
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);
// 恢复 BOM + 原始行尾,写回
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
await ops.writeFile(absolutePath, finalContent);
// 生成 diff + patch
const diffResult = generateDiffString(baseContent, newContent);
const patch = generateUnifiedPatch(path, baseContent, newContent);
return {
content: [{ type: "text", text: `Successfully replaced ${edits.length} block(s) in ${path}.` }],
details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
};
});
}
edit 的设计亮点:
withFileMutationQueue:文件级互斥锁。同一文件的并发 edit / write 请求会排队执行,避免读写竞态。- BOM + 行尾归一化 :读时 strip BOM + 归一化为 LF 做匹配,写时恢复 BOM + 原始行尾。这保证 LLM 看到的
oldText不受文件编码细节干扰,同时不破坏原文件格式。 details.diff+details.patch:edit 结果的content只是一句"成功替换了 N 块",但details里带了完整 diff 和 unified patch------UI 可以渲染富 diff 展示,日志可以记录 patch 做审计。
3. write------写文件
typescript
// coding-agent/src/core/tools/write.ts:14-17
const writeSchema = Type.Object({
path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
content: Type.String({ description: "Content to write to the file" }),
});
typescript
// write.ts:194-225
async execute(_toolCallId, { path, content }, signal?) {
const absolutePath = resolveToCwd(path, cwd);
const dir = dirname(absolutePath);
return withFileMutationQueue(absolutePath, async () => {
// 自动创建父目录
await ops.mkdir(dir);
await ops.writeFile(absolutePath, content);
return {
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
details: undefined,
};
});
}
write 最简单------创建父目录 + 写文件。和 edit 一样走 withFileMutationQueue 文件锁。details 是 undefined------write 没有结构化元数据需要给 UI 展示。
4. grep------搜索内容
typescript
// coding-agent/src/core/tools/grep.ts:24-36
const grepSchema = Type.Object({
pattern: Type.String({ description: "Search pattern (regex or literal string)" }),
path: Type.Optional(Type.String({ description: "Directory or file to search (default: current directory)" })),
glob: Type.Optional(Type.String({ description: "Filter files by glob pattern, e.g. '*.ts'" })),
ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
literal: Type.Optional(Type.Boolean({ description: "Treat pattern as literal string instead of regex (default: false)" })),
context: Type.Optional(Type.Number({ description: "Number of lines to show before and after each match (default: 0)" })),
limit: Type.Optional(Type.Number({ description: "Maximum number of matches to return (default: 100)" })),
});
typescript
// grep.ts:134-193(简化)
async execute(_toolCallId, { pattern, path: searchDir, glob, ignoreCase, literal, context, limit }, signal?) {
// 确保安装了 ripgrep
const rgPath = await ensureTool("rg", true);
if (!rgPath) throw new Error("ripgrep (rg) is not available");
const searchPath = resolveToCwd(searchDir || ".", cwd);
const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
// 构建 rg 命令参数
const args = ["--line-number", "--color=never", "--no-heading"];
if (ignoreCase) args.push("-i");
if (literal) args.push("--fixed-strings");
if (context) args.push(`--context=${context}`);
if (glob) args.push("--glob", glob);
args.push("--max-count", String(effectiveLimit));
args.push(pattern, searchPath);
// 执行 rg,处理输出
const result = await execFile(rgPath, args, { signal });
const matches = parseGrepOutput(result.stdout, searchPath);
return {
content: [{ type: "text", text: formatMatches(matches) }],
details: { matchCount: matches.length, truncated: matches.length >= effectiveLimit },
};
}
grep 的设计亮点:
- 依赖 ripgrep :
ensureTool("rg", true)自动下载安装 ripgrep。不用 Node.js 实现正则搜索------ripgrep 性能远超任何 JS 实现,且支持 .gitignore。 --max-count+limit:服务端限制 + 客户端限制双重保护,防止超大仓库的搜索结果撑爆 context。
5. find------查找文件
typescript
// coding-agent/src/core/tools/find.ts:20-25
const findSchema = Type.Object({
pattern: Type.String({ description: "Glob pattern to match files (e.g. '*.ts', '**/*.md')" }),
path: Type.Optional(Type.String({ description: "Directory to search in (default: current directory)" }),
limit: Type.Optional(Type.Number({ description: "Maximum number of results (default: 1000)" })),
});
typescript
// find.ts:120-179(简化)
async execute(_toolCallId, { pattern, path: searchDir, limit }, signal?) {
const searchPath = resolveToCwd(searchDir || ".", cwd);
const effectiveLimit = limit ?? DEFAULT_LIMIT;
// 如果 customOps 提供了 glob(),用它;否则用 fd 命令
if (customOps?.glob) {
const results = await customOps.glob(pattern, searchPath, {
ignore: ["**/node_modules/**", "**/.git/**"],
limit: effectiveLimit,
});
return {
content: [{ type: "text", text: results.length ? results.join("\n") : "No files found matching pattern" }],
details: { matchCount: results.length },
};
}
// 用 fd 命令(比 find 快,支持 .gitignore)
const fdPath = await ensureTool("fd", true);
const args = [pattern, searchPath, "--type", "f", "--max-results", String(effectiveLimit)];
const result = await execFile(fdPath, args, { signal });
const files = result.stdout.trim().split("\n").filter(Boolean);
return {
content: [{ type: "text", text: files.length ? files.join("\n") : "No files found matching pattern" }],
details: { matchCount: files.length },
};
}
find 和 grep 类似------优先用 fd 命令(比 find 快,默认尊重 .gitignore),但也支持注入 customOps.glob 走纯 JS 实现(用于无 fd 的环境)。
6. ls------列目录
typescript
// coding-agent/src/core/tools/ls.ts:14-17
const lsSchema = Type.Object({
path: Type.Optional(Type.String({ description: "Directory to list (default: current directory)" })),
limit: Type.Optional(Type.Number({ description: "Maximum number of entries to return (default: 500)" })),
});
typescript
// ls.ts:106-165(简化)
async execute(_toolCallId, { path, limit }, signal?) {
const dirPath = resolveToCwd(path || ".", cwd);
const effectiveLimit = limit ?? DEFAULT_LIMIT;
// 检查存在 + 是否目录
if (!(await ops.exists(dirPath))) throw new Error(`Path not found: ${dirPath}`);
const stat = await ops.stat(dirPath);
if (!stat.isDirectory()) throw new Error(`Not a directory: ${dirPath}`);
// 读目录条目
let entries = await ops.readdir(dirPath);
entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); // 字母序
// 格式化------目录加 / 后缀
const results: string[] = [];
for (const entry of entries) {
if (results.length >= effectiveLimit) break;
const entryStat = await ops.stat(nodePath.join(dirPath, entry));
results.push(entryStat.isDirectory() ? `${entry}/` : entry);
}
return {
content: [{ type: "text", text: results.join("\n") }],
details: { entryCount: results.length, truncated: entries.length > effectiveLimit },
};
}
ls 最简单------读目录 + 排序 + 加目录标记。没有外部命令依赖,纯 Node.js fs API。details.truncated 告诉 UI 是否有更多条目没显示。
工具对比
| 工具 | 外部依赖 | 互斥锁 | 流式更新 | details 内容 |
|---|---|---|---|---|
| read | 无 | 无 | 无 | truncation |
| bash | spawn | 无 | 有(节流) | exitCode / truncation / fullOutputPath |
| edit | 无 | 文件级 | 无 | diff / patch / firstChangedLine |
| write | 无 | 文件级 | 无 | 无 |
| grep | ripgrep | 无 | 无 | matchCount / truncated |
| find | fd(可选) | 无 | 无 | matchCount |
| ls | 无 | 无 | 无 | entryCount / truncated |
六、Q&A
Q1:coding tools 和 read-only tools 有什么区别?
能不能改文件系统,这是唯一的分界线。
| 集合 | 工具 | 能改文件系统? |
|---|---|---|
| coding tools | read / bash / edit / write |
能(bash 可执行任意命令,edit/write 直接改文件) |
| read-only tools | read / grep / find / ls |
不能(全是只读操作) |
两组共享 read(读文件本身是只读的)。差异在另外三个工具:coding tools 有 bash / edit / write(能改),read-only tools 有 grep / find / ls(只读)。
为什么分两组:pi 的 createCodingTools / createReadOnlyTools 是两个工厂函数(tools/index.ts:168-184),让 agent 能在运行时切换工具集。典型场景:
- coding 模式 :用
createCodingTools,agent 能读写文件、执行命令------完整编码能力 - read-only / 审查模式 :用
createReadOnlyTools,agent 只能看不能改------代码审查、安全审计、受限环境
AgentHarness.setTools 可以在 agent 运行时动态切换这两组工具,不需要重启。Extension API 也可以根据上下文自动切换(如检测到生产环境时降级为 read-only)。
Q2:如何注册一个新的 tool?
三条路径,按复杂度递增。
1. 直接塞进 context.tools 数组
最简单------实现 AgentTool 接口,塞进 context:
typescript
const myTool: AgentTool = {
name: "search_web",
label: "Search Web",
description: "Search the web and return results",
parameters: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
async execute(toolCallId, params) {
const results = await fetch(...);
return {
content: [{ type: "text", text: JSON.stringify(results) }],
details: { query: params.query },
};
},
};
// 启动 agent 时传入
const context: AgentContext = {
systemPrompt: "...",
messages: [...],
tools: [readTool, bashTool, myTool], // 加在这里
};
agent loop 自动把它转成 OpenAI function schema 发给 LLM。LLM 决定调用 search_web 时,executeToolCalls 查到这个工具、校验参数、调 execute。
2. 通过 AgentHarness.setTools 动态注入
agent 运行中也能换工具集:
typescript
harness.setTools([readTool, bashTool, myTool]);
如果 agent 正忙(phase !== "idle"),变更延迟到当前任务完成后生效。
3. 通过 Extension API 永久注入
前两种是代码层面的。Extension API 让你在 .pi/extensions/ 目录下声明一个扩展,扩展注册的工具对用户来说是"内置"的------不需要改源码,不需要改启动配置。这是 pi "扩展无需 fork" 的核心。
关键约束:
name必须全局唯一------Harness 用Map<string, AgentTool>持有,重名会覆盖,构造时validateUniqueNames会抛错parameters必须是合法 JSON Schema------LLM 根据它生成参数,validateToolArguments用它校验execute抛异常会被executePreparedToolCall的 try-catch 接住,转成isError: true的AgentToolResult------不会中断循环
七、下一章预告
下一篇文章将进入 pi 的 hook 机制------beforeToolCall / afterToolCall 如何拦截和修改工具执行、prepareNextTurn / shouldStopAfterTurn 如何控制循环走向、getSteeringMessages / getFollowUpMessages 如何实现用户与 agent 的实时交互。这些 hook 是 pi "扩展无需 fork" 理念的落地机制,让外部代码能在不改动 agent loop 源码的前提下,改变 agent 的行为。