从零开始拆解Pi系列——(4)工具体系

一、引言:工具的作用

上一篇文章拆解了 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 的工具体系跨三层,每一层加一点能力:

classDiagram class Tool { +name: string +description: string +parameters: TSchema } class AgentTool { +label: string +prepareArguments?(args) +execute(toolCallId, params, signal?, onUpdate?) +executionMode?: &#34;sequential&#34; | &#34;parallel&#34; } class AgentToolResult { +content: (TextContent | ImageContent)[] +details: T +terminate?: boolean } class AgentToolCall { +type: &#34;toolCall&#34; +id: string +name: string +arguments: Record } class AgentContext { +systemPrompt: string +messages: AgentMessage[] +tools?: AgentTool[] } Tool <|-- AgentTool AgentContext o-- AgentTool : tools AgentTool ..> AgentToolResult : execute returns AgentTool ..> AgentToolCall : handles class ReadTool { +name = &#34;read&#34; +execute(file_path, offset?, limit?) } class BashTool { +name = &#34;bash&#34; +execute(command, timeout?) } class EditTool { +name = &#34;edit&#34; +execute(file_path, old_string, new_string) } class WriteTool { +name = &#34;write&#34; +execute(file_path, content) } AgentTool <|-- ReadTool AgentTool <|-- BashTool AgentTool <|-- EditTool AgentTool <|-- WriteTool

ai 层:Toolpackages/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 Toolpackages/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";               // 执行策略
}

四个新增字段的设计意图:

  • labelname 给 LLM 看("read"),label 给人看("Read File")。分离是因为 LLM 更适合短标识符,UI 需要可读名称。
  • prepareArguments:LLM 生成的参数可能不完美(字段名不匹配、类型不对)。这个钩子在 schema 校验前做一次预处理,兼容 LLM 的"粗心"。
  • execute :核心。签名是 (toolCallId, params, signal?, onUpdate?) → Promise<AgentToolResult>onUpdate 让工具能流式推送中间状态(如 bash 的实时输出),UI 不用等执行完才显示。
  • executionMode :标记这个工具能不能和其他工具并行执行。bashedit 通常 sequential(避免冲突),grepfind 可以 parallel

agent 层:AgentToolResultpackages/agent/src/types.ts:345

typescript 复制代码
interface AgentToolResult<T> {
  content: (TextContent | ImageContent)[];  // 给 LLM 看的(回填到 toolResult message)
  details: T;                               // 给 UI / 日志看的(结构化数据)
  terminate?: boolean;                      // 提前终止提示
}

contentdetails 的分离是关键设计:content 是 LLM 能理解的文本/图片,details 是结构化数据(如文件路径、命令退出码),UI 可以用它渲染富展示。两者各走各的路,互不干扰。

agent 层:AgentToolCallpackages/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 / WriteToolimplements AgentTool。第 4 章以 ReadTool 为例展开,第 5 章讲其余三个。

2. 工具注册

pi 的 AgentHarnessMap<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(收尾)。这不是过度设计------每一段都有独立的职责,且支持对应的钩子。我们用搭积木方式逐层加。

完整执行链的流程如下:

flowchart TD A([executeToolCalls 入口]) --> B{有 sequential 工具?} B -->|是| C[executeToolCallsSequential] B -->|否| D[executeToolCallsParallel] C --> E[for each toolCall] D --> E E --> F[emit tool_execution_start] F --> G[prepareToolCall] G --> H{kind?} H -->|immediate<br/>工具不存在/参数无效/被拦截| I[跳过 execute + finalize] H -->|prepared<br/>校验通过| J[executePreparedToolCall] J --> K[tool.execute] K --> L{执行成功?} L -->|是| M[result + isError=false] L -->|否| N[errorToolResult + isError=true] M --> O[finalizeExecutedToolCall] N --> O I --> O O --> P{afterToolCall?} P -->|有| Q[钩子合并结果<br/>content/details/terminate/isError 字段级覆盖] P -->|无| R[保留原结果] Q --> S[emit tool_execution_end] R --> S S --> T[构造 ToolResultMessage] T --> U[emit message_start / message_end] U --> V{还有 toolCall?} V -->|是| E V -->|否| W([返回 messages + terminate])

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 钩子

prepareToolCallagent-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 流式更新

executePreparedToolCallagent-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 钩子合并结果

finalizeExecutedToolCallagent-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 做脱敏,保留 detailsisError)。

典型用途:敏感信息脱敏(把 content 里的密码替换成 ***)、统一日志(记录每次工具调用的 args 和 result)、错误重试(isError 为 true 时换一种方式重试)。

5. 加 parallel:并发执行

executeToolCallsParallelagent-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 本身是无副作用的纯计算(参数已经校验过),并发安全。

executeToolCallsagent-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 工具结构

