Skip to content

5.6 取消与错误处理

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

本章解决什么问题:前面五章走的都是「一切顺利」的路。真实使用中有两条同样常见的路:用户按下 ESC 想停下来,以及模型服务方返回 429、连接中途断开、上下文超出窗口。本章把这两条异常路径从起点追到终点,并回答一个容易被忽略的问题——中断之后,消息历史里到底留下了什么。 前置知识2.7 事件、回调与取消(AbortController)(本章是那张概念图的源码版)、5.3 一次 Tool Call 的完整循环5.4 流式事件如何传播到界面学习目标:读完本章后你能

  • 说出一次 ESC 从终端按键到 fetch 与工具执行之间的每一环,并指出 AbortController 是在哪一行创建的;
  • 解释 Pi 的 Agent Loop 为什么几乎不检查 signal.aborted,却仍然能停下来;
  • 区分 Pi 里三层不同粒度的重试,说出各自的判据、退避公式与预算;
  • 说清上下文溢出为什么被从「可重试错误」里单独摘出去;
  • 亲手跑通 pi-ai 与 pi-agent-core 里与取消、重试相关的真实测试。

建立直觉:异常不是「抛出去」,而是「带回来」

写普通程序时,出错的默认做法是 throw:异常沿着调用栈往上冒,谁想处理谁 catch。但 Agent Harness(Agent 运行框架)里有一个硬性约束让这套做法行不通——一次请求的结果必须被写进消息历史。如果模型服务方返回 429 的那一刻,异常直接把整个 Agent Loop(Agent 循环)炸掉,那么这一轮的部分文本、已经消耗的 token、已经跑完的工具结果就全丢了;界面上只剩一个红色堆栈,会话文件里什么也没有。

所以 Pi 走了另一条路:失败被编码成一条正常的 assistant 消息stopReason 取值 "error""aborted"errorMessage 带上原文。这不是某个文件的实现细节,而是写在类型定义注释里的契约(源码事实):

ts
// packages/agent/src/types.ts:18-32(节选)
/**
 * Contract:
 * - Must not throw or return a rejected promise for request/model/runtime failures.
 * - Failures must be encoded in the returned stream via protocol events and a
 *   final AssistantMessage with stopReason "error" or "aborted" and errorMessage.
 */
export type StreamFn = (
	model: Model<Api>,
	context: Context,
	options?: SimpleStreamOptions,
) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;

理解了这条契约,本章两条路径就都好读了:取消和出错走的是同一条通道,区别只在 stopReason 的取值,以及之后谁来决定「要不要再来一次」。

📘 概念停止原因(stopReason)
一条 assistant 消息的收尾方式。本章关心其中四种:`stop`(正常说完)、`length`(输出被 token 上限截断)、`error`(失败)、`aborted`(被取消)。它是异常路径唯一的载体——Agent Loop、界面、会话文件、重试逻辑全都读同一个字段。

路径一:ESC 按下之后发生了什么

起点:一个按键分派到四种含义

交互模式下,ESC 并不总是「取消」。CustomEditor.handleInput 在把按键交给通用编辑逻辑之前,先匹配 app.interrupt 动作(默认绑定就是 escape,见 packages/coding-agent/src/core/keybindings.ts:66),且只有在自动补全菜单没弹出时才交给 onEscapepackages/coding-agent/src/modes/interactive/components/custom-editor.ts:45-57)。而 onEscape 是一个会被动态改绑的回调:

earendil-works/pi@c13ffe1第 2596–2622 行在 GitHub 查看 ↗
ESC 的四路分派:流式输出中 → 收回排队消息并 abort;bash 在跑 → abortBash;bash 模式 → 清空编辑器;编辑器为空时 500 毫秒内连按两次 → 打开会话树或分支选择器。

还有两个临时改绑点值得记住(源码事实):压缩期间 onEscape 被换成 session.abortCompaction()interactive-mode.ts:3106-3109),自动重试的退避等待期间被换成 session.abortRetry()interactive-mode.ts:3157-3159),事后各自恢复。也就是说「ESC 取消什么」取决于按下的那一刻会话处于哪个阶段——这是一个纯 UI 层的决策,下面的核心层对此一无所知。

真实采集的启动界面里,选择器底部那行提示 ↑↓ navigate enter select escape/ctrl+c cancel(真实采集,见 research/cli-captures/pi-tui-startup.txt)正是同一套按键约定在覆盖层上的表现。

