Skip to content

5.2 一次普通请求的完整路径

本页分析版本earendil-works/pi@c13ffe12026-07-30

本章解决什么问题:你在交互界面里敲下一句话、按回车,屏幕上开始逐字长出回答——这中间到底经过了哪些函数?本章把这条路径一站一站走完,每一站都给出真实的文件名和行号。 前置知识4.4 从哪里开始读源码(主链路轮廓)、3.5 Agent 与 Agent Loop3.3 流式输出2.6 异步迭代器与 for await学习目标:读完后你能 ① 说出从按键到回答显示经过的七个交接点,并在源码里找到每一个;② 解释「交互界面是事件驱动的,主循环却是顺序的」这件事是怎么缝合的;③ 说清 streamFn 这个参数是从哪里来的、最终打到了哪个函数;④ 讲明白为什么「没有工具调用」时 Agent Loop 只转一圈就停。

本章的实验对象:一句不触发工具的话

想象你在 Pi 的交互界面里输入:

text
用一句话解释什么是 Agent Loop

模型只回文字,不读文件、不跑命令、不发起任何工具调用(Tool Calling)。这是 Pi 能跑的最短的一条完整路径:没有回环、没有工具执行、没有上下文压缩(Context Compaction)。

为什么先看它?因为 Agent 的所有复杂度都是在这条主干上加分支长出来的。工具调用是主干上多绕一圈(5.3),取消是在主干上插一个 AbortSignal(5.6),会话保存是在主干旁边挂一个写盘动作(5.5)。先把主干背下来,分支才有地方挂。

🌱 初学者提示没有 API Key 也能读懂本章
本章不需要你真的向模型发出请求。所有结论都来自源码,本章末尾的实践任务也只用 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编辑器与主循环按键字节stringpackages/tui + packages/coding-agent
2AgentSession.promptstringAgentMessage[]packages/coding-agent
3Agent.promptAgentMessage[]一次 run 的生命周期packages/agent
4runLoop上下文 + 配置循环控制packages/agent
5streamFnContext事件流packages/coding-agentpackages/ai
6Provider 与 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() 的最后是一个朴素得出奇的循环:

ts
// 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);
	}
}
earendil-works/pi@c13ffe1第 936–945 行在 GitHub 查看 ↗
交互模式主循环:拿一句输入,交给 session.prompt,出错就在界面上显示错误后继续下一轮。

getUserInput() 就是那个缝合点。它并不去读标准输入,而是返回一个悬而未决的 Promise,并把这个 Promise 的 resolve 函数存进实例字段 onInputCallback

ts
// 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)。这个 onSubmitInteractiveMode 在启动时装上去的一个大闭包(interactive-mode.ts:2686-2872),末尾几行才是我们这条路径关心的:

ts
// 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);
earendil-works/pi@c13ffe1第 2862–2872 行在 GitHub 查看 ↗
编辑器提交闭包的最后一段:空闲时直接唤醒主循环挂起的 Promise,否则把文本排进队列等下一轮取。

至此第 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)门面」:它拥有 AgentSessionManagerSettingsManager、扩展运行器等一堆东西,对外只暴露若干高层动作。prompt() 是其中最重要的一个,它在把文本交给 Agent 之前要过四道关:

ts
// 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 与模板展开、流式期排队分支)
earendil-works/pi@c13ffe1第 1114–1129 行在 GitHub 查看 ↗
第一道关:以 / 开头的文本先给扩展(Extension)注册的命令处理,命中就直接返回,根本不会走到模型。

四道关依次是:

  1. 扩展命令agent-session.ts:1122-1129):扩展注册的斜杠命令自己管理与模型的交互,命中即返回。
  2. input 扩展事件agent-session.ts:1134-1149):扩展可以拦截(handled)或改写(transform)这句话。
  3. 技能与模板展开agent-session.ts:1151-1156):/skill:name args/template args 在这里被替换成真正的提示词(Prompt)。
  4. 流式期分支agent-session.ts:1158-1172):如果 Agent 正忙且调用方没写 streamingBehavior,直接抛错。

我们的实验对象是一句普通的话,四道关全部放行。接着是这条路径上最容易被读者忽视、却最常在实际使用中报错的一段——模型与凭证校验:

ts
// 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));
}
earendil-works/pi@c13ffe1第 1177–1195 行在 GitHub 查看 ↗
发请求前的两项校验:有没有选中模型、这个 provider 有没有可用凭证。抛出的错误会被主循环的 catch 接住并显示在界面上。

从源码结构看,这两个 throw 正是主循环里那个 try/catchinteractive-mode.ts:939-944)存在的原因:预检失败不应该让整个交互模式退出,只该在屏幕上印一行错误,然后继续等下一句输入。