classDiagram class ReadSchema { +path: string +offset?: number +limit?: number } class ReadToolDefinition { +name = &#34;read&#34; +label = &#34;read&#34; +description: &#34;Read the contents of a file...&#34; +parameters: readSchema +execute(toolCallId, params, signal?, onUpdate?, ctx?) +renderCall(args, theme, context) +renderResult(result, options, theme, context) } class ReadOperations { <<interface>> +readFile(path): Promise~Buffer~ +access(path): Promise~void~ +detectImageMimeType?(path): Promise~string~ } class ReadToolDetails { +truncation?: TruncationResult } class TruncationResult { +content: string +truncated: boolean +truncatedBy: &#34;lines&#34; | &#34;bytes&#34; | null +totalLines: number +outputLines: number +firstLineExceedsLimit: boolean +maxLines: number +maxBytes: number } ReadToolDefinition --> ReadSchema : parameters ReadToolDefinition --> ReadOperations : ops ReadToolDefinition ..> ReadToolDetails : details ReadToolDetails --> TruncationResult : truncation

read 工具的组成:schema 定义参数、ReadOperations 抽象文件 IO(可替换为 SSH 等远程实现)、TruncationResult 记录截断信息。renderCall / renderResult 是 TUI 渲染钩子,不属于 agent loop 执行链,这里不展开。

0.1 execute 内部流程

flowchart TD A([execute 入口]) --> B[解析路径 resolveReadPathAsync] B --> C[检查可读 ops.access] C --> D{abort?} D -->|是| E([reject aborted]) D -->|否| F[检测 MIME 类型] F --> G{是图片?} G -->|是| H[读文件 ops.readFile] H --> I{autoResizeImages?} I -->|是| J[resizeImage 缩放到 2000x2000] I -->|否| K[base64 原图] J --> L[构造 TextContent + ImageContent] K --> L L --> M([resolve result]) G -->|否| N[读文件 ops.readFile] N --> O[split 为行数组] O --> P[应用 offset 切片] P --> Q[应用 limit 切片] Q --> R[truncateHead 截断] R --> S{firstLineExceedsLimit?} S -->|是| T[建议用 bash sed 读取] S -->|否| U{truncated?} U -->|是| V[附加 continue 提示<br/>Use offset=N to continue] U -->|否| W[保留原文] T --> X[构造 TextContent] V --> X W --> X X --> M

两条路径在 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 读到这段描述后,遇到大文件会主动分段调用。

opsReadOperations 接口,默认用本地 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 }];

核心逻辑三层:

  1. 路径解析 + 权限检查resolveReadPathAsync 把相对路径转绝对路径,ops.access 检查文件可读。
  2. offset/limit 切片:LLM 通过 offset/limit 精准读取文件的某一段。这两个参数让 LLM 能"翻页"大文件。
  3. 截断 :即使有 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 钩子,finalizeExecutedToolCallafterToolCall 钩子合并结果。差异全在 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。
  • spawnHookBashSpawnContext 允许外部注入 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 文件锁。detailsundefined------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 的设计亮点:

  • 依赖 ripgrepensureTool("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: trueAgentToolResult------不会中断循环

七、下一章预告

下一篇文章将进入 pi 的 hook 机制------beforeToolCall / afterToolCall 如何拦截和修改工具执行、prepareNextTurn / shouldStopAfterTurn 如何控制循环走向、getSteeringMessages / getFollowUpMessages 如何实现用户与 agent 的实时交互。这些 hook 是 pi "扩展无需 fork" 理念的落地机制,让外部代码能在不改动 agent loop 源码的前提下,改变 agent 的行为。

相关推荐
Rubin智造社1 小时前
字节AI生产力大整合:TRAE、扣子并入豆包,「豆包工作」登场,工作方式正在改写
agent·trae·ai生产力·豆包工作·扣子coze
IvanCodes2 小时前
RAG 实战教程(四):GraphRAG 查询实战,本地检索、全局检索与 DRIFT Search
人工智能·agent
CoovallyAIHub2 小时前
客户问交期、报价、配置?销售最值钱的时间不该花在翻资料上,这中间差了一张客户信息拼图
agent·数据可视化
小七-七牛开发者2 小时前
拆解 DeepSeek Harness:Profile 与 Bundle 如何装配运行时
ai·大模型·agent·token·工作流·claudecode·ai coding
NineData2 小时前
DTCC 2026 NineData 叶正盛:如何统一管理人与 AI Agent 的数据访问行为
数据库·人工智能·sql·oracle·agent·ninedata·dtcc
路多辛3 小时前
全能型 Go Agent 框架 covonaut v1.0.8 发布:新增行内补全与多后端可观测性
开发语言·golang·agent
Csvn3 小时前
没有评测,你就不敢改任何东西——AI 应用评测第一课(E01)
aigc·agent·ai编程
Csvn3 小时前
从 POC 到上线,AI 应用差的不只是代码——30 篇《AI 应用生产化手册》免费连载
aigc·agent·ai编程