中间:AbortController 在哪一行诞生

流式输出中按 ESC,会走到 restoreQueuedMessagesToEditorinteractive-mode.ts:4021-4040):它先把 steering 与 follow-up 队列里排队的文本拼回编辑器(用户不会白打字),然后调用 this.agent.abort()。注意这里的 this.agentthis.session.agentinteractive-mode.ts:460-462),即 pi-agent-core 的 Agent 实例,而不是 AgentSession

earendil-works/pi@c13ffe1第 306–314 行在 GitHub 查看 ↗
Agent 的取消入口只有两行:拿到本次运行的 AbortController 并按下。没有活动运行时它是安全的空操作。

这个 controller 不是长期存在的,而是每次运行现造一个runWithLifecycleagent.ts:476new AbortController(),存进 activeRun,并把 abortController.signal 作为参数交给执行体(agent.ts:488)。执行体就是 runAgentLoop(...) 调用,signal 是它的第 5 个参数(agent.ts:402-411)。运行结束时 finishRun()activeRun 清空(agent.ts:514-520),下一轮再造新的——所以上一轮的取消绝不会误伤下一轮。

🌱 初学者提示为什么不复用一个全局 AbortController
AbortSignal 是一次性的:`abort()` 之后 `aborted` 永远是 `true`,无法「复位」。要让「取消」和「下一次运行」互不干扰,只能一轮一个。2.7 的最小示例里已经踩过这个坑。

终点一:signal 进入网络请求

runLoop 把同一条 signal 一路透传,最终交给 StreamFn:

earendil-works/pi@c13ffe1第 304–312 行在 GitHub 查看 ↗
streamAssistantResponse 把 config 展开后附上解析好的 apiKey 与 signal,一起交给 streamFn。signal 从这里离开 agent 包,进入 pi-ai。

在 pi-ai 一侧,signal 有两个作用点。一是交给 Provider(模型服务提供方)SDK 的请求选项(packages/ai/src/api/anthropic-messages.ts:554-566),让底层 fetch 直接断开连接;二是在服务器发送事件(SSE,Server-Sent Events)的读取循环里每轮检查一次(anthropic-messages.ts:398-400),保证即使连接层没能立刻断开,解析循环也会主动抛出。两者抛出的异常最终都落到同一个 catch:

earendil-works/pi@c13ffe1第 760–769 行在 GitHub 查看 ↗
适配层的收尾 catch:清掉流式过程中的临时字段,按 signal 是否已取消决定 stopReason 是 aborted 还是 error,再把这条消息作为 error 事件推入流并结束流——异常在这里被「翻译」成数据,不再向上抛。

终点二:signal 进入工具执行

工具那一侧,signal 出现在三个位置(源码事实):beforeToolCall 钩子的第 2 参(agent-loop.ts:619-628)、afterToolCall 钩子的第 2 参(agent-loop.ts:720-731),以及最关键的 tool.execute 第 3 参:

ts
// packages/agent/src/agent-loop.ts:675-693(节选,executePreparedToolCall 内)
const result = await prepared.tool.execute(
	prepared.toolCall.id,
	prepared.args as never,
	signal,
	(partialResult) => {
		// …(省略:把中间进度包成 tool_execution_update 事件)
	},
);

工具拿到的第三个参数就是这条 signal。工具内部是否响应它,完全取决于工具自己的实现——abort 是请求,不是强杀。

除了传下去,Agent Loop 自己也查:prepareToolCall 在钩子前后各查一次 signal?.aborted,命中就直接返回一条 "Operation aborted" 的错误工具结果而不执行(agent-loop.ts:629-635644-650);串行执行在每个工具跑完后 breakagent-loop.ts:478-480),并行执行在准备阶段 breakagent-loop.ts:516-518535-537)。

这里有一个后果需要单独记住:break 意味着剩下的 tool call 连一条工具结果都不会产生。一条 assistant 消息声明了 3 个 tool call,取消之后历史里可能只有 1 条 toolResult。这个「残缺」由谁收拾,下一节回答。

循环怎么知道该停了

一个反直觉的事实:runLoop 在 turn 边界没有任何 signal.aborted 判断(源码事实——agent-loop.ts:224-260 这段从 turn_end 到轮询 steering 队列,全程不查 signal)。真正的终止判据只有一个:

