5.2 一次普通请求的完整路径
本页分析版本earendil-works/pi@16787ad2026-09-21本章解决什么问题:你在交互界面里敲下一句话、按回车,屏幕上开始逐字长出回答——这中间到底经过了哪些函数?本章把这条路径一站一站走完,每一站都给出真实的文件名和行号。 前置知识: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 | TranscriptContext | 事件流 | 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」是事件驱动与顺序循环的缝合点;第 9、10 步是每次请求模型之前,AgentSession 用会话条目重新投影出的消息替换掉循环手里的上下文;第 14 到 18 步是同一份数据被反复重新包装的回程。
静态时序图适合看全貌,但不适合回答“这一站手里的对象到底变成了什么”。下面选择纯文本、工具调用或扩展拦截,再逐站前进;每一站都给出输入形状、输出形状、可见事件、写盘时机和锁定源码入口。尤其留意:message_end 到达 AgentSession 后,是先经过扩展、再通知界面、最后立即追加会话条目,并不是等 agent_end 才保存整批消息。
调用链追踪器已就绪。
一次请求的源码调用链追踪器
沿着真实函数与对象变形走,不把 package 图误当成实际调用顺序最短主链:一次模型请求,不进入工具分支。
这是功能入口。鉴权、预提示压缩和扩展输入钩子都在交给 Agent 之前完成。
- 输入形状
用户输入的 string- 输出形状
展开命令、Skill 与模板后的 AgentMessage[](系统提示词或工具有变化时,前面再加一条 system 消息)- 可见事件
- input / before_agent_start
- 会话持久化
- 尚未写盘
packages/coding-agent/src/core/agent-session.ts:1606
后面几小节就按这张图逐段拆开。每一段都会先说「这一站在做什么」,再给源码位置。
第 1 棒:从按键到一个字符串
Pi 的交互模式(interactive mode)看起来是一个 REPL:你打字、回车、等回答、再打字。但终端程序天生是事件驱动的——按键随时到达,程序不能傻等。Pi 用一个很小的技巧把两者缝在一起。
先看终点。InteractiveMode.run() 的最后是一个朴素得出奇的循环:
// packages/coding-agent/src/modes/interactive/interactive-mode.ts:1184-1193
// 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:4093-4104
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:1361-1375;触发点在 editor.ts:906-920,匹配键位 tui.input.submit,默认就是 Enter),它清空编辑器并调用 this.onSubmit(result)(editor.ts:1374)。这个 onSubmit 是 InteractiveMode 在启动时装上去的一个大闭包(interactive-mode.ts:3078-3275),末尾几行才是我们这条路径关心的:
// packages/coding-agent/src/modes/interactive/interactive-mode.ts:3265-3274
// (前面省略:斜杠命令分派、! 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:3256-3263):如果此刻 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:1606-1625(节选)
async prompt(text: string, options?: PromptOptions): Promise<void> {
// …(省略:若正处在 agent_settled 的派发过程中,先把这次调用推迟到派发结束)
const expandPromptTemplates = options?.expandPromptTemplates ?? true;
const preflightResult = options?.preflightResult;
let messages: AgentMessage[] | undefined;
try {
// Handle extension commands first (execute immediately, even during streaming)
// Extension commands manage their own LLM interaction via pi.sendMessage()
if (expandPromptTemplates && text.startsWith("/")) {
const handled = await this._tryExecuteExtensionCommand(text);
if (handled) {
// Extension command executed, no prompt to send
preflightResult?.(true);
return;
}
}
// …(省略:压缩进行中则拒绝、input 扩展事件、skill 与模板展开、流式期排队分支)async prompt四道关依次是:
- 扩展命令(
agent-session.ts:1618-1625):扩展注册的斜杠命令自己管理与模型的交互,命中即返回。 input扩展事件(agent-session.ts:1633-1644):扩展可以拦截(handled)或改写(transform)这句话。在它之前还有一道硬性检查:如果上下文压缩正在进行,直接抛错让你稍后重试(agent-session.ts:1627-1631)。- 技能与模板展开(
agent-session.ts:1646-1651):/skill:name args与/template args在这里被替换成真正的提示词(Prompt)。 - 流式期分支(
agent-session.ts:1653-1667):如果 Agent 正忙且调用方没写streamingBehavior,直接抛错。
我们的实验对象是一句普通的话,四道关全部放行。接着是这条路径上最容易被读者忽视、却最常在实际使用中报错的一段——模型与凭证校验:
// packages/coding-agent/src/core/agent-session.ts:1673-1691(节选)
// 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:1187-1192)存在的原因:预检失败不应该让整个交互模式退出,只该在屏幕上印一行错误,然后继续等下一句输入。
过关之后,prompt() 先触发 before_agent_start 扩展事件(agent-session.ts:1703),再组装真正要送进 Agent 的消息数组(agent-session.ts:1720-1746:一条 user 消息,外加此前排队的 nextTurn 消息和扩展在 before_agent_start 里附带的消息)。
最后还有一步容易漏看:_preparePromptAndToolLoadout(agent-session.ts:1407-1421)把这次要用的系统提示词(System Prompt)按分段重新构建一遍,和对话里已经生效的那一版逐段比较;只要有差异,就生成一条 role: "system" 的消息,插到数组最前面(agent-session.ts:1747-1749)。在新会话的第一句话上,对话里还没有任何系统消息,于是整份系统提示词都会以这条消息的形式进入对话;之后只有提示词真的变了(比如启用的工具变了)才会再补一条只含变化分段的系统消息。换句话说,系统提示词并不是请求旁边的一个单独字段,而是对话记录里的一条消息,这一点在第 5 棒还会再见到。
然后交棒:
// packages/coding-agent/src/core/agent-session.ts:1468-1490
private async _runAgentPrompt(messages: AgentMessage | AgentMessage[]): Promise<void> {
this._agentRunAbortRequested = false;
this._isAgentRunActive = true;
try {
await this.agent.prompt(messages);
while (!this._agentRunAbortRequested) {
if (await this._handlePostAgentRun()) {
if (this._agentRunAbortRequested) break;
await this.agent.continue();
continue;
}
if (this._agentRunAbortRequested || !(await this._runBeforeSettleBoundary())) break;
if (this._agentRunAbortRequested) break;
await this.agent.continue();
}
} finally {
// …(省略:取消重试的收尾、清掉本次 run 的提示词选项、冲刷排队的 bash 与自定义消息)
await this._emitAgentSettled();
}
}_runAgentPrompt那个 while 循环是 Pi 在 agent 核心之上加的一层,每一圈先问两件事:_handlePostAgentRun(agent-session.ts:1492-1529)管自动重试、自动压缩后续跑、以及 agent_end 监听器新排进队列的消息;都不需要时,再由 _runBeforeSettleBoundary(agent-session.ts:1531-1553)发出 agent_before_settle 扩展事件,扩展可以在 Pi 空闲之前要求再跑一轮。对我们这次普通请求来说,两者都返回 false,循环第一圈就 break。
第 3 棒:Agent 类只做三件事
进入 packages/agent 包后,画风突然变得很干净。Agent.prompt() 全文只有两个动作:拒绝并发,然后转给内部方法(packages/agent/src/agent.ts:370-378)。而内部方法也只是把五样东西凑齐后调低层循环:
// packages/agent/src/agent.ts:429-443
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():当前的messages/tools快照(agent.ts:457-462)。系统提示词不单列,它就在messages里的系统消息中。createLoopConfig():模型、convertToLlm、两个队列的排水函数、工具钩子,以及prepareRequest、finishTurn、prepareNextTurn这几个挂在循环关键节点上的钩子(agent.ts:464-501)。(event) => this.processEvents(event):事件出口。循环里每一次emit最终落到这里(agent.ts:561-608),先更新 Agent 内部状态,再按注册顺序await所有订阅者。signal:由runWithLifecycle创建的AbortController提供(agent.ts:503-526)。
从源码结构看,Agent 类做的就三件事:管一次 run 的生命周期(不许并发、失败兜底、结束清理)、持有可变状态(消息记录、是否在流式)、分发事件。真正的循环逻辑一行都不在这个类里。
第 4 棒:runLoop 的第一轮
runAgentLoop 是个薄函数:它先用 declareToolChanges(agent-loop.ts:332-362)核对「本次能执行的工具」与「对话里已经声明给模型的工具」,有差异就写进系统消息的 toolsAdded / toolsRemoved 字段;然后发 agent_start 和 turn_start,再把每条 prompt 消息(系统消息与用户消息)各发一对 message_start / message_end,最后进入 runLoop(packages/agent/src/agent-loop.ts:109-123)。注意这个顺序——这些消息的 message_end 事件就是 Pi 把它们追加成会话条目的时机(agent-session.ts:933-940),而且发生在第一次请求模型之前。(条目先进 SessionManager 的内存;新会话要等第一条 assistant 消息出现才真正创建并写入会话文件,见 session-manager.ts:1160-1185。)
runLoop 是全书最该读懂的一个函数。它是两层 while:
// packages/agent/src/agent-loop.ts:178-255(节选,省略处不改变控制流)
while (true) {
let hasMoreToolCalls = true;
// Inner loop: process tool calls and steering messages
while (hasMoreToolCalls || pendingMessages.length > 0) {
if (lastCompletedTurn) {
// …(省略:prepareNextTurn 为下一轮准备上下文、补收 steering 消息)
await emit({ type: "turn_start" });
}
// …(省略:把准备好的消息与 pendingMessages 注入上下文并各发一对 message 事件)
const requestUpdate = await config.prepareRequest?.(
{ context: currentContext, model: config.model, thinkingLevel: config.reasoning ?? "off" },
signal,
);
// …(省略:requestUpdate 可以替换 context、model 与思考等级)
// Stream assistant response
const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction);
newMessages.push(message);
if (message.stopReason === "error" || message.stopReason === "aborted") {
// …(省略:记下 lastCompletedTurn 并调用 finishTurn)
await emit({ type: "turn_end", message, toolResults: [] });
await emit({ type: "agent_end", messages: newMessages });
return;
}hasMoreToolCalls(为了排版,摘录把 prepareRequest 的参数对象压成了一行,源码里是分行写的,agent-loop.ts:218-225。)
第一次进内层循环时 hasMoreToolCalls 被硬编码为 true(agent-loop.ts:179),这是个小技巧:它保证无论如何都至少请求模型一次。lastCompletedTurn 在第一圈还是 undefined,所以第一圈既不调 prepareNextTurn,也不重复发 turn_start(runAgentLoop 已经发过一次了);只有上一轮确实结束、要开启新一轮时,才会先准备、再发 turn_start。
prepareRequest 则不同:它在每一次请求模型之前都会被调用,第一次也不例外(agent-loop.ts:218-238)。Pi 的 AgentSession 在这里装了一个钩子(agent-session.ts:608-633),把 context.messages 整个换成 sessionManager.buildSessionProjection().messages——也就是按会话条目重新投影出来的消息列表。所以真正发给模型的历史以 SessionManager 为准,而不是 Agent 内存里的 state.messages;直接给 session.agent.state.messages 赋值,不会改变后续请求带上的历史。这也是上一站强调 message_end 时机的原因:系统消息与用户消息在那时已经追加进 SessionManager,投影里自然有它们。
一个必须先建立的概念:
packages/agent/src/types.ts:489 的注释)。本章的普通请求只有一个 turn;下一章的工具调用会让内层循环转多圈,也就是多个 turn。 第 5 棒:streamAssistantResponse 与那个叫 streamFn 的参数
内层循环里唯一「离开本进程」的一步是 streamAssistantResponse。它做三件事:把 AgentMessage[] 变成大语言模型(LLM)认识的 Message[]、解析 API Key、调用 streamFn。
// packages/agent/src/agent-loop.ts:387-406
// 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 = normalizeContext({ messages: llmMessages });
// 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注意 llmContext 里只有 messages 一个字段。normalizeContext(packages/ai/src/utils/transcript.ts:30-34)产出的 TranscriptContext(packages/ai/src/types.ts:631-634)是 pi-ai 规定的、Provider 唯一接受的请求形状:系统提示词和工具声明都由消息列表里的系统消息(SystemMessage,packages/ai/src/types.ts:491-506)携带,按顺序重放所有系统消息,就能得到当前的提示词与工具集。这就是第 2 棒插进来的那条系统消息最终的去处。各个 Provider 再按自己的能力处理它:支持对话中途出现系统消息的,就原地发送;不支持的,就从重放结果重建开头那一条(types.ts:483-489 的注释)。
这里有一个对理解 Pi 架构至关重要的事实:packages/agent 包不认识任何 Provider。它只知道有个符合下面这个签名的函数可以调用。
packages/agent/src/types.ts:33-37:(model, context, options?) => AssistantMessageEventStream | Promise<…>,其中 context 的类型是 TranscriptContext。它的契约写在同文件 27-31 行的注释里:不许 throw,也不许返回 rejected 的 Promise;请求失败必须编码进返回的流里,最终产出一条 stopReason 为 "error" 或 "aborted" 的 assistant 消息。这个契约解释了为什么 runLoop 里没有 try/catch 包住 streamAssistantResponse——错误是当作数据回来的,而不是当作异常。 那这个函数具体是谁?有两条来源,两条都要知道。
来源一:构造 Agent 时显式注入。 这是 Pi 自己走的路。createAgentSession() 在 new Agent({...}) 时给了一个闭包:
// packages/coding-agent/src/core/sdk.ts:366-386(节选)
const agent = new Agent({
initialState: {
systemPrompt: "",
model,
thinkingLevel,
tools: [],
messages: existingSession.messages,
},
convertToLlm: convertToLlmWithBlockImages,
streamFn: async (model, context, options) => {
const requestOptions = buildRequestOptions(model, options);
// …(省略:本会话的请求会顺带启动提示词缓存预热 cacheWarmer)
return modelRuntime.streamSimple(model, context, requestOptions);
},streamFninitialState 里的 systemPrompt 是空字符串、tools 是空数组:Agent 只在两者至少有一个非空时,才会把它们折成开头的一条系统消息(packages/agent/src/agent.ts:77-86),这里两者都为空,所以 Pi 的系统提示词不走这里,而是走第 2 棒那条由 AgentSession 生成的系统消息。buildRequestOptions(sdk.ts:311-337)从设置里取超时、WebSocket 连接超时、重试上限,并装上请求头改写钩子。
ModelRuntime.streamSimple(packages/coding-agent/src/core/model-runtime.ts:638-644)先用 normalizeContext 确保入参是 TranscriptContext,再在 prepareRequest(model-runtime.ts:574 起)里从 pi-ai 的 Provider 注册表取出对应 provider、解析鉴权,最后直接调用该 provider 的 streamSimple。pi-ai 自己的 Models.streamSimple(packages/ai/src/models.ts:703-710)走的也是「规整上下文 → 解析鉴权 → 交给 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:39,其中 streamSimple 来自 @earendil-works/pi-ai/compat,见 sdk.ts:4)。取用点有三处:Agent 构造函数(packages/agent/src/agent.ts:234)、runAgentLoop(agent-loop.ts:123)、runAgentLoopContinue(agent-loop.ts:148),写法都是 streamFn ?? getDefaultStreamFn()。源码注释(sdk.ts:36-38)说明这是为 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:652-668)共 12 种:start、三组 *_start / *_delta / *_end(text、thinking、toolcall)、以及二选一的终止事件 done / error。除两个终止事件外,每个事件都带 partial 字段——当前已累积的 assistant 消息;终止事件则分别用 message / error 字段带上最终消息。
第二层,agent 包的 AgentEvent(packages/agent/src/types.ts:485-500)共 10 种。streamAssistantResponse 用 for await 消费第一层,映射成第二层(agent-loop.ts:411-438):start → message_start;九种增量事件 → 同一个 message_update,原事件放进 assistantMessageEvent 字段;done / error → 先 await response.result() 拿到最终消息,再发 message_end(agent-loop.ts:440-453)。
第三层,AgentSessionEvent(packages/coding-agent/src/core/agent-session.ts:164-205)。AgentSession 在构造时就订阅了 Agent(agent-session.ts:434),处理函数是:
// packages/coding-agent/src/core/agent-session.ts:894-942(节选)
private _handleAgentEvent = async (event: AgentEvent): Promise<void> => {
// …(省略:user 消息出现时把它从 steering/followUp 队列里摘掉)
// Emit to extensions first, then notify public listeners.
await this._emitExtensionEvent(event);
this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);
// Handle session persistence
if (event.type === "message_end") {
// …(省略:custom 消息走 appendCustomMessageEntry;system、user、assistant、toolResult 走 appendMessage)_handleAgentEvent这三步的顺序是有含义的:扩展先看到事件(可以做记录或副作用),UI 再看到,会话持久化最后做。_emit(agent-session.ts:831-835)只是同步遍历监听器数组。
最后一层,UI。 InteractiveMode.subscribeToAgent()(interactive-mode.ts:3278-3282)注册唯一的监听器,转给一个大 switch(interactive-mode.ts:3284 起)。对本章的路径来说三个分支最关键:
message_start且role === "assistant"(interactive-mode.ts:3385-3398):新建一个AssistantMessageComponent挂进聊天容器。message_update(interactive-mode.ts:3401-3404):先this.streamingMessage = event.message,再streamingComponent.updateContent(this.streamingMessage, true)(第二个参数表示仍在流式中)——注意传的是event.message(累积后的完整消息),不是增量。所以组件每次都按完整内容重建子组件,真正的「只重画变化的行」交给 TUI 的差分渲染(6.9)。message_end(interactive-mode.ts:3436-3476):最后渲染一次,然后把streamingComponent置为undefined(interactive-mode.ts:3471-3472)——组件本身留在聊天记录里,只是不再是「正在流式的那个」。
没有 toolCall,循环怎么停下来
回到 runLoop。streamAssistantResponse 返回后,循环要判断还要不要再来一轮:
// packages/agent/src/agent-loop.ts:257-291(节选)
const toolCalls = message.content.filter((c) => c.type === "toolCall");
const toolResults: ToolResultMessage[] = [];
hasMoreToolCalls = false;
if (toolCalls.length > 0) {
// …(省略:截断保护与 executeToolCalls,见 5.3)
}
lastCompletedTurn = {
message,
toolResults,
context: currentContext,
newMessages,
};
const decision = await config.finishTurn?.(lastCompletedTurn, signal);
await emit({ type: "turn_end", message, toolResults });
if (decision?.action === "end") {
await emit({ type: "agent_end", messages: newMessages });
return;
}关键在 hasMoreToolCalls = false 这一行:它先无条件置假,只有在真的执行了工具、且工具批次没要求终止时才会被改回 true(agent-loop.ts:271)。我们的消息里没有 toolCall,整个 if 被跳过。紧接着,这一轮在发出 turn_end 之前先交给 finishTurn 钩子「定稿」:钩子返回 { action: "end" } 就立即结束整次 run,返回 { action: "continue" } 则要求循环至少再请求一次模型。于是:
图 5.2-3 Agent Loop 的三个终止出口
这张图把 runLoop 的退出条件全画出来了。本章的普通请求走的是最下面那条:无 toolCall、无队列消息、finishTurn 也没有要求续跑,从 break 走到函数最后一行的 agent_end。E1 对应 5.6 的取消与错误;E2 是 finishTurn 返回 end 的提前结束;5.3 的工具 terminate 并不单独成为出口,它只是让 hasMoreToolCalls 保持 false,最终同样从 E3 离开。
对应源码:finishTurn 的调用与 end 判定在 agent-loop.ts:279-291;steering 队列在 agent-loop.ts:294 轮询;follow-up 队列在 agent-loop.ts:301-307;continue 决定在 agent-loop.ts:293 记下、在 agent-loop.ts:309-313 兑现(此时没有新消息,只带现有上下文再请求一次);break 在 agent-loop.ts:316;最终那句 await emit({ type: "agent_end", messages: newMessages }) 在 agent-loop.ts:319。Pi 的 AgentSession 就是通过 finishTurn 把 turn_end 扩展事件接进循环的(agent-session.ts:675-685):扩展要求续跑时它返回 { action: "continue" };它自己不会产生 end,只会原样转交更早装上的 finishTurn 钩子给出的 end。
agent_end 顺着回程走完最后一段:Agent.processEvents 更新状态(agent.ts:596-598)并 await 所有监听器 → AgentSession._handleAgentEvent 补上 willRetry 字段 → InteractiveMode.handleEvent 的 agent_end 分支(interactive-mode.ts:3525-3538)关掉终端进度指示、清掉状态指示器、清空 streamingComponent 引用。之后 Agent.finishRun()(agent.ts:546-552)把 isStreaming 置假;_runAgentPrompt 的循环确认既不用重试、也没有扩展要求续跑,于是在 finally 里发出 agent_settled,主循环里的 await this.session.prompt(userInput) 才终于返回——回到 while (true) 顶端,重新挂起在 getUserInput() 上,等你敲下一句话。
packages/agent/src/types.ts:481-483)明确写道:agent_end 只代表不会再有循环事件了;被 await 的订阅者仍属于本次 run 的结算过程,Agent 要等这些监听器全部跑完、finishRun() 清完状态才算空闲。这解释了为什么一个慢监听器会让整个 prompt() 迟迟不返回。 相关测试:packages/agent/test/agent-loop.test.ts:121-165 的用例 "should emit events with AgentMessage types" 正是本章这条路径的最小化版本——喂一个只发 done 的假 streamFn,断言事件序列里出现 agent_start、turn_start、message_start、message_end、turn_end、agent_end,且最终 messages 恰好两条(user + assistant)。紧接着的 "should build provider context exclusively from transcript messages"(agent-loop.test.ts:166-203)则断言 streamFn 收到的上下文只有 messages 一个键、第一条就是传入的系统消息——正是第 5 棒讲的 TranscriptContext。同文件第 86 行起的 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 行结果。请在其中找到 :1188(主循环那一处,它上面两行就是 const userInput = await this.getUserInput();)。另外注意 :3259 带着 { streamingBehavior: "steer" }——那是流式期间插队的分支;:1166 则是启动时发送 initialMessage 的分支。
如何判断成功:你能说出这三处的区别——「空闲时走主循环」「流式时走 steer」「启动时直接发」,并能指出 :1188 之所以能被唤醒,是因为 :3270 调用了 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:39。取用点有三处,都写成 ?? getDefaultStreamFn():agent.ts:234、agent-loop.ts:123、agent-loop.ts:148。
如何判断成功:你能回答这个问题——「Pi 自己跑的时候,用的是这个默认注册表里的函数吗?」答案是「不是」:sdk.ts:375 在 new Agent 时显式传了 streamFn,所以 agent.ts:234 的 ?? 右侧不会被求值;注册表是给不传 streamFn 的旧扩展兜底的。
常见错误:① 忘记 --include="*.ts",会把 dist/ 或 node_modules/ 里的编译产物一起搜出来,结果行数对不上;② 把整个搜索模式的双引号漏掉,| 会被 shell 当成管道;③ 结果行数与书中不同,多半是你的克隆不在本书锁定的 commit 上,用 git log -1 --format=%H 对一下页面顶部徽章里的版本号。
对应源码位置:packages/coding-agent/src/modes/interactive/interactive-mode.ts:1184-1193、:3265-3275、:4093-4105;packages/agent/src/stream-fn.ts:3-20;packages/coding-agent/src/core/sdk.ts:36-39 与 :366-386。
本章小结
- 一次普通请求经过七次交接:编辑器 → 主循环 →
AgentSession.prompt→Agent.prompt→runLoop→streamFn→ Provider,再原路把事件送回屏幕;去程与回程是重叠的。 - 交互模式用「挂起的 Promise +
onInputCallback」把事件驱动的终端缝合成顺序的 REPL;主循环里的try/catch是给预检失败(无模型、无凭证)准备的。 AgentSession.prompt是预检层:扩展命令、input事件、技能与模板展开、流式排队、模型与凭证校验,全部通过后才组装user消息交给Agent;系统提示词有变化时,会以一条系统消息的形式排在最前面一起进入对话。Agent类只管生命周期、状态与事件分发;循环逻辑全在runLoop里。两者之间的接口就是runAgentLoop的五个参数。- 每次请求模型之前,
prepareRequest钩子都会被调用;Pi 在这里用SessionManager的会话投影替换上下文,所以发给模型的历史以会话条目为准。 streamFn是 agent 核心与 Provider 之间唯一的缝:它收到的是只含messages的TranscriptContext,系统提示词与工具声明都在系统消息里;它不许抛异常,错误必须编码进流。Pi 走的是构造Agent时显式注入的闭包,模块级注册表只作旧扩展的兜底。- 事件回程有三层信封:
AssistantMessageEvent→AgentEvent(message_update裹着前者)→AgentSessionEvent→ UI 组件树。 - 没有
toolCall时hasMoreToolCalls保持false,内层循环条件不成立;两个队列都空、finishTurn也没要求续跑,则break,最终发出agent_end。 - 关键术语:交接点、一个 turn、StreamFn、TranscriptContext、系统消息(System Message)、事件信封、run 的结算(settlement)。
- 关键源码索引:
interactive-mode.ts:1184-1193(主循环)、:3265-3275(提交)、:4093-4105(挂起)、agent-session.ts:1606(prompt)、:1673-1691(模型与凭证校验)、:1407(系统消息差分)、:1468(_runAgentPrompt)、:608(prepareRequest投影)、:894(事件回流)、agent.ts:429(runPromptMessages)、agent-loop.ts:178(双层循环)、:218(prepareRequest调用)、:387-406(streamFn调用)、:319(agent_end)、stream-fn.ts:11-20、sdk.ts:39与:366-386。 - 自测问题:① 如果你在模型正在输出时又敲了一句话回车,代码会走到本章的哪一条分支?为什么它不会唤醒主循环?②
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)。
✅ 自测问题参考答案先自己回答,再点开对照
- 走的是提交处理函数里
if (this.session.isStreaming)那条分支(interactive-mode.ts:3256-3263):它直接await this.session.prompt(text, { streamingBehavior: "steer" }),把这句话排进 steering 队列,然后return。它不会唤醒主循环,是因为主循环挂起的那个 Promise 只由onInputCallback兑现,而onInputCallback(text)只在下面「Normal message submission」那一段才会被调用(:3269-3273)——steer 分支在到达那里之前就return了。所以主循环仍然安静地挂在getUserInput()上,等这一轮结束。 - 是累积后的完整消息,不是增量——
interactive-mode.ts:3401-3404传给组件的是event.message。对 UI 写法的影响是:组件每次都按完整内容重建子组件,不必自己维护「把 delta 一块块拼起来」的累加状态,逻辑简单也不会拼错;代价是每次都产出整棵子树,所以「只重画真正变化的行」这件事被下放给 TUI 的差分渲染去做(6.9)。 - 因为内层循环的条件是
while (hasMoreToolCalls || pendingMessages.length > 0),而第一次进来时还没有任何工具调用、队列通常也是空的——如果初值是false,条件当场不成立,一次模型都不会请求。硬编码成true就是「先无条件转一圈」的意思。转完之后它会被无条件置回false(agent-loop.ts:261),只有真的执行了工具、且工具批次没要求终止时才改回true。 - 会。
agent_end只代表不会再有循环事件了,被await的订阅者仍属于本次 run 的结算过程(源码注释在packages/agent/src/types.ts:481-483)。事件分发是await每个监听器的,慢监听器会一直拖到它自己跑完,Agent 才能finishRun()清状态、发出agent_settled,session.prompt()也才返回。所以订阅者里做耗时的事(同步写大文件、发网络请求)会直接表现为「回答显示完了但输入框迟迟不回来」。