过关之后,prompt() 组装出真正要送进 Agent 的消息数组(agent-session.ts:1204-1222:一条 user 消息,外加此前排队的 nextTurn 消息),触发 before_agent_start 扩展事件(agent-session.ts:1225),最后交棒:

ts
// 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();
	}
}
earendil-works/pi@c13ffe1第 1061–1073 行在 GitHub 查看 ↗
交棒点:调用 Agent.prompt;返回后还会检查是否需要自动重试或压缩后继续,最后一定发出 agent_settled。

那个 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)。而内部方法也只是把五样东西凑齐后调低层循环:

ts
// 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,
		);
	});
}
packages/agent/src/agent.ts · runPromptMessages
earendil-works/pi@c13ffe1第 398–412 行在 GitHub 查看 ↗
Agent 类的核心交接:把上下文快照、循环配置、事件回调、AbortSignal 和 streamFn 五样东西交给低层函数 runAgentLoop。

这五个参数值得逐个记住,因为它们就是 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_startturn_start,再把每条 prompt 消息各发一对 message_start / message_end,然后进入 runLooppackages/agent/src/agent-loop.ts:109-117)。注意这个顺序——用户消息的 message_end 事件是 Pi 把它写进会话文件的时机agent-session.ts:641)。

runLoop 是全书最该读懂的一个函数。它是两层 while

ts
// 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;
		}
earendil-works/pi@c13ffe1第 170–200 行在 GitHub 查看 ↗
双层循环骨架:外层等待 follow-up 消息,内层处理「模型响应 → 工具执行 → 回填 → 再响应」。第一次进内层的条件是 hasMoreToolCalls 被初始化为 true。

第一次进内层循环时 hasMoreToolCalls 被硬编码为 trueagent-loop.ts:171),这是个小技巧:它保证无论如何都至少请求模型一次firstTurn 标志则避免重复发 turn_startrunAgentLoop 已经发过一次了)。

一个必须先建立的概念:

📘 概念一个 turn(Turn)
在 Pi 的事件模型里,一个 turn = 一次 assistant 响应 + 它引发的工具调用与结果(源码事实,见 packages/agent/src/types.ts:426 的注释)。本章的普通请求只有一个 turn;下一章的工具调用会让内层循环转多圈,也就是多个 turn。

第 5 棒:streamAssistantResponse 与那个叫 streamFn 的参数

内层循环里唯一「离开本进程」的一步是 streamAssistantResponse。它做三件事:把 AgentMessage[] 变成大语言模型(LLM)认识的 Message[]、解析 API Key、调用 streamFn

ts
// 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,
});
earendil-works/pi@c13ffe1第 288–312 行在 GitHub 查看 ↗
去程的最后一站:transformContext 与 convertToLlm 两步转换之后,把 Context 交给 streamFn,拿回一个事件流。

这里有一个对理解 Pi 架构至关重要的事实:packages/agent 包不认识任何 Provider。它只知道有个符合下面这个签名的函数可以调用。

📘 概念StreamFn(Stream Function)
类型定义在 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({...}) 时给了一个闭包:

ts
// 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 超时、重试上限、请求头改写钩子)
		});
	},
earendil-works/pi@c13ffe1第 294–312 行在 GitHub 查看 ↗
Pi 实际使用的 streamFn:一个把 settings 里的超时/重试配置合进去、再委托给 ModelRuntime.streamSimple 的闭包。

ModelRuntime.streamSimplepackages/coding-agent/src/core/model-runtime.ts:494-499)再委托给 pi-ai 的 Models.streamSimplepackages/ai/src/models.ts:512-518),后者解析鉴权并交给具体 provider。第 6 棒(真正的 HTTP 请求、SSE 解析)属于 pi-ai 包的内部,6.1 会专门拆解。

来源二:模块级默认注册表。 packages/agent 提供了一个可选的全局兜底:

ts
// 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)、runAgentLoopagent-loop.ts:116)、runAgentLoopContinueagent-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 的 AssistantMessageEventpackages/ai/src/types.ts:501-513)共 12 种:start、三组 *_start / *_delta / *_end(text、thinking、toolcall)、以及二选一的终止事件 done / error。每个事件都带 partial 字段——当前已累积的完整 assistant 消息。

第二层,agent 包的 AgentEventpackages/agent/src/types.ts:422-437)共 10 种。streamAssistantResponsefor await 消费第一层,映射成第二层(agent-loop.ts:317-344):startmessage_start;九种增量事件 → 同一个 message_update,原事件放进 assistantMessageEvent 字段;done / error → 先 await response.result() 拿到最终消息,再发 message_endagent-loop.ts:346-359)。

第三层,AgentSessionEventpackages/coding-agent/src/core/agent-session.ts:139-181)。AgentSession 在构造时就订阅了 Agent(agent-session.ts:393),处理函数是:

ts
// 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)
earendil-works/pi@c13ffe1第 594–622 行在 GitHub 查看 ↗
第三层信封:先给扩展、再给 UI 订阅者、最后写会话文件。注意 agent_end 是唯一被改写的事件——多带一个 willRetry 字段。

这三步的顺序是有含义的:扩展先看到事件(可以做记录或副作用),UI 再看到,会话持久化最后做。_emitagent-session.ts:548-552)只是同步遍历监听器数组。

最后一层,UI。 InteractiveMode.subscribeToAgent()interactive-mode.ts:2875-2879)注册唯一的监听器,转给一个大 switchinteractive-mode.ts:2881 起)。对本章的路径来说三个分支最关键:

  • message_startrole === "assistant"interactive-mode.ts:2945-2957):新建一个 AssistantMessageComponent 挂进聊天容器。
  • message_updateinteractive-mode.ts:2960-2963):streamingComponent.updateContent(event.message)——注意传的是 event.message(累积后的完整消息),不是增量。所以组件每次都按完整内容重建子组件,真正的「只重画变化的行」交给 TUI 的差分渲染(6.9)。
  • message_endinteractive-mode.ts:2995-3032):最后渲染一次,然后把 streamingComponent 置为 undefinedinteractive-mode.ts:3028-3029)——组件本身留在聊天记录里,只是不再是「正在流式的那个」。

没有 toolCall,循环怎么停下来

回到 runLoopstreamAssistantResponse 返回后,循环要判断还要不要再来一轮:

ts
// 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 这一行:它先无条件置假,只有在真的执行了工具、且工具批次没要求终止时才会被改回 trueagent-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-268breakagent-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.handleEventagent_end 分支(interactive-mode.ts:3082-3095)关掉终端进度指示、清掉状态指示器、清空 streamingComponent 引用。之后 Agent.finishRun()agent.ts:514-520)把 isStreaming 置假,_runAgentPromptfinally 发出 agent_settled,主循环里的 await this.session.prompt(userInput) 才终于返回——回到 while (true) 顶端,重新挂起在 getUserInput() 上,等你敲下一句话。

🌱 初学者提示为什么 agent_end 不等于「空闲」
源码注释(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_startturn_startmessage_startmessage_endturn_endagent_end,且最终 messages 恰好两条(user + assistant)。同文件第 84 行起的 describe("default stream function compatibility") 则专门验证「省略 streamFn 时会回退到 setDefaultStreamFn 装的那个」。

实践任务

🛠 实践任务用 grep 验证链路上的两个关键调用点

目标:不依赖任何 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:216agent-loop.ts:116agent-loop.ts:141

如何判断成功:你能回答这个问题——「Pi 自己跑的时候,用的是这个默认注册表里的函数吗?」答案是「不是」:sdk.ts:302new 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-3553packages/agent/src/stream-fn.ts:3-20packages/coding-agent/src/core/sdk.ts:33-36:294-312

本章小结

  • 一次普通请求经过七次交接:编辑器 → 主循环 → AgentSession.promptAgent.promptrunLoopstreamFn → Provider,再原路把事件送回屏幕;去程与回程是重叠的。
  • 交互模式用「挂起的 Promise + onInputCallback」把事件驱动的终端缝合成顺序的 REPL;主循环里的 try/catch 是给预检失败(无模型、无凭证)准备的。
  • AgentSession.prompt 是预检层:扩展命令、input 事件、技能与模板展开、流式排队、模型与凭证校验,全部通过后才组装 user 消息交给 Agent
  • Agent 类只管生命周期、状态与事件分发;循环逻辑全在 runLoop 里。两者之间的接口就是 runAgentLoop 的五个参数。
  • streamFn 是 agent 核心与 Provider 之间唯一的缝:它不许抛异常,错误必须编码进流。Pi 走的是构造 Agent 时显式注入的闭包,模块级注册表只作旧扩展的兜底。
  • 事件回程有三层信封:AssistantMessageEventAgentEventmessage_update 裹着前者)→ AgentSessionEvent → UI 组件树。
  • 没有 toolCallhasMoreToolCalls 保持 false,内层循环条件不成立;两个队列都空则 break,最终发出 agent_end
  • 关键术语:交接点一个 turnStreamFn事件信封run 的结算(settlement)
  • 关键源码索引:interactive-mode.ts:936-945(主循环)、:2862-2872(提交)、:3542-3553(挂起)、agent-session.ts:1114prompt)、:1177-1195(模型与凭证校验)、:1061_runAgentPrompt)、:594(事件回流)、agent.ts:398runPromptMessages)、agent-loop.ts:170(双层循环)、:288-312streamFn 调用)、:274agent_end)、stream-fn.ts:11-20sdk.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)。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)