earendil-works/pi@c13ffe1第 196–200 行在 GitHub 查看 ↗
唯一的异常出口:assistant 消息的 stopReason 是 error 或 aborted 时,补发 turn_end 与 agent_end 后立刻 return。整个取消机制最终收敛到这四行。

从源码结构看,如果 ESC 恰好按在工具批次执行中途,流程是这样的:工具批次 break → 回填已有的工具结果 → 发 turn_end → 内层循环条件仍然成立 → 再发起一次流式请求,而这次请求带着一条已经 aborted 的 signal,适配层立刻走 anthropic-messages.ts:766stopReason 置为 aborted → 第 196 行退出。也就是说取消在最坏情况下会多产生一条内容为空的 aborted 消息。这是分析解释(我没有找到直接覆盖这条路径的具名测试,尚未确认真实运行中这条空消息是否总会出现)。

📘 概念AgentHarness 的另一套取消(AgentHarness.abort)
`AgentHarness` 不走 `Agent` 类,它自己在 `packages/agent/src/harness/agent-harness.ts:607` 建 controller、存进 `runAbortController`(第 612 行)。它的 `abort()`(`agent-harness.ts:1025-1052`)比 `Agent.abort()` 多做三件事:清空 steer 与 followUp 队列并返回被清掉的内容、等待 `waitForIdle()`、发一个 `abort` 事件。有一件事它**不做**——不清 `nextTurn` 队列。
图加载中…

图 5.6-1 一次 ESC 在 Pi 里的真实传播链
阅读顺序:从上到下。这是 [2.7 的概念图 2.7-2](/foundations/events-and-abort) 换成真实文件与行号后的样子,请重点对比两处差异:① 概念图里「agent.abort」是一个抽象节点,实际它上面还有 UI 层的四路分派,下面还有「每轮新建 controller」这一步;② 概念图里三个订阅者是并列的终点,实际两条支路最终都汇合到 `agent-loop.ts:196` 这同一个出口——signal 负责让正在进行的工作停下,让**循环**停下的是那条 aborted 消息。

取消之后,消息历史里留下什么

分两个层面看,答案不一样。

会话文件层面:全部留下。 AgentSession._handleAgentEvent 对每个 message_end 都调 sessionManager.appendMessagepackages/coding-agent/src/core/agent-session.ts:625-642),不区分 stopReason。所以那条 aborted 的 assistant 消息、以及取消前已经跑完的工具结果,都会写进会话文件,重开会话时你还能看到它们。

发给模型的请求层面:几乎全部丢弃。 每个 Provider 适配器在构造请求体之前都会先过一遍 transformMessages(例如 anthropic-messages.ts:947),而它的第二趟扫描做了两件与本章直接相关的事:

packages/ai/src/api/transform-messages.ts · insertSyntheticToolResults
earendil-works/pi@c13ffe1第 185–197 行在 GitHub 查看 ↗
第 195-197 行:stopReason 为 error 或 aborted 的 assistant 消息被整条 continue 跳过,不进入请求体;注释给出的理由是这类消息可能只有半截思考或半截 tool call,重放会直接触发 Provider 报错。第 187 行:在跳过之前,先给上一条 assistant 遗留的、没有配对结果的 tool call 补一条合成结果。

那条合成结果长这样(transform-messages.ts:163-180):role: "toolResult"isError: true、内容是 "No result provided"。上一节说的「3 个 tool call 只有 1 条结果」的残缺,就是在这里被补齐的——它必须补,因为多数 Provider 的接口要求每个 tool call 都有配对结果,否则整个请求 400。

于是就有了一个初看奇怪、细想合理的现象(分析解释):界面上那条红色的「Operation aborted」是给人看的,模型下一轮完全看不见它。模型看到的是「上一条 assistant 消息发起了几个工具调用,其中若干条的结果是 No result provided」,然后从最后一个有效状态继续。

路径二:错误的分诊与三层重试

失败一旦被编码成 stopReason: "error" 的消息,接下来的问题就变成了纯粹的决策题:重试、压缩,还是放弃。Pi 在三个不同高度各放了一层判断,粒度从细到粗。

第一层:HTTP 请求级(pi-ai 内部,用户无感)

earendil-works/pi@c13ffe1第 105–125 行在 GitHub 查看 ↗
包住单次 SDK 请求的重试循环:先查 signal 是否已取消,再判断预算与错误是否可重试,然后可中断地睡一段时间重发。每次重试都是一次全新的 SDK 请求。

