读懂 Pi,你就是 AI 应用之王
逐段解析 pi 的 agent-loop.ts,理解双层循环、工具调用、流式响应和事件广播等生产级机制。
之前我们亲手写了 100 行的 mini-pi,理解了 agent 循环的骨架。这一部分我们不再写新代码——直接打开 pi 的 agent-loop.ts[1],逐段读它的生产级实现。
你会发现你在 Part 1 写的每一行都有对应物,但 pi 在每个关键位置都多了一层保护。这些”多出来的”不是过度设计——它们是被真实用户的真实 bug 逼出来的。
这 743 行只做一件事
在拆任何零件之前,先理解整体:agent-loop.ts 就是一个”对话推进器”——它反复执行”问模型→执行工具→把结果喂回去→再问模型”这个循环,直到模型说”我没有工具要调了”为止。
它面对的所有复杂性,都来自三个”如果”:
- 如果模型瞎编怎么办? → 三道关卡(prepareToolCall)在执行前拦截
- 如果用户中途插话怎么办? → steering 队列让用户随时注入新指令
- 如果模型说完了但还有活儿怎么办? → follow-up 队列把 agent 拉回来继续
其他所有东西——并行执行、流式响应、abort 信号、afterToolCall 改写、prepareNextTurn 换模型——都是在这个基本循环上的保护层和优化层。不是 8 个并列的模块,而是一个洋葱:
1 | 最内层:while 循环(问模型 → 执行工具 → 喂回去) ← Part 1 你写的 mini-pi第二层:三道关卡 + 错误自愈 ← 防止模型瞎编第三层:steering + follow-up ← 允许人类介入第四层:流式 + 并行 + abort ← 性能和体验优化最外层:prepareNextTurn + shouldStopAfterTurn ← 生命周期管理 |
从内往外读: 每一层都是在前一层”够用但不够好”的基础上加的。你在 Part 1 写的 mini-pi 就是最内层——能跑,但模型瞎编你没辙、用户插不上话、工具一个一个串行等。pi 的 743 行就是把这四层保护一层一层包上去。
一次完整迭代的全景图
带着”洋葱”的心智模型,再看一轮完整的循环经过了哪些步骤——你会发现每个方框都属于某一层:
每个方框对应源码中一个明确的函数或代码段。下面我们按执行顺序逐个拆解。
1. runLoop:双层 while 循环(第 155–269 行)
你在 mini-pi 里写的是一个 while (true)。pi 写了两层:
1 | // 第 170 行起// pendingMessages —— 等待注入的消息(用户中途打的字)// 循环开始前先摸一次队列:用户可能在你启动 agent 的那一瞬间就打了字let pendingMessages = (await config.getSteeringMessages?.()) || [];// getSteeringMessages() 就是去摸"用户输入队列"——有就取出来,没有就返回空数组// ═══ 外层循环 ═══// 职责:处理 follow-up(agent 打算散会时追加的任务)while (true) { // hasMoreToolCalls —— 内层循环用这个变量判断"模型是不是还在调工具" // 初始设为 true 是为了让内层循环至少跑一次 let hasMoreToolCalls = true; // ═══ 内层循环 ═══ // 职责:处理 tool calls + steering(用户中途插话) // 条件:只要模型还在调工具 OR 有用户插话,就继续转 while (hasMoreToolCalls || pendingMessages.length > 0) { // ... 一轮完整的流程: // 1. 把 pendingMessages 注入 context(模型下次调用就能看到) // 2. 调 LLM(streamAssistantResponse) // 3. 检查返回的消息里有没有 toolCall // 4. 有 → 执行工具: // - 如果所有工具都返回 terminate: true → hasMoreToolCalls = false(虽然有工具调用,但主动要求停) // - 否则 → hasMoreToolCalls = true → 循环继续 // 没有 → hasMoreToolCalls = false // 5. 再摸一次 steering 队列 → 有新消息的话 pendingMessages 不为空 → 循环继续 pendingMessages = (await config.getSteeringMessages?.()) || []; } // 到这里说明内层循环结束了——没有 tool calls,也没有 pending messages // 最后检查:有没有 follow-up 消息? // getFollowUpMessages() 和 getSteeringMessages() 是两个不同的队列 const followUpMessages = (await config.getFollowUpMessages?.()) || []; if (followUpMessages.length > 0) { // 有后续任务 → 当作 pendingMessages 注入 → 重新进入内层循环 pendingMessages = followUpMessages; continue; } break; // 两个队列都空了,真的没事了,散会} |
为什么一层不够? 因为有两种”继续”的理由,生命周期不同。在说理由之前,先解释两个你会在源码注释里频繁看到的英文词:
- Steering(转向) = 用户在 agent 干活中途打进来的话。就像开车时扭方向盘——agent 正在读文件,你突然说”别管 test/ 目录”,这句话就叫 steering 消息。
- Follow-up(后续) = agent 本来已经要收工了,但有人递了张纸条说”还有活儿”——不用重启,接着干。
现在看两层循环各管什么:
| 内层循环 | 外层循环 | |
|---|---|---|
| 继续的理由 | 模型还在调工具 / 用户中途插了话(steering) | agent 本来要散会,但有后续任务(follow-up) |
| 什么时候检查 | 每轮 tool execution 之后 | 内层循环彻底结束之后 |
| 类比 | “资本家还在发指令,或有人凑过来说了一句” | “资本家说完了,但突然有人递了张纸条” |
内层条件 hasMoreToolCalls || pendingMessages.length > 0 的意思是:只要模型还在调工具(hasMoreToolCalls),或者用户在中途插了话(pendingMessages),就继续转。只留 hasMoreToolCalls 会丢掉”用户中途插话”的能力。
hasMoreToolCalls 的赋值比你想的更微妙。 看实际代码(第 206–210 行):
1 | hasMoreToolCalls = false; // 先假设没有if (toolCalls.length > 0) { const executedToolBatch = await executeToolCalls(...); toolResults.push(...executedToolBatch.messages); hasMoreToolCalls = !executedToolBatch.terminate; // 关键:有工具调用,但全部 terminate → 仍然是 false} |
不是”有 tool call → hasMoreToolCalls = true”这么简单。如果一批工具调用里所有工具都返回了 terminate: true,executedToolBatch.terminate 为 true,hasMoreToolCalls 就是 false——虽然模型确实调了工具,但工具们主动要求”到此为止”。
对比你的 mini-pi:
1 | // mini-pi:只有一层,只看 stopReasonwhile (true) { ... if (message.stopReason !== "toolUse") break; ...} |
你的版本在 stopReason !== "toolUse" 时立刻散会——中途没法插话,散会后也没法续命。pi 的双层结构让两种”继续”各有自己的入口。
2. prepareToolCall:执行之前的三道关卡(第 562–626 行)
mini-pi 里工具执行就一行:runTool(call.name, call.arguments)——信任模型给的一切。pi 在执行之前过了三道关。
prepareToolCall 返回两种结果,用 kind 字段区分:
1 | // 情况 1:关卡没过——结果立刻就有了(错误消息),不需要真正执行工具{ kind: "immediate", result: createErrorToolResult("Tool xxx not found"), isError: true }// 情况 2:三关全过——准备好了,等着后面真正执行 tool.execute(){ kind: "prepared", toolCall, tool, args: validatedArgs } |
immediate = “立即有结果”(跳过执行),prepared = “准备好了”(等待执行)。下游代码根据 kind 分流:
1 | const preparation = await prepareToolCall(...);if (preparation.kind === "immediate") { // 已经有结果了(错误)→ 直接用,跳过执行 finalized = preparation;} else { // kind === "prepared" → 真正去调 tool.execute() const executed = await executePreparedToolCall(preparation, signal, emit); ...} |
现在看图里的两条路径——左边三个 ❌ 都走 immediate(当场返回错误),只有右边 ✓ 走 prepared(进入真正的执行阶段)。
先看代码里出现的几个变量长什么样——这些是真实运行时的数据,不是抽象概念:
1 | // toolCall —— 模型生成的一次工具调用请求(从 assistant 消息的 content 里取出来的){ type: "toolCall", id: "toolu_01XFDUDYJgAACzvnptvVer6C", // API 生成的唯一 ID name: "read_file", // 模型想调哪个工具 arguments: { path: "package.json" }, // 模型生成的参数}// tool —— 你注册的工具声明(从 context.tools 数组里 find 出来的)// 工具怎么注册的?→ 见 Part 6"给资本家添工具:registerTool"(part-06.md#给资本家添工具registertoolpart-1-的线在这里合龙){ name: "read_file", label: "Read File", description: "读取项目里一个文本文件的完整内容。", parameters: { // TypeBox 生成的 JSON Schema type: "object", properties: { path: { type: "string", description: "..." } }, required: ["path"], }, execute: async (id, args, signal) => { ... }, // 真正干活的函数}// validatedArgs —— 校验(可能还转换过类型)之后的参数{ path: "package.json" } // 和 toolCall.arguments 可能一样,也可能 "5000" 被转成了 5000 |
现在看 prepareToolCall 拿这些数据过三道关:
1 | async function prepareToolCall(currentContext, assistantMessage, toolCall, config, signal) { // 关卡 1:工具存在吗? // 用 toolCall.name("read_file")去 context.tools 数组里找 const tool = currentContext.tools?.find((t) => t.name === toolCall.name); if (!tool) { // 模型编了个不存在的工具名 → 回报"查无此工具" return { kind: "immediate", result: createErrorToolResult(`Tool ${toolCall.name} not found`), isError: true }; } // 例:toolCall.name 是 "read_file",找到了上面那个 tool 对象 ✓ // 关卡 2:参数合法吗? // 2a. prepareToolCallArguments —— 工具自己的预处理(大多数工具没有,直通) // 如果工具定义了 prepareArguments 函数,先让它修正参数。 // 比如:路径规范化 "./src/../src/main.ts" → "src/main.ts" // 没定义的话?原样返回 toolCall,什么都不做。 const preparedToolCall = prepareToolCallArguments(tool, toolCall); // 2b. validateToolArguments —— 用 TypeBox schema 严格校验 // 内部两步:Value.Convert(宽容转换:"42"→42)+ validator.Check(严格校验) // 通过 → 返回校验后的参数 // 不通过 → 抛异常,外层 catch 住变成错误回报 const validatedArgs = validateToolArguments(tool, preparedToolCall); // 例:schema 说 path 必须是 string,arguments.path 是 "package.json" ✓ // 如果模型传了 { path: 123 } → 校验失败 → 抛异常 → 被 catch 变成错误回报 // 关卡 3:策略允许吗?(beforeToolCall 钩子) if (config.beforeToolCall) { const beforeResult = await config.beforeToolCall( { assistantMessage, toolCall, args: validatedArgs, context: currentContext }, signal, ); if (beforeResult?.block) { return { kind: "immediate", result: createErrorToolResult(beforeResult.reason || "Tool execution was blocked"), isError: true }; } } // 三关全过 → 返回 "prepared",让后面真正执行 return { kind: "prepared", toolCall, tool, args: validatedArgs };} |
关卡 3 里 beforeToolCall 收到了什么、做了什么?
它收到的参数是一个对象,包含做决策所需的全部信息:
1 | { assistantMessage, // 模型的完整回复(万一你想看它说了什么话再决定) toolCall, // 原始的工具调用请求(name + id + arguments) args: validatedArgs,// 已经校验过的干净参数(你该看这个而不是 toolCall.arguments) context, // 当前整个对话状态(所有历史消息、所有工具列表)} |
钩子函数拿到这些信息之后做一个判断:这次调用允不允许? 返回值只有两种:
- 返回
undefined或{ block: false }→ 放行,继续执行 - 返回
{ block: true, reason: "不允许读 .env" }→ 拦住,reason 会被包装成错误回报喂给模型
策略规则在哪里定义的?
不在 agent-loop.ts 里。 循环只提供”这里可以拦”的插入点(beforeToolCall 钩子),具体拦什么由上层决定。
最简单的用法——直接写 if/else:
1 | agent.beforeToolCall = async ({ toolCall, args }) => { if (toolCall.name === "bash" && args.command.includes("rm -rf")) { return { block: true, reason: "不让删" }; } return undefined; // 放行}; |
pi 的产品没有这么直接写——它用了一个扩展系统,让用户往 .pi/extensions/ 目录放插件文件来定义拦截规则。扩展系统的完整实现(插件怎么加载、pi.on() 背后是什么、extension/runtime 对象的内部结构、jiti 动态加载、两阶段时间线)见 Part 6 — 扩展系统的内部实现[2]。
这里你只需要知道一件事:beforeToolCall 就是一个函数,谁提供它、内部怎么判断,agent-loop 不关心。
返回值为什么是 toolCall 而不是 preparedToolCall?
最后一行 return { kind: "prepared", toolCall, tool, args: validatedArgs } 返回了原始的 toolCall 而不是处理过的 preparedToolCall。原因是:
- 参数已经被
validatedArgs取代了 — 后续执行工具用的是args,不会再碰toolCall.arguments toolCall只是带着id和name往下传 — 下游需要toolCall.id来构造toolResult消息(对应”这是对你哪条指令的答复”)toolCall和preparedToolCall的id/name完全一样,只有arguments不同 — 而 arguments 已经不用了
简单说:toolCall 在这里是个”身份证”(带 id),不是”参数来源”。参数来源是独立的 args 字段。
三道关卡各管一件事:
| 关卡 | 问的问题 | 失败时 |
|---|---|---|
| 1. 工具存在 | 模型是不是在编工具名? | 回报”查无此工具” |
| 2. 参数校验 | 参数格式对吗?类型对吗? | 回报”参数不合格 + 具体哪里错” |
| 3. 策略钩子 | 这个操作允不允许? | 回报”被拦截 + 原因” |
关键设计:三道关卡全都不抛异常到外层——它们返回 kind: "immediate" 带着错误结果。 调用方把这个结果包装成 toolResult 喂回模型。模型收到后通常会自愈:换个工具名、修正参数、或者换个策略。
validateToolArguments 做了什么
这个函数住在 validation.ts 第 292 行[3],做三件事:
- 深拷贝参数 — 不动原对象,避免后面的修改影响到调用方
- 宽容转换 — 用
Value.Convert把模型的小毛病修正掉(比如把"42"转成42) - 严格校验 — 转换完之后再检查格式是否合规,不合规就抛异常
1 | export function validateToolArguments(tool, toolCall) { const args = structuredClone(toolCall.arguments); Value.Convert(tool.parameters, args); const validator = getValidator(tool.parameters); if (validator.Check(args)) { return args; } const errors = validator.Errors(args).map(...).join("\n"); throw new Error(`Validation failed for tool "${toolCall.name}":\n${errors}`);} |
structuredClone — JavaScript 内置的深拷贝函数。为什么要拷贝?因为下一步 Value.Convert 会直接修改对象内容(把 "42" 改成 42)。如果不拷贝,调用方那边的原始数据也跟着变了。
Value.Convert(tool.parameters, args) — TypeBox 的类型强制转换。两个参数的角色:
tool.parameters= 规则(schema),描述每个字段应该是什么类型args= 数据(模型传来的参数),可能有类型错误
Convert 做的事:按照规则逐字段检查数据,能转就转,转不了就不动(不报错)。它直接修改 args 对象本身,不是返回新对象——这就是为什么前面要先 structuredClone 拷贝。
Value.Convert 的两个参数长什么样——给一个具体例子:
1 | // 第一个参数 tool.parameters(规则)——TypeBox 生成的 JSON Schema:{ type: "object", properties: { path: { type: "string" }, timeout: { type: "number" }, recursive: { type: "boolean" }, }, required: ["path", "timeout"]}// 第二个参数 args(数据)——模型传来的参数,有毛病:{ path: "main.ts", timeout: "5000", recursive: "true" }// ^^^^^^ ^^^^^^// 应该是数字但写成了字符串 应该是布尔但写成了字符串// 执行 Value.Convert(tool.parameters, args) 后,args 被直接修改成:{ path: "main.ts", timeout: 5000, recursive: true }// ^^^^ ^^^^// 变成数字了 变成布尔了 |
Convert 逐字段对比规则和数据:
path:规则说 string,实际是 string → 不动timeout:规则说 number,实际是"5000"→ 转成5000recursive:规则说 boolean,实际是"true"→ 转成true
validator.Check(args) — 真正的格式校验。Convert 之后如果参数还是不合格(比如必填字段缺失),这里会返回 false,然后下面收集所有错误信息抛出异常。
完整走一遍三步——场景 1:模型把数字写成了字符串(常见的小毛病):
1 | // 进来的数据:toolCall.arguments = { path: "src/main.ts", timeout: "5000" }// 第 1 步:structuredClone → 拷贝一份,不动原对象args = { path: "src/main.ts", timeout: "5000" } // 新对象// 第 2 步:Value.Convert(tool.parameters, args)// schema 说 timeout 应该是 number,args.timeout 是 "5000"// → +"5000" = 5000 → 直接把 args.timeout 改成 5000args = { path: "src/main.ts", timeout: 5000 } // "5000" 变成了 5000// 第 3 步:validator.Check(args)// path 是 string ✓,timeout 是 number ✓,两个 required 都在 ✓// → 返回 true → 校验通过 → 返回 args |
场景 2:模型漏掉了必填字段(真正的错误):
1 | // 进来的数据:toolCall.arguments = {} // path 和 timeout 都没有!// 第 1 步:structuredCloneargs = {}// 第 2 步:Value.Convert(tool.parameters, args)// 没有字段可转 → args 还是 {}// 第 3 步:validator.Check(args)// path 是 required 但不存在 → 返回 false// → 收集错误 → 抛出异常:// "Validation failed for tool "read_file":// - /path: Required property// - /timeout: Required property// Received arguments: {}"// → 异常被外层 catch → 变成 isError: true 的回报喂回模型// → 模型看到"参数缺了"→ 自己重新生成一份带 path 的参数 |
设计哲学:先宽容(Convert),再严格(Check)。 容忍模型的小毛病(类型写错),不放过真正的错误(字段缺失)。
注意: 上面是简化版本。实际代码在 Convert 和 Check 之间还有一层 JSON Schema 兼容处理(
coerceWithJsonSchema)——用于不带 TypeBox metadata 的 plain JSON Schema 工具定义。如果你只用 TypeBox 定义工具参数(pi 的标准做法),这段逻辑不会触发。感兴趣的话看 validation.ts 第 297–309 行[3]。
beforeToolCall 钩子的用途
这就是 Claude Code 里”要执行 rm -rf,允许吗?”弹窗背后的机制。钩子拿到完整上下文(哪个工具、什么参数、当前对话),返回 { block: true, reason: "..." } 就拦住执行。
模型不知道有这个钩子存在——它只看到一条 toolResult 说”操作被拒,原因是 X”。对模型来说这和”文件不存在”没区别——都是带 isError: true 的回报,它会根据回报调整策略。
3. executeToolCallsParallel:并行执行(第 451–516 行)
1 | 流程:并行执行的三个阶段阶段1 准备(顺序): prepareToolCall A → B → C阶段2 执行(并发): execute A | execute B | execute C阶段3 结果(按原序): toolResult A → toolResult B → toolResult C |
模型可以在一条消息里同时发出多个工具调用。比如模型的一条 assistant 消息长这样:
1 | // 模型返回的 message.content(一条消息里三个工具调用)[ { type: "text", text: "我来同时查看这三个文件:" }, { type: "toolCall", id: "toolu_01A", name: "list_files", arguments: { path: "." } }, { type: "toolCall", id: "toolu_01B", name: "read_file", arguments: { path: "README.md" } }, { type: "toolCall", id: "toolu_01C", name: "read_file", arguments: { path: "package.json" } },] |
三个 toolCall 挤在一起——mini-pi 用 for 循环逐个执行;pi 默认并行。
但不是无脑 Promise.all——有个微妙的先后关系:
1 | async function executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, signal, emit) { // finalizedCalls 这个数组最终会装两种东西: // - 已经有结果的(被拦截的):直接是一个结果对象 // - 还没执行的(通过了三关的):一个 async 函数,调它才真正执行 const finalizedCalls: FinalizedToolCallEntry[] = []; // ═══ 阶段 1:准备——逐个过三道关卡 ═══ // 为什么这里是 for 循环(顺序)而不是并发? // 因为 beforeToolCall 钩子可能有状态——比如"同一批次只允许一个写操作" for (const toolCall of toolCalls) { // 通知外面"我要开始处理这个工具了" await emit({ type: "tool_execution_start", toolCallId: toolCall.id, toolName: toolCall.name, args: toolCall.arguments }); // 过三道关卡(工具存在?参数合法?策略允许?) const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal); if (preparation.kind === "immediate") { // "immediate" = 三道关卡里被拦住了,已经有错误结果了 // 不需要真正执行,直接记录这个错误结果 finalizedCalls.push(preparation); continue; } // "prepared" = 三关全过,可以执行 // 但不是现在执行——包成一个函数,等阶段 2 并发调用 finalizedCalls.push(async () => { // executePreparedToolCall = 真正调 tool.execute(),读文件/跑命令 const executed = await executePreparedToolCall(preparation, signal, emit); // finalizeExecutedToolCall = 跑 afterToolCall 钩子(可选的结果改写) return finalizeExecutedToolCall(currentContext, assistantMessage, preparation, executed, config, signal); }); } // ═══ 阶段 2:执行——并发 ═══ // 数组里有两种元素:对象(已有结果)和函数(需要执行) // Promise.all 同时调用所有函数,等全部完成 // 关键:Promise.all 保持数组顺序——第 0 个 toolCall 的结果一定在第 0 位 const orderedFinalizedCalls = await Promise.all( finalizedCalls.map((entry) => typeof entry === "function" ? entry() // 是函数 → 调用它,并发执行 : Promise.resolve(entry) // 是对象 → 已经有结果了,直接用 ) ); // 此时三个工具(list_files、read_file×2)全部执行完毕 // ═══ 阶段 3:把结果按原始顺序推回对话 ═══ // 即使 read_file("package.json") 比 list_files 先完成, // 结果数组里 list_files 仍然排第一——保证对话历史稳定 for (const finalized of orderedFinalizedCalls) { // 把工具执行结果包装成 toolResult 消息 const toolResultMessage = createToolResultMessage(finalized); // 通知外面"这个工具有结果了"+ 推进对话历史 await emitToolResultMessage(toolResultMessage, emit); messages.push(toolResultMessage); }} |
用一个例子看 finalizedCalls 数组的变化:
假设模型同时请求了三个工具:[list_files, read_file("a.ts"), read_file("b.ts")],其中第二个被 beforeToolCall 拦截了。
1 | 阶段 1 结束后,finalizedCalls 长这样(半成品数组,混着两种东西):finalizedCalls = [ async () => { ... }, // list_files:三关全过,等待执行 { toolCall, result: "被拦截", isError: true }, // read_file("a.ts"):被 beforeToolCall 拦了,已有结果 async () => { ... }, // read_file("b.ts"):三关全过,等待执行]阶段 2:await Promise.all(...) 把函数都执行完,拆开所有 Promise:orderedFinalizedCalls = [ { toolCall, result: "目录列表...", isError: false }, // list_files 执行完了 { toolCall, result: "被拦截", isError: true }, // 原封不动(本来就是对象) { toolCall, result: "b.ts 的内容", isError: false }, // read_file("b.ts") 执行完了]现在全是对象,没有函数,没有 Promise。阶段 3:for 循环逐个包装成 toolResultMessage,push 进 context.messages。 |
为什么准备阶段要顺序? 因为 beforeToolCall 钩子可能有状态——比如”同一批次里只允许一个写操作”。如果并发 prepare,钩子看到的状态可能不一致。
为什么结果要按原始顺序? 因为下一轮 LLM 调用时模型会看到这些 toolResult——它期望”我请求的第 0 个工具的结果排第 0 位”,乱了会混淆。Promise.all 天然保持输入数组的索引顺序,不管谁先 resolve。
什么时候回退到顺序执行? 看第 381–384 行:
1 | const hasSequentialToolCall = toolCalls.some( (tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential",);if (config.toolExecution === "sequential" || hasSequentialToolCall) { return executeToolCallsSequential(...);} |
两种情况会回退顺序:全局配置 config.toolExecution === "sequential",或者这批里有工具标了 executionMode: "sequential"。
实际情况:pi 内置的七个工具都没有标 sequential——write 和 edit 内部用了一个按文件路径的队列(file-mutation-queue.ts),同一文件的写操作自动串行,不同文件并行,所以不需要在工具级别强制整批顺序。
executionMode: "sequential" 给什么场景用?pi 官方示例里有两个例子:
question.ts— “问用户问题”工具。模型可能一次发两个 question 调用,并行的话两个弹窗同时出现,用户懵了。标 sequential → 一个一个弹。tic-tac-toe.ts— 井字棋。模型在一条消息里发[move_right, move_down, play]三个调用,它们共享同一个游戏光标。并行的话play可能在move之前执行,棋子落错位置。标 sequential → 动作按顺序生效。
共同模式:工具之间共享可变状态(UI 焦点、游戏光标),且模型在同一条消息里对同一个工具连续调用多次,调用之间有顺序依赖。
工具执行中的流式更新(tool_execution_update)
工具不是黑盒——执行过程中可以通过 onUpdate 回调 emit 中间状态。看 executePreparedToolCall(第 628 行):
1 | const result = await prepared.tool.execute( prepared.toolCall.id, prepared.args, signal, (partialResult) => { // onUpdate 回调——工具每有新输出就调一次 emit({ type: "tool_execution_update", toolCallId: prepared.toolCall.id, toolName: prepared.toolCall.name, args: prepared.toolCall.arguments, partialResult, // 中间结果,比如 bash 命令已输出的 stdout }); },); |
典型场景:bash 工具执行 npm test,测试跑了 30 秒——每当有新输出行时,通过 onUpdate 推送中间结果,UI 就能实时显示测试进度,而不是等 30 秒后一次性吐出全部输出。
4. streamAssistantResponse:流式 + 事件广播(第 275–368 行)
mini-pi 用 complete() 干等——模型想 3 秒,你的终端就死寂 3 秒。pi 用流式接口,模型每生成一点就推过来一点,UI 能实时显示。
这个函数只做一件事:发一次 LLM 请求,返回一条完整的 assistant 消息。 过程分四步:
| 步骤 | 做什么 | 解释 |
|---|---|---|
1. transformContext |
裁剪对话历史(可选) | 对话太长时只保留最近 N 条,防止超 context window。不改原始数据,只影响这一次请求 |
2. convertToLlm |
内部格式 → LLM 格式 | pi 内部有自定义消息类型(通知、压缩摘要等),LLM 不认识。这一步过滤/转换,只留 LLM 认识的 user/assistant/toolResult |
3. streamFunction |
发起流式请求 | 把消息发给 LLM API,拿回一个”水管”(async iterable)——服务端返回 SSE(Server-Sent Events)流,每个 event 是一个 JSON 块,模型每生成一点就推过来一点。pi 不需要和服务端协商”一次推多大”,收到就处理 |
4. for await |
逐事件处理 | 从水管里一个一个接事件,每接到一个就 emit 给外面的 UI,同时更新 context.messages |
先看代码,再用 trace 例子走一遍:
1 | async function streamAssistantResponse(context, config, signal, emit, streamFn) { // 步骤 1:准备上下文 let messages = context.messages; if (config.transformContext) { messages = await config.transformContext(messages, signal); } const llmMessages = await config.convertToLlm(messages); // 步骤 2:把转换后的消息装进 LLM 能理解的 Context 对象 const llmContext = { systemPrompt: context.systemPrompt, messages: llmMessages, // ← llmMessages 用在这里 tools: context.tools, }; // 步骤 3:发起流式请求(返回一个 async iterable,可以用 for await 逐个取) const response = await streamFunction(config.model, llmContext, { ... }); // 步骤 4:逐事件处理 let partialMessage = null; let addedPartial = false; // 追踪是否已经收到过 "start" 事件 for await (const event of response) { switch (event.type) { case "start": // 模型开始说话——创建一条空的 assistant 消息,先推进 context partialMessage = event.partial; context.messages.push(partialMessage); addedPartial = true; await emit({ type: "message_start", message: { ...partialMessage } }); break; case "text_start": case "text_delta": case "text_end": case "thinking_start": case "thinking_delta": case "thinking_end": case "toolcall_start": case "toolcall_delta": case "toolcall_end": // 模型吐出新内容——更新 partial,替换 context 里的旧版本 if (partialMessage) { // 守卫:万一 "start" 事件丢了,不炸 partialMessage = event.partial; context.messages[context.messages.length - 1] = partialMessage; await emit({ type: "message_update", assistantMessageEvent: event, message: { ...partialMessage } }); // ^^^^^^^^^^^^^^^^^^^ // 注意浅拷贝:emit 出去的是快照,后续 delta 不会改变已 emit 的对象 } break; case "done": case "error": // 流结束——拿到最终完整消息 const finalMessage = await response.result(); if (addedPartial) { context.messages[context.messages.length - 1] = finalMessage; // 替换 partial } else { context.messages.push(finalMessage); // 从没收到 "start"(极端情况)→ 直接 push } // [省略] 如果 !addedPartial,这里还会补发一个 message_start(确保事件配对) await emit({ type: "message_end", message: finalMessage }); return finalMessage; } } // [省略] for-await 正常退出(没走 done/error case)的 fallback 路径 // 实际代码在这里也会调 response.result() 拿最终消息并 emit // 这是防御性代码——正常情况下不会走到这里} |
代码里的核心设计:context.messages 里始终只有一条 assistant 消息——t0 push 进去后,后续所有 delta 都是对同一位置的覆盖,不会越积越多。这保证了即使中途 abort,对话历史结构也是完好的。
下面用一个具体例子,看每个事件到达时 context.messages 怎么变化:
一个完整的流式事件 trace
假设用户问”读一下 package.json”,模型决定调用 read_file 工具。
进入 streamAssistantResponse 时,context.messages 长这样:
1 | context.messages = [ { role: "user", content: [{type:"text", text:"读一下 package.json"}] }, // [0] // ← 就这一条,还没有 assistant 回复] |
下面是 for-await 循环收到的每一个事件,以及 context.messages 数组的完整快照:
1 | ━━━ t0: 收到 { type: "start", partial: { role: "assistant", content: [] } } ━━━ 执行: context.messages.push(partialMessage) ← 唯一一次 push,数组长度从 1 变 2 emit: message_start context.messages = [ { role: "user", content: [{type:"text", text:"读一下 package.json"}] }, // [0] { role: "assistant", content: [] }, // [1] ← 新 push 的空壳 ]━━━ t1: 收到 { type: "text_start", partial: { role: "assistant", content: [{type:"text", text:""}] } } ━━━ 执行: context.messages[1] = partialMessage ← 覆盖 [1],数组长度不变 emit: message_update context.messages = [ { role: "user", content: [{type:"text", text:"读一下 package.json"}] }, // [0] 没动 { role: "assistant", content: [{type:"text", text:""}] }, // [1] 被覆盖 ]━━━ t2: 收到 { type: "text_delta", delta: "我来读", partial: { role: "assistant", content: [{type:"text", text:"我来读"}] } } ━━━ 执行: context.messages[1] = partialMessage ← 再次覆盖 [1] emit: message_update context.messages = [ { role: "user", content: [{type:"text", text:"读一下 package.json"}] }, // [0] 没动 { role: "assistant", content: [{type:"text", text:"我来读"}] }, // [1] 文字多了 ]━━━ t3: 收到 { type: "text_delta", delta: "一下", partial: { ... text:"我来读一下" ... } } ━━━ 执行: context.messages[1] = partialMessage ← 覆盖 [1] emit: message_update context.messages = [ { role: "user", content: [{type:"text", text:"读一下 package.json"}] }, // [0] 不变 { role: "assistant", content: [{type:"text", text:"我来读一下"}] }, // [1] ]━━━ t4: 收到 { type: "toolcall_start", partial: { content: [{text:"我来读一下"}, {type:"toolCall", name:"read_file", arguments:{}}] } } ━━━ 执行: context.messages[1] = partialMessage ← 覆盖 [1],content 数组多了一个 toolCall 块 emit: message_update context.messages = [ { role: "user", content: [{type:"text", text:"读一下 package.json"}] }, // [0] 不变 { role: "assistant", content: [ // [1] {type:"text", text:"我来读一下"}, {type:"toolCall", name:"read_file", id:"toolu_01X...", arguments:{}}, ]}, ]━━━ t5: 收到 { type: "toolcall_delta", partial: { role: "assistant", content: [{type:"text", text:"我来读一下"}, {type:"toolCall", name:"read_file", id:"toolu_01X...", arguments:{path:"package.json"}}] } } ━━━ 执行: context.messages[1] = partialMessage ← 覆盖 [1],arguments 填上了 emit: message_update context.messages = [ { role: "user", content: [{type:"text", text:"读一下 package.json"}] }, // [0] 不变 { role: "assistant", content: [ // [1] {type:"text", text:"我来读一下"}, {type:"toolCall", name:"read_file", id:"toolu_01X...", arguments:{path:"package.json"}}, ]}, ]━━━ t6: 收到 { type: "done" } ━━━ 执行: finalMessage = await response.result() ← 拿到带 stopReason + usage 的最终版 context.messages[1] = finalMessage ← 最后一次覆盖 [1] emit: message_end return finalMessage context.messages = [ { role: "user", content: [{type:"text", text:"读一下 package.json"}] }, // [0] 不变 { role: "assistant", content: [ // [1] 完整了 {type:"text", text:"我来读一下"}, {type:"toolCall", name:"read_file", id:"toolu_01X...", arguments:{path:"package.json"}}, ], stopReason: "toolUse", usage: {inputTokens: 52, outputTokens: 31} }, ] |
要点: 数组长度从始至终只在 t0 变了一次(1→2)。t1–t6 全是对 [1] 位置的覆盖。如果中途 abort,[1] 里是截断的 partial 但数组结构完好——不会出现多余的空消息或消息配对断裂。
emit 出去的事件,UI 拿来干什么
循环本身不知道 UI 长什么样——它只管在正确的时刻 emit 正确的事件。UI 订阅自己关心的事件类型:
| 事件 | UI 通常拿它干什么 |
|---|---|
message_start |
显示一个新的气泡框 |
message_update(text_delta) |
逐字往气泡里追加文字(打字机效果) |
message_update(toolcall_delta) |
实时显示工具参数的拼凑过程 |
message_end |
标记这条消息完成 |
tool_execution_start |
显示”正在执行 read_file…” |
tool_execution_update |
工具执行中的中间状态(如 bash 的 stdout) |
tool_execution_end |
显示工具执行结果 |
注意 message_update 里的 message 是累积的——每次 emit 都包含到目前为止的完整内容(上面 trace 里能看到)。UI 如果只想要新增部分,看 assistantMessageEvent.delta;如果想要完整文本,看 message.content。
transformContext 和 convertToLlm
注意流式请求之前有两步预处理:
1 | if (config.transformContext) { messages = await config.transformContext(messages, signal);}const llmMessages = await config.convertToLlm(messages); |
1 | 两步预处理管道:AgentMessage[](完整对话历史) → transformContext → AgentMessage[](裁剪/注入后) → convertToLlm → Message[](LLM 能理解的格式) → 发给 LLM |
transformContext — 在 AgentMessage 层面变换上下文。每一轮 LLM 调用之前都会执行,但不修改真正的对话历史——它只影响这一次发给 LLM 的输入。
它是一个函数,不是一个配置值。每次被调用时拿到完整的消息列表,返回修改后的列表:
1 | // 最简单的用法:对话太长时只保留最近 N 条transformContext: async (messages) => { if (messages.length > 100) { // 保留第一条(系统提示相关)+ 最后 50 条 return [messages[0], ...messages.slice(-50)]; } return messages; // 不到 100 条就原样返回} |
在 pi 的生产代码里,transformContext 被接到了扩展系统的 "context" 事件上——扩展可以在每次 LLM 调用前注入或移除消息。一个真实例子是 pi 官方的 plan-mode 扩展:
1 | // plan-mode 扩展:plan mode 关闭后,把之前注入的 plan mode 提示消息清掉pi.on("context", async (event) => { if (planModeEnabled) return; // plan mode 开着 → 不动,return undefined = 消息列表原样保留 // plan mode 关了 → 过滤掉所有 plan mode 相关的消息(模型不需要再看到它们) return { messages: event.messages.filter((m) => { if (m.customType === "plan-mode-context") return false; // .filter() 里 false = 丢掉 return true; // 其他保留 }), };}); |
这个扩展同时还用 tool_call 事件拦截工具调用——plan mode 开着时模型调任何工具都会被 block。两个事件配合:context 控制”模型看到什么”,tool_call 控制”模型能做什么”。
convertToLlm — 把 AgentMessage 转成 LLM 能理解的标准 Message[]。LLM 只认 user / assistant / toolResult 三种角色,但 pi 内部有更多类型。下面是 pi 真实的 convertToLlm(messages.ts 第 148 行,简化注释):
1 | function convertToLlm(messages: AgentMessage[]): Message[] { return messages.map((m) => { switch (m.role) { case "user": case "assistant": case "toolResult": return m; // LLM 认识,直通 case "custom": // 扩展注入的自定义消息(如 plan-mode-context)→ 转成 user 消息喂给模型 return { role: "user", content: m.content, timestamp: m.timestamp }; case "bashExecution": // 用户在终端跑的 ! 命令的输出 → 转成 user 消息 return { role: "user", content: [{ type: "text", text: bashExecutionToText(m) }], ... }; case "compactionSummary": // 压缩摘要 → 转成 user 消息(让模型知道"之前的对话被压缩了,这是摘要") return { role: "user", content: [{ type: "text", text: "..." + m.summary + "..." }], ... }; case "branchSummary": // 分支摘要 → 同上 return { role: "user", content: [...], ... }; } }).filter(m => m !== undefined);} |
所有非标准类型最终都被转成了 role: "user" 消息——对 LLM 来说它们就是”用户给的额外信息”。
两步的分工:transformContext 管”给多少”(裁剪),convertToLlm 管”怎么翻译”(格式转换)。分成两步是因为裁剪逻辑需要看完整的 AgentMessage 类型信息(比如”保留所有 compactionSummary 但删旧的 assistant”),而翻译只需要逐条转换。
这就是 types.ts 第 309 行[4] 定义的 AgentMessage 和底层 Message 的区别:
1 | // AgentMessage = 标准 LLM 消息 + 你自定义的消息类型export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages]; |
循环内部全程操作 AgentMessage,只在发给 LLM 的最后一刻才转成 Message——保留了自定义消息的能力,又不污染 LLM 的输入。
5. Steering 和 Follow-up 队列(第 166 行、第 253 行、第 257 行)
用户在 agent 干活时打字进来,循环怎么接住?
单循环(mini-pi)的问题
你在 Part 1 写的 mini-pi 是这样的:
1 | while (true) { const message = await callLLM(); if (message.stopReason !== "toolUse") break; // 没有工具调用 → 直接退出 await executeTools(message);} |
用户完全插不上话。 循环在跑的时候,用户打什么字都没人接;循环退出后,对话就结束了。
pi 的解法:两个队列 + 两个检查点
pi 用两个队列解决两种”用户想说话”的场景:
| Steering | Follow-up | |
|---|---|---|
| 用户意图 | “现在就听我的,改方向” | “你先干完,干完了接着干这个” |
| 检查时机 | 每轮工具执行后(内层循环里) | agent 打算退出时(外层循环里) |
| 怎么触发 | 按 Enter 发送 |
按 Alt+Enter 发送 |
对应的代码只有两行:
1 | // 内层循环末尾(第 253 行)——agent 还在忙,检查有没有中途插话pendingMessages = (await config.getSteeringMessages?.()) || [];// 外层循环末尾(第 257 行)——agent 打算停了,检查有没有追加任务const followUpMessages = (await config.getFollowUpMessages?.()) || [];if (followUpMessages.length > 0) { pendingMessages = followUpMessages; continue; // 拉回内层循环继续}break; // 真的没事了,退出 |
一个具体场景
1 | 你:帮我检查有没有 bugagent:开始干活 → 读文件 → 读文件 → ← 你按 Enter 说:"顺便看安全漏洞" → 进 steering 队列 → 本轮工具执行完后注入 → 下一轮模型就看到agent:检查完了,发现 2 个 bug → 没有更多工具调用 → 内层循环退出 → 走到外层的 break 之前 → 先检查 follow-up 队列... ← 你之前按 Alt+Enter 说过:"现在帮我修第一个" → 在 follow-up 队列里等着 → 被检查到 → 不 break,continue 回内层循环agent:继续跑 → 修 bug → 完成 → 再次走到 break 前 → follow-up 队列空了 → 这次真的 break → agent_end |
和单循环的根本区别
单循环只有一个退出点(break),退了就退了。pi 的双层结构多了一道”散会前最后检查”:
1 | 单循环: 没有 tool call → break → 结束,用户没机会追加双层循环: 没有 tool call → 内层退出 → 检查 follow-up → 有 → 拉回来继续 → 没有 → 才真正退出 |
如果你的 agent 不需要”agent 干完后追加任务”的能力,单循环 + 单队列完全够用。 pi 加第二层是因为它要支持 Alt+Enter 排队和 SDK 的 streamingBehavior: "followUp" 这两个产品功能。
SDK 调用时通过参数指定:
1 | session.prompt("别管 test/", { streamingBehavior: "steer" }); // 立刻插入session.prompt("搞完再帮我改 README", { streamingBehavior: "followUp" }); // 排队等着 |
6. afterToolCall:执行后的改写(第 665–708 行)
还有一个值得单独说的钩子——afterToolCall,在工具执行完、结果回报模型之前跑:
1 | async function finalizeExecutedToolCall(currentContext, assistantMessage, prepared, executed, config, signal) { // executed —— 工具刚刚执行完的原始结果,长这样: // { result: { content: [{ type: "text", text: "文件内容..." }] }, isError: false } let result = executed.result; let isError = executed.isError; // 如果你注册了 afterToolCall 钩子—— if (config.afterToolCall) { // 把完整上下文交给钩子:谁调的、什么参数、原始结果、当前对话状态 const afterResult = await config.afterToolCall( { assistantMessage, toolCall: prepared.toolCall, args: prepared.args, result, isError, context: currentContext }, signal, ); // 钩子返回值(如果有的话)可以改写结果的任何部分 // ?? 是"空值合并":左边是 null/undefined 就用右边(保留原值) if (afterResult) { result = { content: afterResult.content ?? result.content, // 想换内容?给 content;不想换?返回 undefined,保留原来的 details: afterResult.details ?? result.details, // 同上 terminate: afterResult.terminate ?? result.terminate, // 想让循环停?给 true }; isError = afterResult.isError ?? isError; // 想把成功改成失败(或反过来)?改这个 } } // 最终返回给循环的结果——可能被钩子改过,也可能原封不动 return { toolCall: prepared.toolCall, result, isError };} |
实际例子——afterToolCall 截断大文件:
1 | // 你注册的 afterToolCall 钩子:afterToolCall: async ({ toolCall, result }) => { if (toolCall.name === "read_file") { const text = result.content[0].text; const lines = text.split("\n"); if (lines.length > 200) { // 文件超过 200 行 → 截断,省 token return { content: [{ type: "text", text: lines.slice(0, 200).join("\n") + `\n... (截断,共 ${lines.length} 行)` }], }; // 只返回了 content → details/terminate/isError 保持原值(?? 生效) } } // 不需要改写 → 返回 undefined → 结果原封不动} |
更多用途:
- 工具返回了敏感信息(密码、API key),钩子把它替换成
[REDACTED]再回报模型 - 工具执行成功但你想让 agent 停下来,钩子返回
{ terminate: true }→ 循环不再调 LLM
terminate 的细节:只有当一个 batch 里所有工具结果都设置了 terminate: true 时,循环才真正停下来(第 544 行[1])。一个说停、另一个没说 → 继续转。
7. 生命周期钩子:prepareNextTurn + shouldStopAfterTurn(第 226、241 行)
在每轮工具执行完、结果推回对话之后,循环还要做两件事情再决定”下一步怎么走”。先看它们在整个循环结构里的位置:
1 | 外层 while (true) { 内层 while (hasMoreToolCalls || pendingMessages.length > 0) { 调 LLM(streamAssistantResponse) 执行工具(executeToolCalls) emit turn_end → prepareNextTurn ← 每轮都跑:要不要换 model / thinking level? → shouldStopAfterTurn ← 每轮都跑:要不要强制停? → getSteeringMessages ← 检查用户中途插话 } → getFollowUpMessages ← 检查追加任务} |
这两个钩子分别回答两个问题:
1 | 轮次结束后的两个检查:本轮结束(turn_end 已 emit) → prepareNextTurn(下一轮用什么配置?) → shouldStopAfterTurn(要不要停?) 停 → agent_end 不停 → getSteeringMessages → 继续循环 |
prepareNextTurn — 动态换挡(第 226 行)
1 | // 第 226–239 行const nextTurnContext = { message, // 刚完成的 assistant 消息 toolResults, // 本轮所有工具的执行结果 context: currentContext, // 完整对话状态 newMessages, // 本次 agent run 新增的所有消息};const nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext);if (nextTurnSnapshot) { currentContext = nextTurnSnapshot.context ?? currentContext; // 可以换整个上下文 config = { ...config, model: nextTurnSnapshot.model ?? config.model, // 可以换模型 reasoning: nextTurnSnapshot.thinkingLevel === undefined ? config.reasoning : nextTurnSnapshot.thinkingLevel === "off" ? undefined : nextTurnSnapshot.thinkingLevel, // 可以换 thinking level };} |
什么时候用? 典型场景:
- 动态降级:前几轮用 Claude Opus 做规划,工具执行完发现剩下的是简单的文本生成 → 切到 Sonnet 省 token
- thinking 调节:对话超过 50 条消息了 → 关掉 extended thinking 防止上下文溢出
- 上下文裁剪:对话太长 → 钩子返回一个精简过的
context,只保留最近 N 轮
shouldStopAfterTurn — 强制停(第 241 行)
1 | // 第 241–251 行if ( await config.shouldStopAfterTurn?.({ message, toolResults, context: currentContext, newMessages, })) { await emit({ type: "agent_end", messages: newMessages }); return; // 不再检查 steering 和 follow-up,直接退出} |
pi 自己没有提供默认实现。 这是一个可选字段——不传就不检查,循环永远不会因为它停下来。你自己写 agent 时可以传一个:
1 | const agent = new Agent({ shouldStopAfterTurn: ({ context }) => { // 对话超过 200 条消息就强制停 return context.messages.length > 200; },}); |
terminate 和 shouldStopAfterTurn 的区别
这两个都能让循环停下来,但层级不同:
1 | 一次内层循环迭代: 调 LLM → 得到 assistant 消息(带 3 个 tool call) 执行 3 个工具 → 每个工具结果里有 terminate 字段 ↓ 全部 terminate: true? → 是:hasMoreToolCalls = false(工具说"够了") 但!如果有 steering 消息,内层循环还会继续 → 否:hasMoreToolCalls = true,继续调 LLM ↓ emit turn_end → prepareNextTurn ↓ shouldStopAfterTurn 返回 true? → 是:直接 agent_end,不管有没有 steering / follow-up,彻底结束 → 否:继续检查 steering |
terminate |
shouldStopAfterTurn |
|
|---|---|---|
| 谁设置 | 工具(通过 afterToolCall 钩子返回 {terminate: true}) |
你传给 agent 的函数 |
| 作用 | hasMoreToolCalls = false(内层循环可能继续) |
直接退出整个循环(一定停) |
| 能被 steering 覆盖吗 | 能——如果有 steering 消息,循环还是继续 | 不能——它在 steering 检查之前 |
| 类比 | 工人说”活干完了”(但老板可以加新活) | 老板说”下班了”(不管还有没有活) |
简单记忆:terminate 是温和建议,shouldStopAfterTurn 是强制命令。
8. Abort 信号:随时可中断(贯穿全文件)
你可能注意到几乎每个函数的签名里都有 signal?: AbortSignal:
1 | async function runLoop(..., signal?: AbortSignal)async function streamAssistantResponse(..., signal?: AbortSignal)async function prepareToolCall(..., signal?: AbortSignal)async function executePreparedToolCall(prepared, signal?: AbortSignal, ...)async function finalizeExecutedToolCall(..., signal?: AbortSignal) |
用户按 Ctrl+C 时,上层调 abortController.abort(),signal.aborted 变成 true。循环在所有可能长期阻塞的点都检查这个信号:
1 | // prepareToolCall 第 591 行和第 606 行if (signal?.aborted) { return { kind: "immediate", result: createErrorToolResult("Operation aborted"), isError: true, };} |
1 | // executeToolCallsSequential 第 440 行if (signal?.aborted) { break; // 不再执行剩下的工具,立即返回已有结果} |
设计要点:
- Abort 不抛异常——它返回一个带
isError: true的"Operation aborted"结果 - 这意味着 abort 后的对话历史是完整的(不会出现”有 toolCall 但没有 toolResult”的断裂)
- 上层可以通过
message.stopReason === "aborted"判断循环是正常结束还是被中断的(第 196 行)
对比 mini-pi:你的版本没有 abort 机制——一旦 LLM 调用发出去,用户只能干等。pi 允许用户随时按 Ctrl+C,循环会在最近的检查点优雅退出。
接着干,等我
你现在能读懂 pi 的整个 agent 循环了——从双层 while 到三道关卡到并行执行到流式广播。但我们一直在 packages/agent/ 这一层——它只是个抽象框架,不知道什么是”文件”、什么是”终端”。packages/coding-agent/ 才是那个知道怎么读文件、跑 bash、编辑代码的具体产品。下一步可以去看它怎么定义那七个工具(bash、edit、find、grep、ls、read、write),以及 system prompt 怎么写——那是 agent 的”性格”来源。

