5.2 一次普通请求的完整路径
本页分析版本earendil-works/pi@c13ffe12026-07-30本章解决什么问题:你在交互界面里敲下一句话、按回车,屏幕上开始逐字长出回答——这中间到底经过了哪些函数?本章把这条路径一站一站走完,每一站都给出真实的文件名和行号。 前置知识:4.4 从哪里开始读源码(主链路轮廓)、3.5 Agent 与 Agent Loop、3.3 流式输出、2.6 异步迭代器与 for await。 学习目标:读完后你能 ① 说出从按键到回答显示经过的七个交接点,并在源码里找到每一个;② 解释「交互界面是事件驱动的,主循环却是顺序的」这件事是怎么缝合的;③ 说清
streamFn这个参数是从哪里来的、最终打到了哪个函数;④ 讲明白为什么「没有工具调用」时 Agent Loop 只转一圈就停。
本章的实验对象:一句不触发工具的话
想象你在 Pi 的交互界面里输入:
用一句话解释什么是 Agent Loop模型只回文字,不读文件、不跑命令、不发起任何工具调用(Tool Calling)。这是 Pi 能跑的最短的一条完整路径:没有回环、没有工具执行、没有上下文压缩(Context Compaction)。
为什么先看它?因为 Agent 的所有复杂度都是在这条主干上加分支长出来的。工具调用是主干上多绕一圈(5.3),取消是在主干上插一个 AbortSignal(5.6),会话保存是在主干旁边挂一个写盘动作(5.5)。先把主干背下来,分支才有地方挂。
grep。如果你确实想跑起来,需要先按 5.1 配好一个 provider(模型服务提供方)。真实采集的启动界面里有一行 Warning: No models available. Use /login to log into a provider...(素材:research/cli-captures/pi-tui-main.txt,真实采集),它对应的正是本章第二站里的凭证校验。 建立直觉:七次交接
把这条路径想成一场接力赛。每一棒的选手都只做自己那一小段,然后把「棒」交给下一个人。棒本身也在变形:一开始是一串字符,中途变成一条 user 消息,再变成一个 HTTP 请求体,回程时又变成一串事件。
| 棒次 | 交接站 | 输入 | 输出 | 所在包 |
|---|---|---|---|---|
| 1 | 编辑器与主循环 | 按键字节 | string | packages/tui + packages/coding-agent |
| 2 | AgentSession.prompt | string | AgentMessage[] | packages/coding-agent |
| 3 | Agent.prompt | AgentMessage[] | 一次 run 的生命周期 | packages/agent |
| 4 | runLoop | 上下文 + 配置 | 循环控制 | packages/agent |
| 5 | streamFn | Context | 事件流 | packages/coding-agent → packages/ai |
| 6 | Provider 与 LLM | 请求体 | SSE 分片 | packages/ai |
| 7 | 事件回流 | 事件 | 屏幕上的字 | 逆序走回第 1 棒 |
七棒里有一个容易被忽略的事实:第 7 棒不是在第 6 棒全部跑完之后才开始的。回程和去程是重叠的——模型每吐出几个字符,事件就已经开始往回走了。这就是「流式输出(Streaming)」在架构上的样子。
全景图
图 5.2-1 一次不触发工具的请求的完整时序
阅读顺序:从上到下。参与者分属四个 package:Editor 来自 packages/tui,InteractiveMode 与 AgentSession 在 packages/coding-agent,Agent 与 runLoop 在 packages/agent,streamFn 之后落到 packages/ai。请重点看两处:第 3 步「唤醒 Promise」是事件驱动与顺序循环的缝合点;第 12 到 16 步是同一份数据被反复重新包装的回程。
后面几小节就按这张图逐段拆开。每一段都会先说「这一站在做什么」,再给源码位置。
第 1 棒:从按键到一个字符串
Pi 的交互模式(interactive mode)看起来是一个 REPL:你打字、回车、等回答、再打字。但终端程序天生是事件驱动的——按键随时到达,程序不能傻等。Pi 用一个很小的技巧把两者缝在一起。
先看终点。InteractiveMode.run() 的最后是一个朴素得出奇的循环:
// packages/coding-agent/src/modes/interactive/interactive-mode.ts:936-945
// Main interactive loop
while (true) {
const userInput = await this.getUserInput();
try {
await this.session.prompt(userInput);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
this.showError(errorMessage);
}
}getUserInput() 就是那个缝合点。它并不去读标准输入,而是返回一个悬而未决的 Promise,并把这个 Promise 的 resolve 函数存进实例字段 onInputCallback:
// packages/coding-agent/src/modes/interactive/interactive-mode.ts:3542-3553
async getUserInput(): Promise<string> {
const queuedInput = this.pendingUserInputs.shift();
if (queuedInput !== undefined) {
return queuedInput;
}
return new Promise((resolve) => {
this.onInputCallback = (text: string) => {
this.onInputCallback = undefined;
resolve(text);
};
});
}另一边,编辑器组件在你按下回车时会调用 Editor.submitValue()(packages/tui/src/components/editor.ts:1260-1274;触发点在 editor.ts:804-818,匹配键位 tui.input.submit,默认就是 Enter),它清空编辑器并调用 this.onSubmit(result)(editor.ts:1273)。这个 onSubmit 是 InteractiveMode 在启动时装上去的一个大闭包(interactive-mode.ts:2686-2872),末尾几行才是我们这条路径关心的:
// packages/coding-agent/src/modes/interactive/interactive-mode.ts:2862-2871
// (前面省略:斜杠命令分派、! bash 命令、压缩期排队、流式期 steer 分支)
// Normal message submission
// First, move any pending bash components to chat
this.flushPendingBashComponents();
if (this.onInputCallback) {
this.onInputCallback(text);
} else {
this.pendingUserInputs.push(text);
}
this.editor.addToHistory?.(text);onInputCallback至此第 1 棒完成:一串按键变成了一个 string,并且主循环从挂起状态被唤醒。注意闭包里更早的分支(interactive-mode.ts:2853-2859):如果此刻 Agent 正在流式输出,走的不是唤醒主循环,而是 session.prompt(text, { streamingBehavior: "steer" })——那是插队消息(steering)的路径,本章不展开。
while (true) { await getUserInput(); ... } 看着像忙等,其实一次键盘轮询都没有。await 挂起后,整个 Node.js 事件循环空闲下来去处理终端输入、定时器、网络回包;直到 onInputCallback 被调用,主循环才被唤醒。这是 2.5 讲过的「Promise 作为一次性信号」的典型用法。 第 2 棒:AgentSession.prompt 的四道关卡
AgentSession 是 coding-agent 包里的「会话(Session)门面」:它拥有 Agent、SessionManager、SettingsManager、扩展运行器等一堆东西,对外只暴露若干高层动作。prompt() 是其中最重要的一个,它在把文本交给 Agent 之前要过四道关:
// packages/coding-agent/src/core/agent-session.ts:1114-1129(节选)
async prompt(text: string, options?: PromptOptions): Promise<void> {
const expandPromptTemplates = options?.expandPromptTemplates ?? true;
const preflightResult = options?.preflightResult;
let messages: AgentMessage[] | undefined;
try {
// Handle extension commands first (execute immediately, even during streaming)
if (expandPromptTemplates && text.startsWith("/")) {
const handled = await this._tryExecuteExtensionCommand(text);
if (handled) {
preflightResult?.(true);
return;
}
}
// …(省略:input 扩展事件、skill 与模板展开、流式期排队分支)async prompt四道关依次是:
- 扩展命令(
agent-session.ts:1122-1129):扩展注册的斜杠命令自己管理与模型的交互,命中即返回。 input扩展事件(agent-session.ts:1134-1149):扩展可以拦截(handled)或改写(transform)这句话。- 技能与模板展开(
agent-session.ts:1151-1156):/skill:name args与/template args在这里被替换成真正的提示词(Prompt)。 - 流式期分支(
agent-session.ts:1158-1172):如果 Agent 正忙且调用方没写streamingBehavior,直接抛错。
我们的实验对象是一句普通的话,四道关全部放行。接着是这条路径上最容易被读者忽视、却最常在实际使用中报错的一段——模型与凭证校验:
// packages/coding-agent/src/core/agent-session.ts:1177-1195(节选)
// Validate model
if (!this.model) {
throw new Error(formatNoModelSelectedMessage());
}
const hasConfiguredAuth =
this._modelRuntime.hasConfiguredAuth(this.model.provider) ||
(await this._modelRuntime.checkAuth(this.model.provider)) !== undefined;
if (!hasConfiguredAuth) {
// …(省略:OAuth 凭证过期时提示 /login 的分支)
throw new Error(formatNoApiKeyFoundMessage(this.model.provider));
}hasConfiguredAuth从源码结构看,这两个 throw 正是主循环里那个 try/catch(interactive-mode.ts:939-944)存在的原因:预检失败不应该让整个交互模式退出,只该在屏幕上印一行错误,然后继续等下一句输入。
过关之后,prompt() 组装出真正要送进 Agent 的消息数组(agent-session.ts:1204-1222:一条 user 消息,外加此前排队的 nextTurn 消息),触发 before_agent_start 扩展事件(agent-session.ts:1225),最后交棒:
// packages/coding-agent/src/core/agent-session.ts:1061-1073
private async _runAgentPrompt(messages: AgentMessage | AgentMessage[]): Promise<void> {
this._isAgentRunActive = true;
try {
await this.agent.prompt(messages);
while (await this._handlePostAgentRun()) {
await this.agent.continue();
}
} finally {
this._systemPromptOverride = undefined;
this._flushPendingBashMessages();
await this._emitAgentSettled();
}
}_runAgentPrompt那个 while (await this._handlePostAgentRun()) 是 Pi 在 agent 核心之上加的一层:自动重试、自动压缩后 continue() 续跑都在这里。对我们这次普通请求来说,它一次都不会转(_handlePostAgentRun 判定无需重试即返回 false,见 agent-session.ts:1075-1090)。
第 3 棒:Agent 类只做三件事
进入 packages/agent 包后,画风突然变得很干净。Agent.prompt() 全文只有两个动作:拒绝并发,然后转给内部方法(packages/agent/src/agent.ts:339-347)。而内部方法也只是把五样东西凑齐后调低层循环:
// packages/agent/src/agent.ts:398-412
private async runPromptMessages(
messages: AgentMessage[],
options: { skipInitialSteeringPoll?: boolean } = {},
): Promise<void> {
await this.runWithLifecycle(async (signal) => {
await runAgentLoop(
messages,
this.createContextSnapshot(),
this.createLoopConfig(options),
(event) => this.processEvents(event),
signal,
this.streamFunction,
);
});
}runPromptMessages这五个参数值得逐个记住,因为它们就是 Agent 类与 Agent Loop(Agent 循环)之间的全部接口:
messages:本次要追加的消息。createContextSnapshot():当前的systemPrompt/messages/tools快照。createLoopConfig():模型、convertToLlm、两个队列的排水函数、工具钩子等。(event) => this.processEvents(event):事件出口。循环里每一次emit最终落到这里(agent.ts:529-576),先更新 Agent 内部状态,再按注册顺序await所有订阅者。signal:由runWithLifecycle创建的AbortController提供(agent.ts:471-494)。
从源码结构看,Agent 类做的就三件事:管一次 run 的生命周期(不许并发、失败兜底、结束清理)、持有可变状态(消息记录、是否在流式)、分发事件。真正的循环逻辑一行都不在这个类里。
第 4 棒:runLoop 的第一轮
runAgentLoop 是个薄函数:它先发 agent_start 和 turn_start,再把每条 prompt 消息各发一对 message_start / message_end,然后进入 runLoop(packages/agent/src/agent-loop.ts:109-117)。注意这个顺序——用户消息的 message_end 事件是 Pi 把它写进会话文件的时机(agent-session.ts:641)。
runLoop 是全书最该读懂的一个函数。它是两层 while:
// packages/agent/src/agent-loop.ts:170-200(节选,省略处不改变控制流)
while (true) {
let hasMoreToolCalls = true;
// Inner loop: process tool calls and steering messages
while (hasMoreToolCalls || pendingMessages.length > 0) {
if (!firstTurn) {
await emit({ type: "turn_start" });
} else {
firstTurn = false;
}
// …(省略:把 pendingMessages 注入上下文并各发一对 message 事件)
// Stream assistant response
const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction);
newMessages.push(message);
if (message.stopReason === "error" || message.stopReason === "aborted") {
await emit({ type: "turn_end", message, toolResults: [] });
await emit({ type: "agent_end", messages: newMessages });
return;
}hasMoreToolCalls第一次进内层循环时 hasMoreToolCalls 被硬编码为 true(agent-loop.ts:171),这是个小技巧:它保证无论如何都至少请求模型一次。firstTurn 标志则避免重复发 turn_start(runAgentLoop 已经发过一次了)。
一个必须先建立的概念:
packages/agent/src/types.ts:426 的注释)。本章的普通请求只有一个 turn;下一章的工具调用会让内层循环转多圈,也就是多个 turn。 第 5 棒:streamAssistantResponse 与那个叫 streamFn 的参数
内层循环里唯一「离开本进程」的一步是 streamAssistantResponse。它做三件事:把 AgentMessage[] 变成大语言模型(LLM)认识的 Message[]、解析 API Key、调用 streamFn。
// packages/agent/src/agent-loop.ts:288-312(节选)
// Apply context transform if configured (AgentMessage[] → AgentMessage[])
let messages = context.messages;
if (config.transformContext) {
messages = await config.transformContext(messages, signal);
}
// Convert to LLM-compatible messages (AgentMessage[] → Message[])
const llmMessages = await config.convertToLlm(messages);
const llmContext: Context = {
systemPrompt: context.systemPrompt,
messages: llmMessages,
tools: context.tools,
};
// Resolve API key (important for expiring tokens)
const resolvedApiKey =
(config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;
const response = await streamFunction(config.model, llmContext, {
...config,
apiKey: resolvedApiKey,
signal,
});streamFunction这里有一个对理解 Pi 架构至关重要的事实:packages/agent 包不认识任何 Provider。它只知道有个符合下面这个签名的函数可以调用。
packages/agent/src/types.ts:28-32:(model, context, options?) => AssistantMessageEventStream | Promise<…>。它的契约写在同文件 22-27 行的注释里:不许 throw,也不许返回 rejected 的 Promise;请求失败必须编码进返回的流里,最终产出一条 stopReason 为 "error" 或 "aborted" 的 assistant 消息。这个契约解释了为什么 runLoop 里没有 try/catch 包住 streamAssistantResponse——错误是当作数据回来的,而不是当作异常。 那这个函数具体是谁?有两条来源,两条都要知道。
来源一:构造 Agent 时显式注入。 这是 Pi 自己走的路。createAgentSession() 在 new Agent({...}) 时给了一个闭包:
// packages/coding-agent/src/core/sdk.ts:294-312(节选)
agent = new Agent({
initialState: { systemPrompt: "", model, thinkingLevel, tools: [] },
convertToLlm: convertToLlmWithBlockImages,
streamFn: async (model, context, options) => {
// …(省略:从 settings 读取超时、重试次数等参数)
return modelRuntime.streamSimple(model, context, {
...options,
timeoutMs,
// …(省略:WebSocket 超时、重试上限、请求头改写钩子)
});
},streamFnModelRuntime.streamSimple(packages/coding-agent/src/core/model-runtime.ts:494-499)再委托给 pi-ai 的 Models.streamSimple(packages/ai/src/models.ts:512-518),后者解析鉴权并交给具体 provider。第 6 棒(真正的 HTTP 请求、SSE 解析)属于 pi-ai 包的内部,6.1 会专门拆解。
来源二:模块级默认注册表。 packages/agent 提供了一个可选的全局兜底:
// packages/agent/src/stream-fn.ts:3-20(节选)
let defaultStreamFn: StreamFn | undefined;
export function setDefaultStreamFn(streamFn: StreamFn | undefined): void {
defaultStreamFn = streamFn;
}
export function getDefaultStreamFn(): StreamFn {
if (!defaultStreamFn) {
throw new Error("No default stream function configured. Pass streamFn explicitly or call setDefaultStreamFn().");
}
return defaultStreamFn;
}coding-agent 在模块加载时就装上了它:setDefaultStreamFn(streamSimple)(packages/coding-agent/src/core/sdk.ts:36,其中 streamSimple 来自 @earendil-works/pi-ai/compat,见 sdk.ts:3)。取用点有三处:Agent 构造函数(packages/agent/src/agent.ts:216)、runAgentLoop(agent-loop.ts:116)、runAgentLoopContinue(agent-loop.ts:141),写法都是 streamFn ?? getDefaultStreamFn()。源码注释(sdk.ts:33-35)说明这是为 0.81 之前不传 streamFn 的旧扩展保留的兼容行为,agent 核心本身不 import 任何 provider 目录。
第 6、7 棒:事件如何一层层包着回到屏幕
去程结束了,现在看回程。回程最需要建立的心智模型是:同一条信息被包了三层信封,每层由不同的包负责拆开再重新装。
图 5.2-2 事件的三层信封
这张图说明「一段文本增量」在四个包之间的四种身份。请重点看第二层到第三层:agent 包没有把 text_delta 提升为顶层事件,而是原样塞进 message_update 的 assistantMessageEvent 字段——所以想拿增量的人必须拆两层信封。
逐层对照源码:
第一层,pi-ai 的 AssistantMessageEvent(packages/ai/src/types.ts:501-513)共 12 种:start、三组 *_start / *_delta / *_end(text、thinking、toolcall)、以及二选一的终止事件 done / error。每个事件都带 partial 字段——当前已累积的完整 assistant 消息。
第二层,agent 包的 AgentEvent(packages/agent/src/types.ts:422-437)共 10 种。streamAssistantResponse 用 for await 消费第一层,映射成第二层(agent-loop.ts:317-344):start → message_start;九种增量事件 → 同一个 message_update,原事件放进 assistantMessageEvent 字段;done / error → 先 await response.result() 拿到最终消息,再发 message_end(agent-loop.ts:346-359)。
第三层,AgentSessionEvent(packages/coding-agent/src/core/agent-session.ts:139-181)。AgentSession 在构造时就订阅了 Agent(agent-session.ts:393),处理函数是:
// packages/coding-agent/src/core/agent-session.ts:594-622(节选)
private _handleAgentEvent = async (event: AgentEvent): Promise<void> => {
// …(省略:user 消息出现时把它从 steering/followUp 队列里摘掉)
// Emit to extensions first
await this._emitExtensionEvent(event);
// Notify all listeners
this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);
// Handle session persistence
if (event.type === "message_end") {
// …(省略:按 role 分派到 appendCustomMessageEntry / appendMessage)_handleAgentEvent这三步的顺序是有含义的:扩展先看到事件(可以做记录或副作用),UI 再看到,会话持久化最后做。_emit(agent-session.ts:548-552)只是同步遍历监听器数组。
最后一层,UI。 InteractiveMode.subscribeToAgent()(interactive-mode.ts:2875-2879)注册唯一的监听器,转给一个大 switch(interactive-mode.ts:2881 起)。对本章的路径来说三个分支最关键:
message_start且role === "assistant"(interactive-mode.ts:2945-2957):新建一个AssistantMessageComponent挂进聊天容器。message_update(interactive-mode.ts:2960-2963):streamingComponent.updateContent(event.message)——注意传的是event.message(累积后的完整消息),不是增量。所以组件每次都按完整内容重建子组件,真正的「只重画变化的行」交给 TUI 的差分渲染(6.9)。message_end(interactive-mode.ts:2995-3032):最后渲染一次,然后把streamingComponent置为undefined(interactive-mode.ts:3028-3029)——组件本身留在聊天记录里,只是不再是「正在流式的那个」。
没有 toolCall,循环怎么停下来
回到 runLoop。streamAssistantResponse 返回后,循环要判断还要不要再来一轮:
// packages/agent/src/agent-loop.ts:203-224(节选)
const toolCalls = message.content.filter((c) => c.type === "toolCall");
const toolResults: ToolResultMessage[] = [];
hasMoreToolCalls = false;
if (toolCalls.length > 0) {
// …(省略:截断保护与 executeToolCalls,见 5.3)
}
await emit({ type: "turn_end", message, toolResults });关键在 hasMoreToolCalls = false 这一行:它先无条件置假,只有在真的执行了工具、且工具批次没要求终止时才会被改回 true(agent-loop.ts:216)。我们的消息里没有 toolCall,整个 if 被跳过,于是:
图 5.2-3 Agent Loop 的四个终止出口
这张图把 runLoop 的退出条件全画出来了。本章的普通请求走的是最下面那条:无 toolCall、无队列消息,从 break 走到函数最后一行的 agent_end。另外三个出口分别对应 5.6 的取消与错误、5.3 的工具 terminate、以及 Pi 通过 shouldStopAfterTurn 实现的额外判定。
对应源码:shouldStopAfterTurn 判定在 agent-loop.ts:247-257;steering 队列在 agent-loop.ts:259 轮询;follow-up 队列在 agent-loop.ts:263-268;break 在 agent-loop.ts:271;最终那句 await emit({ type: "agent_end", messages: newMessages }) 在 agent-loop.ts:274。
agent_end 顺着回程走完最后一段:Agent.processEvents 更新状态(agent.ts:564-566)并 await 所有监听器 → AgentSession._handleAgentEvent 补上 willRetry 字段 → InteractiveMode.handleEvent 的 agent_end 分支(interactive-mode.ts:3082-3095)关掉终端进度指示、清掉状态指示器、清空 streamingComponent 引用。之后 Agent.finishRun()(agent.ts:514-520)把 isStreaming 置假,_runAgentPrompt 的 finally 发出 agent_settled,主循环里的 await this.session.prompt(userInput) 才终于返回——回到 while (true) 顶端,重新挂起在 getUserInput() 上,等你敲下一句话。
packages/agent/src/types.ts:418-420)明确写道:agent_end 只代表不会再有循环事件了;被 await 的订阅者仍属于本次 run 的结算过程,Agent 要等这些监听器全部跑完、finishRun() 清完状态才算空闲。这解释了为什么一个慢监听器会让整个 prompt() 迟迟不返回。 相关测试:packages/agent/test/agent-loop.test.ts:119-164 的用例 "should emit events with AgentMessage types" 正是本章这条路径的最小化版本——喂一个只发 done 的假 streamFn,断言事件序列里出现 agent_start、turn_start、message_start、message_end、turn_end、agent_end,且最终 messages 恰好两条(user + assistant)。同文件第 84 行起的 describe("default stream function compatibility") 则专门验证「省略 streamFn 时会回退到 setDefaultStreamFn 装的那个」。
实践任务
目标:不依赖任何 API Key,用全文搜索亲手确认本章两个最关键的交接点真实存在——① 交互模式主循环确实调用 session.prompt;② streamFn 的默认注册表确实被 coding-agent 安装、并被 agent 核心取用。
前置:进入本书锁定的源码目录(或你自己的 Pi 克隆):cd _sources/pi。
步骤 1 · 找主循环的调用点
grep -rn "this.session.prompt(" packages/coding-agent/src/modes/interactive/interactive-mode.ts预期现象:在本书锁定的 commit 上共 11 行结果。请在其中找到 :940(主循环那一处,它上面两行就是 const userInput = await this.getUserInput();)。另外注意 :2856 带着 { streamingBehavior: "steer" }——那是流式期间插队的分支;:918 则是启动时发送 initialMessage 的分支。
如何判断成功:你能说出这三处的区别——「空闲时走主循环」「流式时走 steer」「启动时直接发」,并能指出 :940 之所以能被唤醒,是因为 :2867 调用了 onInputCallback。
步骤 2 · 找默认 streamFn 的安装点与取用点
grep -rn "setDefaultStreamFn\|getDefaultStreamFn" packages/agent/src packages/coding-agent/src --include="*.ts"预期现象:在本书锁定的 commit 上共 11 行结果,分布在 5 个文件里。安装点只有一处:packages/coding-agent/src/core/sdk.ts:36。取用点有三处,都写成 ?? getDefaultStreamFn():agent.ts:216、agent-loop.ts:116、agent-loop.ts:141。
如何判断成功:你能回答这个问题——「Pi 自己跑的时候,用的是这个默认注册表里的函数吗?」答案是「不是」:sdk.ts:302 在 new Agent 时显式传了 streamFn,所以 agent.ts:216 的 ?? 右侧不会被求值;注册表是给不传 streamFn 的旧扩展兜底的。
常见错误:① 忘记 --include="*.ts",会把 dist/ 或 node_modules/ 里的编译产物一起搜出来,结果行数对不上;② 把整个搜索模式的双引号漏掉,| 会被 shell 当成管道;③ 结果行数与书中不同,多半是你的克隆不在本书锁定的 commit 上,用 git log -1 --format=%H 对一下页面顶部徽章里的版本号。
对应源码位置:packages/coding-agent/src/modes/interactive/interactive-mode.ts:936-945、:2862-2872、:3542-3553;packages/agent/src/stream-fn.ts:3-20;packages/coding-agent/src/core/sdk.ts:33-36 与 :294-312。
本章小结
- 一次普通请求经过七次交接:编辑器 → 主循环 →
AgentSession.prompt→Agent.prompt→runLoop→streamFn→ Provider,再原路把事件送回屏幕;去程与回程是重叠的。 - 交互模式用「挂起的 Promise +
onInputCallback」把事件驱动的终端缝合成顺序的 REPL;主循环里的try/catch是给预检失败(无模型、无凭证)准备的。 AgentSession.prompt是预检层:扩展命令、input事件、技能与模板展开、流式排队、模型与凭证校验,全部通过后才组装user消息交给Agent。Agent类只管生命周期、状态与事件分发;循环逻辑全在runLoop里。两者之间的接口就是runAgentLoop的五个参数。streamFn是 agent 核心与 Provider 之间唯一的缝:它不许抛异常,错误必须编码进流。Pi 走的是构造Agent时显式注入的闭包,模块级注册表只作旧扩展的兜底。- 事件回程有三层信封:
AssistantMessageEvent→AgentEvent(message_update裹着前者)→AgentSessionEvent→ UI 组件树。 - 没有
toolCall时hasMoreToolCalls保持false,内层循环条件不成立;两个队列都空则break,最终发出agent_end。 - 关键术语:交接点、一个 turn、StreamFn、事件信封、run 的结算(settlement)。
- 关键源码索引:
interactive-mode.ts:936-945(主循环)、:2862-2872(提交)、:3542-3553(挂起)、agent-session.ts:1114(prompt)、:1177-1195(模型与凭证校验)、:1061(_runAgentPrompt)、:594(事件回流)、agent.ts:398(runPromptMessages)、agent-loop.ts:170(双层循环)、:288-312(streamFn调用)、:274(agent_end)、stream-fn.ts:11-20、sdk.ts:36与:294-312。 - 自测问题:① 如果你在模型正在输出时又敲了一句话回车,代码会走到本章的哪一条分支?为什么它不会唤醒主循环?②
message_update事件带的message字段是增量还是累积后的完整消息?这对 UI 组件的写法有什么影响?③runLoop第一次进内层循环时hasMoreToolCalls为什么要硬编码成true?④ 一个执行很慢的subscribe监听器,会不会拖慢session.prompt()的返回? - 下一章:5.3 一次 Tool Call 的完整循环——把本章跳过的那个
if (toolCalls.length > 0)展开,看内层循环如何转第二圈。 - 本章尚未展开的内容:
convertToLlm如何处理 Pi 自定义的消息类型、pi-ai 内部的 SSE 解析与 Provider 分派(6.1)、message_end之后会话文件到底写了什么(5.5)、TUI 的差分渲染(6.9)、以及 steering / follow-up 两个队列的完整语义(6.3)。