这一层存在的理由写在它自己的注释里(官方说明,来源文件 packages/ai/src/utils/provider-retry.ts:97-104):OpenAI 与 Anthropic 的官方 SDK 自带重试,但它们的退避计时器不理会请求的 AbortSignal——用户按了 ESC,SDK 还在傻等下一次重试。Pi 的做法是把 SDK 的 maxRetries 强行设为 0(anthropic-messages.ts:557),自己实现一份可被打断的等价逻辑。

判据是 HTTP 语义(provider-retry.ts:23-35):响应头 x-should-retry 优先;否则 408、409、429 与所有 5xx 可重试;statusundefined(连接层根本没拿到响应)也算可重试。等待时长优先采用服务器给的 retry-after-msretry-after 头,都没有才用 min(0.5 * 2^n, 8) 秒并乘上最多 25% 的抖动(provider-retry.ts:51-67)。还有一条防御值得注意:如果服务器要求的等待超过 maxRetryDelayMs(默认 60 秒,provider-retry.ts:1packages/coding-agent/src/core/settings-manager.ts:838),Pi 直接失败并在错误里写明「服务器要求等 N 秒,超过上限」,而不是真的挂在那里。

第二层:单次 assistant 调用级(用于压缩与分支摘要)

retryAssistantCallpackages/ai/src/utils/retry.ts:162-211)接收一个「产出一条 assistant 消息」的函数,按策略反复调用它。它的判定顺序很值得逐条读(源码事实):

ts
// packages/ai/src/utils/retry.ts:172-196(节选)
for (;;) {
	const response = await produce();
	// 取消是终态,永不重试
	if (response.stopReason === "aborted") { /* …(省略:回调) */ return response; }
	// 非 error 即成功
	if (response.stopReason !== "error") { /* …(省略:回调) */ return response; }
	// 预算用尽,或分类为不可重试 → 直接把最后这条错误交回去
	if (attempt >= maxAttempts || !isRetryableAssistantError(response)) { /* …(省略) */ return response; }
	attempt++;
	const delayMs = policy!.baseDelayMs * 2 ** (attempt - 1);
	// …(省略:onRetryScheduled 回调)
	try { await sleep(delayMs, signal); } catch (error) { /* …(省略:把退避期间的取消归一化成 aborted 消息) */ }
}

最后那个 catch 是个容易被跳过、但很能说明设计取向的细节:退避睡眠被 signal 打断时,它不把异常抛出去,而是返回 { ...response, stopReason: "aborted", errorMessage: undefined }retry.ts:205)。调用方因此不需要关心「用户是在请求中途取消的,还是在两次重试之间取消的」,拿到的都是同一种形状的 aborted 消息。

这一层目前的使用者是压缩与分支摘要(packages/agent/src/harness/compaction/compaction.ts:132packages/coding-agent/src/core/compaction/compaction.ts:580),注释说明其动机是「一次瞬时的流中断不应该让整个压缩操作失败」(官方说明,来源文件 packages/coding-agent/src/core/compaction/compaction.ts:557-560)。

分类器:两张正则表

第一、二层共用同一个分类函数:

packages/ai/src/utils/retry.ts · isRetryableAssistantError
earendil-works/pi@c13ffe1第 222–227 行在 GitHub 查看 ↗
分类只看 errorMessage 文本,且**先排除后匹配**:命中「额度/账单类」黑名单直接判不可重试,再看是否命中「瞬时故障」白名单。

两张表的内容本身就是一份 Provider 故障图鉴(retry.ts:7-2426-89)。黑名单收的是花钱也解决不了、重试只会浪费时间的确定性失败:insufficient_quotaquota exceededbillingMonthly usage limit reached。白名单收的是瞬时故障,覆盖四类:HTTP 与服务端(overloadedrate.?limit429500504524)、网络与代理(fetch failedENOTFOUNDEAI_AGAINsocket hang upconnection.?refused)、流提前结束(ended withoutstream ended before message_stop)、以及服务方明文写的重试指引(you can retry your requesttry your request again)。

顺序不能反:Bedrock 的限流文案里同时含有「too many」这类词,也含有账单语义的措辞,先排除后匹配才能避免误判——overflow.ts:74-78 里的 NON_OVERFLOW_PATTERNS 用的是同一套思路。

上下文溢出:被单独摘出去的一类

earendil-works/pi@c13ffe1第 132–160 行在 GitHub 查看 ↗
三种判据:① stopReason 为 error 且错误文本命中溢出正则、且不命中限流类反例;② 提供了 contextWindow 时,stopReason 为 stop 但 input 加 cacheRead 已超窗口(静默溢出);③ stopReason 为 length、output 为 0、且输入填满了 99% 窗口(服务端先截断再生成,没地方可写了)。

为什么要单独摘出来?因为溢出重试一万次也是溢出——它是确定性失败,正确的处理是压缩上下文再来。coding-agent 因此把它挡在重试判断的第一行:

ts
// packages/coding-agent/src/core/agent-session.ts:2635-2639
private _isRetryableError(message: AssistantMessage): boolean {
	// Context overflow is handled by compaction, not retry.
	if (isContextOverflow(message, this.model?.contextWindow ?? 0)) return false;
	return isRetryableAssistantError(message);
}

情况 ②③ 特别能说明这个函数为什么要写 160 行:有些 Provider 面对超长输入不报错,而是默默接受(z.ai)或默默截断再返回 finish_reason: length(小米 MiMo)。没有这两条兜底,Pi 会误以为一切正常,然后一次次发出注定被截断的请求。文件头部那份按 Provider 罗列的错误文案清单(overflow.ts:9-35)是我在整个 pi-ai 里见过信息密度最高的注释之一,值得单独读一遍。

第三层:turn 级(coding-agent,用户看得见)

earendil-works/pi@c13ffe1第 1075–1103 行在 GitHub 查看 ↗
每次 agent 运行结束后的分诊台:可重试错误 → 准备重试并返回 true;重试预算已用尽 → 发 auto_retry_end 通报最终失败;否则检查是否需要压缩;最后看扩展有没有在 agent_end 期间排队新消息。返回 true 表示「还要再跑一轮」。

它的调用者是一个极简的循环(agent-session.ts:1061-1073):

ts
// packages/coding-agent/src/core/agent-session.ts:1061-1067(节选)
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 里清理并发 settled)
}

_prepareRetryagent-session.ts:2676-2726)做四件事:计数并检查预算(默认 maxRetries: 3baseDelayMs: 2000,即 2 秒、4 秒、8 秒,见 settings-manager.ts:813-818);发 auto_retry_start 事件;把那条错误消息从 agent 运行时状态里删掉agent-session.ts:2701-2704,注释写明「会话文件里保留作为历史」);最后用一个可被 _retryAbortController 打断的 sleep 完成退避。删消息这一步是 agent.continue() 能成立的前提——continue() 拒绝从 assistant 消息续跑(agent-loop.ts:131-133)。

⚠️ 常见误解以为三层重试会叠乘
它们的作用域不重叠:第一层在单个 HTTP 请求内,第二层只包住压缩/摘要这类「一次性 assistant 调用」,第三层在整个 turn 之外。一次普通对话里,第一层与第三层可能先后生效(HTTP 层重试用尽后仍然失败 → 错误冒泡成 error 消息 → turn 级再退避重试),第二层不参与。
图加载中…

图 5.6-2 一条失败消息的分诊路径
阅读顺序:从上到下,中间的 `ENC` 是分水岭。请重点关注两处:① 第一层重试完全发生在 pi-ai 内部,界面上看不到任何提示,只有它失败之后错误才会「浮出水面」变成一条消息;② 分诊台先问溢出、后问可重试,这个顺序对应 `agent-session.ts:2637` 那一行注释——溢出是确定性失败,抢在重试判断之前拦下。图中 `_checkCompaction` 分支的细节属于 [6.6 Context 构造与 Compaction](/pi-modules/compaction) 的范围,本章只标出它的入口。

错误怎么变成界面上的红字

事件到了界面就只剩渲染。InteractiveMode.handleEventmessage_end 分支(interactive-mode.ts:2995-3033 一段)先给 aborted 消息补一条文案——如果此前发生过重试,文案会是「Aborted after N retry attempts」(interactive-mode.ts:3000-3007)——然后把这条错误同时写进所有还挂着的工具组件interactive-mode.ts:3014-3020),避免界面上留下几个永远转圈的工具。

真正上色在 AssistantMessageComponent.updateContent

ts
// packages/coding-agent/src/modes/interactive/components/assistant-message.ts:165-177(节选)
} else if (!hasToolCalls) {
	if (message.stopReason === "aborted") {
		const abortMessage = /* …(省略:优先用 errorMessage) */ "Operation aborted";
		this.contentContainer.addChild(new Text(theme.fg("error", abortMessage), this.outputPad, 0));
	} else if (message.stopReason === "error") {
		const errorMsg = message.errorMessage || "Unknown error";
		this.contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), this.outputPad, 0));
	}
}

注意 !hasToolCalls 这个条件(源码事实,assistant-message.ts:165):带工具调用的失败消息不在这里显示红字,因为错误已经由上面那段写进各个工具组件了,两边都写会重复。而 stopReason === "length"(输出被截断)不受这个条件限制,永远单独提示一句(assistant-message.ts:153-164)。

重试期间界面的变化则由两个专属事件驱动:auto_retry_start 换上 RetryStatusIndicator 并把 ESC 临时改绑到 abortRetryauto_retry_end 恢复原 ESC 处理器、清掉指示器,只在最终失败时弹出一行 Retry failed after N attemptsinteractive-mode.ts:3154-3180)。

图加载中…

图 5.6-3 一次可重试错误的完整时间线
阅读顺序:从上到下按时间。请关注三处:① 错误消息先经过 `message_end` 落进会话文件,之后才被从运行时状态里删掉,所以「历史里有、请求里没有」;② 重试指示器出现期间 ESC 的含义被临时改成 `abortRetry`,这正是第一节说的动态改绑;③ 重试是靠 `agent.continue()` 重新进入循环,而不是在循环内部打转——对应 `agent-session.ts:1065-1067` 那个 while 循环。图中 `auto_retry_start` 与 `auto_retry_end` 是 AgentSession 自有事件,不属于 pi-agent-core 的 10 种 AgentEvent。

实践任务

🛠 实践任务跑通取消与重试的真实测试

目标:确认本章讲的三个判定函数(重试分类、HTTP 重试策略、溢出识别)与 Agent 的取消契约都有测试覆盖,并亲眼看到「需要 API Key 的测试会被自动跳过」这一约定。全程只读,不需要任何 API Key,不要修改 _sources/pi 下的任何文件。

步骤与命令(在 Pi 仓库根目录执行,例如 _sources/pi):

  1. 先确认测试文件确实存在:

    sh
    ls packages/ai/test/retry.test.ts packages/ai/test/provider-retry.test.ts packages/ai/test/overflow.test.ts
  2. 跑本章第二条路径涉及的三个测试文件:

    sh
    npm test --workspace=@earendil-works/pi-ai -- test/retry.test.ts test/provider-retry.test.ts test/overflow.test.ts
  3. 跑取消路径的测试(其中包含 should pass the active abort signal to subscribersshould handle abort controller 两个用例,见 packages/agent/test/agent.test.ts:263:501):

    sh
    npm test --workspace=@earendil-works/pi-agent-core -- test/agent.test.ts
  4. 对比一个需要真实 Provider 的测试:

    sh
    npm test --workspace=@earendil-works/pi-ai -- test/abort.test.ts
  5. 最后用一次搜索验证本章的核心论断——「取消的终点只有一个」:

    sh
    grep -rn 'stopReason === "aborted"' packages/agent/src --include="*.ts"

预期现象(以下为本书作者在锁定 commit 上实际执行的结果):第 2 步汇总行为 Test Files 3 passed (3)Tests 37 passed (37);第 3 步为 Test Files 1 passed (1)Tests 20 passed (20);第 4 步为 Test Files 1 skipped (1)Tests 37 skipped (37)——整个文件被跳过,因为它在模块顶层解析 OAuth 令牌,没有凭据就不运行;第 5 步返回 4 行结果,其中 agent-loop.ts:196 就是本章图 5.6-1 的汇合点,另外三行都在 compaction 相关文件里。

如何判断成功:三个 npm test 全部以 0 退出且没有 failed;并且你能回答——第 4 步为什么是 skipped 而不是 failed?(提示:看 packages/ai/test/abort.test.ts:12 的顶层 resolveApiKey,以及各用例的条件跳过写法。)

常见错误

  • 写成 npm test --workspace=@earendil-works/pi-ai -- --run test/retry.test.ts 会直接崩,报 Expected a single value for option "--run"——因为包内的 test 脚本已经是 vitest --run,再传一次就重复了。
  • 忘了 --npm 会把文件名当成自己的参数,结果跑了整个包的测试(耗时很长且大量跳过)。
  • packages/ai 目录里直接敲 npm test:可以跑,但 workspace 依赖需要根目录已经 npm install 过。

对应源码位置packages/ai/src/utils/retry.ts:162(重试循环)与 :222(分类器)、packages/ai/src/utils/provider-retry.ts:105(HTTP 层重试)、packages/ai/src/utils/overflow.ts:132(溢出识别)、packages/agent/src/agent.ts:312(取消入口)、packages/agent/src/agent-loop.ts:196(循环出口)。

本章小结

  • Pi 的异常路径不靠 throw,靠把失败编码成一条 assistant 消息stopReason"error""aborted",这是写在 StreamFn 类型注释里的契约。取消与出错共用同一条通道。
  • 取消链:ESC 按键 → CustomEditor 匹配 app.interruptonEscape(四路分派,且在压缩/重试期间会被临时改绑)→ Agent.abort() → 每轮新建的 AbortController → 同一条 signal 同时进入 streamFn、tool.execute 第 3 参、两个工具钩子与事件监听器。
  • 让循环真正停下来的不是 signal 检查,而是 agent-loop.ts:196stopReason 的判断——signal 负责让正在进行的工作停,aborted 消息负责让循环停。
  • 取消后会话文件里保留完整痕迹,但 transformMessages 在构造请求体时会整条跳过 error/aborted 的 assistant 消息,并给落单的 tool call 补 "No result provided" 合成结果。
  • 三层重试各管一段:HTTP 请求级(retryProviderRequest,替换掉 SDK 那个不理会 AbortSignal 的退避计时器)、单次 assistant 调用级(retryAssistantCall,服务于压缩与摘要)、turn 级(_prepareRetry + agent.continue(),默认 3 次、2/4/8 秒)。
  • 上下文溢出被从「可重试」里单独摘出:它是确定性失败,正确处置是压缩;isContextOverflow 还额外识别两种「不报错的溢出」。
  • 关键术语:停止原因(stopReason)、AbortController / AbortSignal、可重试错误(retryable error)、指数退避(exponential backoff)、上下文溢出(context overflow)、合成工具结果(synthetic tool result)。
  • 关键源码索引packages/agent/src/agent.ts:306-314(abort)、:476(controller 创建);packages/agent/src/agent-loop.ts:196-200(循环出口)、:304-312(signal 进 streamFn)、:675-693(signal 进工具)、:478-480:516-518(批次中断);packages/ai/src/api/anthropic-messages.ts:760-769(异常转数据);packages/ai/src/api/transform-messages.ts:158-197(跳过与补全);packages/ai/src/utils/provider-retry.ts:105-125packages/ai/src/utils/retry.ts:162-227packages/ai/src/utils/overflow.ts:132-160packages/coding-agent/src/core/agent-session.ts:1075-1103(分诊台)、:2676-2726(退避);packages/coding-agent/src/modes/interactive/interactive-mode.ts:2596-2622(ESC 分派)、:3154-3180(重试指示器)。相关测试:packages/ai/test/retry.test.tspackages/ai/test/provider-retry.test.tspackages/ai/test/overflow.test.tspackages/agent/test/agent.test.ts:263:501packages/agent/test/harness/agent-harness.test.ts:188
  • 自测问题:① 用户在三个工具并行执行到一半时按下 ESC,下一次发给模型的请求体里能看到这三个工具调用吗?能看到几条工具结果?② 模型返回「prompt is too long: 213462 tokens > 200000 maximum」,Pi 会重试几次?为什么?③ 为什么 retryAssistantCall 要把退避睡眠期间的取消转换成一条 aborted 消息,而不是直接抛异常?④ Agent.abort()AgentHarness.abort() 在行为上有哪三点不同?
  • 下一部分预告:第五部分到此走完了一次完整请求的去程、回程与两条异常路。第六部分将换一个切法,按模块纵向深入。本章刻意没有展开的内容:压缩本身的算法与提示词(留给 6.6)、Provider 适配层各家 SDK 的差异(6.2)、扩展如何介入错误处理(7.1),以及 RPC 模式下取消如何跨进程传递(6.10,尚未确认其实现是否复用同一条 signal 链)。

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