Skip to content

6.3 pi-agent-core:Agent 与循环 ​

本页分析版本earendil-works/pi@16787ad2026-09-21

本章解决什么问题:@earendil-works/pi-agent-core 这个包里同时住着 runLoop、Agent 类和 AgentHarness 三套东西,它们看起来都在「跑 Agent 循环」。本章讲清三者各自负责什么、谁调用谁,以及那个双层 while 到底每一步在做什么。 前置知识:3.5 Agent 与 Agent Loop、3.6 Agent Harness、Session 与状态、5.3 一次 Tool Call 的完整循环、6.1 pi-ai:统一的模型接口。 学习目标:① 说出三层结构的分工与调用关系,并解释为什么 AgentHarness 既不用 Agent 类、也不调 runAgentLoop;② 逐段读懂 runLoop 的内外双层循环、三个钩子(prepareRequest / finishTurn / prepareNextTurn)与四个终止出口;③ 背下 AgentEvent 的 10 种事件与各自的发射时机;④ 讲清 steer / followUp / nextRun 三个队列的语义差异;⑤ 亲手跑通描述这些行为的真实单元测试。

建立直觉:一个循环,一台状态机,三层分工 ​

3.5 讲过 Agent Loop 的原理:拿上下文问模型 → 模型要么回答要么请求工具 → 执行工具 → 把结果塞回上下文 → 再问一次,直到模型不再要工具。这段逻辑本身只有几十行,难的是它周围的东西:谁记着 transcript?谁把消息写进磁盘?用户中途插一句话该塞到哪?谁能按下取消?进程崩了之后,做到一半的工作能不能接着做?

pi-agent-core 的做法是把这些问题分给三层:

📘 概念pi-agent-core 的三层(three layers of pi-agent-core)
无状态层(agent-loop.ts):一个模块内私有的函数 runLoop,进去的是「上下文 + 配置 + 事件回调」,出来的是一串事件和新消息。它不持有任何跨调用的字段,跑完就忘。
有状态层(agent.ts 的 Agent 类):在循环外面包一层,持有 transcript、维护 AgentState、提供 subscribe() 事件订阅和两个消息队列。
持久化编排层(harness/ 目录下的 AgentHarness):把会话和「做到哪一步了」都写进存储,按 lane(一条可独立运行的会话支线)管理操作、队列、hook 与事件,崩溃后能从存储里恢复。

反直觉的地方在这里:AgentHarness 既不使用 Agent 类,也不调用 runAgentLoop。它有一台自己的「Drive 状态机」(harness/runtime/drive.ts),直接通过 pi-ai 的 models.streamSimple 请求模型(harness/runtime/drive/generation.ts:216)。可以自己复核:grep -rn "agent-loop" packages/agent/src/harness 一处都搜不到。也就是说第三层不是套在第二层外面的壳,而是另起炉灶的一套实现。

图 6.3-1 三层的调用关系:两台引擎,一个模型出口
箭头是「调用」方向。请重点看最右边那条:AgentHarness 绕开了下面两层,自己直接指向模型出口;只有 Agent 类是建立在 runLoop 之上的。两条通往 streamSimple 的箭头说明:同一个 pi-ai 接口(6.1 讲过)被两台互不相干的引擎共用。

这张图的每个节点都有对应源码:runLoop 在 agent-loop.ts:162,Agent 类在 agent.ts:187,AgentHarness 在 harness/agent-harness.ts:622(一个只带 create 方法的常量对象,真正的实现类 Harness 在 harness/runtime/harness.ts:29)。三者全部从包的 index.ts 导出(packages/agent/src/index.ts:41-43),对外都是一等公民。

第一层:无状态的 runLoop ​

runLoop 本身没有 export,包对外只暴露四个入口:agentLoop(新提示词,返回事件流)、agentLoopContinue(从现有上下文续跑)、runAgentLoop 与 runAgentLoopContinue(同样两件事,但用回调收事件而不是返回流)。前两个内部就是 void runAgentLoop(...).then(stream.end)(agent-loop.ts:46-57)。

runAgentLoop 在进入循环前先把开场事件发完:agent_start → turn_start → 每条 prompt 的 message_start / message_end(agent-loop.ts:116-121),然后才把控制权交给 runLoop。发之前它还会先过一遍 declareToolChanges(109 行,下面细讲),必要时在 prompt 前面插一条 system 消息。

内外双层 while ​

ts
// packages/agent/src/agent-loop.ts:178-242(节选,prepareRequest 的多行调用压成了一行)
while (true) {                                                   // 外层:follow-up 续跑
	let hasMoreToolCalls = true;
	while (hasMoreToolCalls || pendingMessages.length > 0) {      // 内层:工具与插话
		let preparedMessages: AgentMessage[] = [];
		if (lastCompletedTurn) {                                  // 不是本次运行的第一轮
			const nextTurnSnapshot = await config.prepareNextTurn?.(lastCompletedTurn);
			// …(省略:用快照替换 context / model / thinkingLevel,必要时再轮询一次 steering)
			await emit({ type: "turn_start" });
		}
		for (const message of declareToolChanges(currentContext, [...preparedMessages, ...pendingMessages])) {
			await emit({ type: "message_start", message });
			await emit({ type: "message_end", message });
			currentContext.messages.push(message);
			newMessages.push(message);
		}
		pendingMessages = [];
		const requestUpdate = await config.prepareRequest?.({ context: currentContext, model: config.model, thinkingLevel: config.reasoning ?? "off" }, signal);
		// …(省略:用 requestUpdate 替换 context / model / thinkingLevel)
		const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction);
		newMessages.push(message);
		// …(省略:错误与中止的提前返回,见下一段摘录)
earendil-works/pi@16787ad第 159–182 行在 GitHub 查看 ↗
runLoop 的签名与双层 while 的入口。内层条件 hasMoreToolCalls 初值为 true(179 行),所以第一轮一定会执行一次。

四个细节值得停一下:

  1. lastCompletedTurn 决定是不是「新一轮」(172 行声明,184-207 行使用):内层循环第一圈时它还是 undefined,于是跳过 prepareNextTurn 和 turn_start——runAgentLoop 已经在 117 行发过一次 turn_start,这里再发就重复了。从第二圈起,每圈开头先调 prepareNextTurn,允许调用方换掉下一轮的上下文、模型和思考等级,还能追加几条消息;准备工作可能很久(比如做一次压缩),所以之后若手头没有排队消息,会再轮询一次 steering(203-205 行)。
  2. 排队消息在请求模型之前注入(210-216 行):它们被当作普通消息发一遍 message_start / message_end,然后同时推进 currentContext.messages(模型看得到)和 newMessages(返回给调用方)。这两个数组的分工贯穿整个函数。
  3. prepareRequest 在每次请求前都跑,包括第一次(218-238 行):它拿到的是已经注入完排队消息的上下文,可以整体替换 context、model、thinkingLevel。它不轮询队列——在它运行期间排进来的 steering 要等下一个正常轮询点。
  4. streamAssistantResponse 是唯一的模型出口(241 行):它内部先 transformContext(390 行)、再 convertToLlm(394 行)把 AgentMessage[] 投影成 LLM 认识的 Message[],然后用 pi-ai 的 normalizeContext 规范化成 TranscriptContext(396 行),最后调 streamFunction(402-406 行)。

第 2 条里的 declareToolChanges(332-362 行)需要多说一句。6.1 会讲到,pi-ai 现在把系统提示词和工具声明都放进 transcript 里的 system 消息。于是就有两份「工具清单」:context.tools 是运行时能执行的工具,transcript 里的 system 消息声明的是模型以为能调的工具。declareToolChanges 在每批消息注入前比对两者,有差异就生成一条带 toolsAdded / toolsRemoved 的 system 消息,插在第一条非 system 消息前面(源码注释原话是「replay always yields exactly context.tools」)。所以 message_start / message_end 现在也会为 system 消息发出(types.ts:492 的注释写明了这一点)。

一个 turn 里发生了什么 ​

源码注释把 turn 定义得很清楚:「a turn is one assistant response + any tool calls/results」(types.ts:489)。也就是说一次模型响应加上它引发的全部工具执行算一个 turn,而不是一问一答。

ts
// packages/agent/src/agent-loop.ts:244-286(节选,两处 lastCompletedTurn 对象字面量压成了一行)
if (message.stopReason === "error" || message.stopReason === "aborted") {
	lastCompletedTurn = { message, toolResults: [], context: currentContext, newMessages };
	await config.finishTurn?.(lastCompletedTurn, signal);           // 返回值被忽略
	await emit({ type: "turn_end", message, toolResults: [] });
	await emit({ type: "agent_end", messages: newMessages });
	return;
}
const toolCalls = message.content.filter((c) => c.type === "toolCall");
const toolResults: ToolResultMessage[] = [];
hasMoreToolCalls = false;
if (toolCalls.length > 0) {
	const executedToolBatch =
		message.stopReason === "length"
			? await failToolCallsFromTruncatedMessage(toolCalls, emit)
			: await executeToolCalls(currentContext, message, config, signal, emit);
	toolResults.push(...executedToolBatch.messages);
	hasMoreToolCalls = !executedToolBatch.terminate;
	// …(省略:把每条工具结果同时推进 currentContext.messages 与 newMessages)
}
lastCompletedTurn = { message, toolResults, context: currentContext, newMessages };
const decision = await config.finishTurn?.(lastCompletedTurn, signal);
await emit({ type: "turn_end", message, toolResults });
earendil-works/pi@16787ad第 244–286 行在 GitHub 查看 ↗
一个 turn 的主体:错误出口、toolCall 检测、批量执行、结果回填上下文,然后 finishTurn 定稿,最后发 turn_end。

hasMoreToolCalls 在 261 行被先置为 false——没有工具调用,内层循环就会在下一次判断条件时退出。有工具调用时它取决于 executedToolBatch.terminate:只有当这一批工具结果每一个都带 terminate === true 才算终止(shouldTerminateToolBatch,agent-loop.ts:685-687,在串行与并行两条执行路径末尾各调一次,579 与 655 行)。部分工具想终止是不够的。被 beforeToolCall 拦下的调用也能参与这条规则:拦截结果里带上 terminate: true,生成的错误结果就会带上这个标记(741-743 行)。

另外注意 stopReason === "length" 这个分支:模型输出被 token 上限截断时,工具参数可能是残缺的,于是全批直接失败而不执行(failToolCallsFromTruncatedMessage,475-500 行)。这是一个很容易被忽略的安全设计。

finishTurn 的位置也值得记住:工具结果全部落定之后、turn_end 之前。正常 turn 里它的返回值会影响调度(下一段);error / aborted 这条硬出口虽然也调它,却根本不读返回值(251 行)。

出口与队列轮询 ​

ts
// packages/agent/src/agent-loop.ts:288-319(节选,省略了注释)
		if (decision?.action === "end") {
			await emit({ type: "agent_end", messages: newMessages });
			return;
		}
		explicitContinuation = decision?.action === "continue";
		pendingMessages = (await config.getSteeringMessages?.()) || [];
		if (hasMoreToolCalls || pendingMessages.length > 0) {
			explicitContinuation = false;                // 已经有「自然」的下一轮了
		}
	}                                                    // 内层 while 结束
	const followUpMessages = (await config.getFollowUpMessages?.()) || [];
	if (followUpMessages.length > 0) {
		explicitContinuation = false;
		pendingMessages = followUpMessages;
		continue;                                        // 回到内层
	}
	if (explicitContinuation) {                          // finishTurn 要求再问一次
		explicitContinuation = false;
		continue;
	}
	break;
}                                                        // 外层 while 结束
await emit({ type: "agent_end", messages: newMessages });
packages/agent/src/agent-loop.ts · getFollowUpMessages
earendil-works/pi@16787ad第 288–319 行在 GitHub 查看 ↗
turn 结束后的三件事:finishTurn 的决定是否提前收工、轮询 steering 队列、外层轮询 follow-up 队列;最后兑现 finishTurn 的 continue 要求。

finishTurn 可以返回三种东西:undefined(按正常规则调度)、{ action: "end" }(立刻收工,连队列都不看)、{ action: "continue" }(保证至少再发一次请求)。continue 的语义有个巧妙之处:如果工具结果、steering 或 follow-up 本来就会引出下一次请求,这个要求就算「已被满足」,不会额外多问一次;只有什么都没有时,才用现有上下文补跑一轮(explicitContinuation 这个变量就是干这个的)。类型注释(types.ts:142-154)和 README 都这样说明,测试 "makes exactly one context-only request when no natural request satisfies continuation"(test/agent-loop.test.ts:1225)守着它。

把三个钩子按时间排一下(分析解释,依据是上面几段源码的先后):

钩子何时运行能做什么
prepareNextTurn确定要开下一轮之后、下一轮 turn_start 之前;第一轮不跑换 context / model / thinkingLevel,追加消息
prepareRequest每次请求模型之前,包括第一次;排队消息已注入换 context / model / thinkingLevel
finishTurn工具结果全部落定之后、turn_end 之前返回 end 收工、continue 再问一次

官方 README 给出了每轮的生命周期(packages/agent/README.md:165-176:prepareRequest → 模型响应 → 工具结果 → finishTurn → turn_end),但没有提 prepareNextTurn 排在哪;这个位置只能从源码读出来。README 还提醒:旧版的 shouldStopAfterTurn 已被移除,迁移方法是在 finishTurn 里返回 { action: "end" }(README.md:156)。

于是 runLoop 一共有四个出口:

#出口位置
1模型响应 stopReason 为 error 或 abortedagent-loop.ts:244-255
2整批工具结果都要求 terminateagent-loop.ts:271 + 685-687
3finishTurn 返回 { action: "end" }agent-loop.ts:288-291
4没有工具调用、没有 steering、没有 follow-up,finishTurn 也没要求 continue内层条件 182 不成立 + 外层 316 break

第 2 个出口严格说是「工具不再推着循环走」:它只把 hasMoreToolCalls 置为 false,如果这时恰好取到了 steering 或 follow-up,循环照样继续(294-307 行)。

图 6.3-2 runLoop 的内外双层循环与四个出口
从上到下读。Prep → Inject → Req → Stream → Tools → Finish → PollSteer 回到 Prep 是内层循环;PollFollow → Prep 是外层循环;Cont → Prep 是 finishTurn 要求补跑的那一轮。四条指向 End 的箭头对应上表的四个出口(整批 terminate 体现为 PollSteer 不再因工具调用回到 Prep)。P0 对应 agent-loop.ts:175 那次容易被忽略的开局轮询;第一轮从 P0 直接进 Inject,所以不经过 prepareNextTurn。

AgentEvent:循环对外说话的 10 个词 ​

runLoop 不返回中间状态,它只发事件。AgentEvent 是一个可辨识联合,恰好 10 种:

earendil-works/pi@16787ad第 485–500 行在 GitHub 查看 ↗
10 种事件的完整定义。注意这里没有顶层的 text_delta——文本增量藏在 message_update 的 assistantMessageEvent 字段里。
事件含义发射点(agent-loop.ts)
agent_start一次 run 开始116、145
turn_start一个 turn 开始117、146、206
message_start一条消息(system / user / assistant / toolResult)开始119、211、417、449、462、896
message_update仅 assistant 流式期间,携带 pi-ai 的 AssistantMessageEvent433
message_end一条消息定稿120、212、451、464、897
tool_execution_start一次工具执行开始482、540、595
tool_execution_update工具汇报中间进度791
tool_execution_end一次工具执行结束872
turn_end本 turn 的 assistant 消息与全部工具结果已齐252、286
agent_end本次 run 不会再有事件253、289、319

message_update 是一个「信封」:里面装的是 6.1 讲过的 12 种 AssistantMessageEvent(packages/ai/src/types.ts:652-668),其中 9 种「块内事件」(text_delta、toolcall_delta 等)被统一折叠进这一个 message_update,原事件放在 assistantMessageEvent 字段里随行(agent-loop.ts:420-437)。5.4 流式事件如何传播到界面 已经把这层映射讲透,这里不再展开。

steering:用户中途插话如何进入循环 ​

这是 Pi 交互体验里最容易被当成「魔法」的一块:模型正在跑工具,你在输入框里又敲了一句话回车,它没有打断当前动作,但下一轮就带上了你的新要求。

机制其实很朴素——循环在几个固定位置去问一句「队列里有东西吗」:

  • agent-loop.ts:175,开局前一次(注释原文说明这是为了接住「用户在等待时打的字」);
  • agent-loop.ts:294,每个 turn 结束后一次;
  • agent-loop.ts:204,prepareNextTurn 跑完之后、手头还没有排队消息时补一次;
  • agent-loop.ts:301,外层的 follow-up 轮询。

关键是位置:steering 的轮询在 finishTurn 与 turn_end 之后,也就是当前这条 assistant 消息的工具已经全部跑完才去取。所以 steering 从不打断执行中的工具,只在 turn 边界插队。

图 6.3-3 一次 steering 插话的时序
请注意用户的入队动作发生在最上方,而队列被读走发生在两次工具执行之后。这正是 agent-loop.ts:294 那一行的位置带来的语义:插话不抢占工具,只在 turn 边界生效。这条时序被 test/agent-loop.test.ts:714 的用例逐条断言过。

排水的粒度由 QueueMode 控制(types.ts:55):"one-at-a-time" 每个排水点只取最旧的一条,"all" 一次全取。Agent 类的默认值是 "one-at-a-time"(agent.ts:244-245);AgentHarness 的默认值却是 "all"(harness/runtime/harness.ts:66-67)——同一个包里两套实现的默认值并不一致,换层时要留意。一种看法是 Agent 的默认值选得保守:一次只喂一条能让模型逐条消化用户的连续插话,代价是排队多时要多跑几个 turn 才能全部消费。

follow-up 的差别只有一个:它在内层循环已经退出、Agent 本来要停下时才被读(301 行)。「现在就改方向」用 steering,「等你忙完再说」用 follow-up。

第二层:有状态的 Agent 类 ​

Agent 类的自述是「低层 agent loop 的有状态包装」(agent.ts:181-186 注释)。它把三样东西补给了循环:transcript、事件订阅、消息队列。

earendil-works/pi@16787ad第 367–408 行在 GitHub 查看 ↗
公开入口 prompt 与 continue。prompt 有三个重载;正在跑时再调 prompt 会抛错,提示改用 steer 或 followUp。

它的公开 API 可以按用途分成五组:

  • 发起:prompt(文本 | 消息 | 消息数组)、continue()。continue() 要求末条消息不是 assistant;如果是 assistant,它会先尝试排干 steering 队列(agent.ts:392-396,带 skipInitialSteeringPoll: true),再尝试 follow-up 队列(398-402),都空才抛错。
  • 插话:steer(message) / followUp(message)(295-303 行)以及 clearSteeringQueue / clearFollowUpQueue / clearAllQueues / hasQueuedMessages;peekQueuedMessages()(326-330 行)可以预览下一轮会取走哪些消息而不真正消费。
  • 观察:subscribe(listener)(263-266 行)返回取消函数;监听器按注册顺序被 await(605-607 行)。
  • 钩子:prepareRequest、finishTurn、prepareNextTurn / prepareNextTurnWithContext 都是可以随时改写的公开字段(207-215 行),createLoopConfig 每次运行时把它们原样交给循环(478-488 行)。
  • 控制:abort()(337-340 行)、waitForIdle()(347-349 行)、reset()(351-365 行,运行中调用会直接抛错;清空时保留 transcript 开头那条承载系统提示词与工具声明的 system 消息)。

这里顺带说一个和 6.1 呼应的变化:AgentState.systemPrompt 现在是只读的,它的值是从 transcript 里的 system 消息「回放」出来的;想改系统提示词,得往 transcript 里追加一条 system 消息(types.ts:379-385 的注释)。transcript 成了系统提示词和工具声明的唯一来源。

队列和循环的接线只有八行:

ts
// packages/agent/src/agent.ts:492-499(createLoopConfig 内)
getSteeringMessages: async () => {
	if (skipInitialSteeringPoll) {
		skipInitialSteeringPoll = false;
		return [];
	}
	return this.steeringQueue.drain();
},
getFollowUpMessages: async () => this.followUpQueue.drain(),
packages/agent/src/agent.ts · getSteeringMessages
earendil-works/pi@16787ad第 492–500 行在 GitHub 查看 ↗
Agent 把自己的两个队列注入低层循环的两个回调。skipInitialSteeringPoll 用于 continue 已经手工排过一次队的场景,避免同一条消息被消费两次。

事件处理在 processEvents(561-608 行)里分两步:先用 switch 把事件归约进 _state(例如 message_end 时把消息推进 transcript、tool_execution_start 时把 id 加入 pendingToolCalls),再逐个 await 订阅者。顺序很重要:订阅者被调用时看到的一定是已更新的状态。也因为监听器是被 await 的,agent_end 发出后 Agent 还不算 idle——要等所有监听器结算完(agent.ts:554-560 的注释与 README.md:208 一致)。

coding-agent 怎样用这三个钩子 ​

三个钩子不是摆设,coding-agent 的 AgentSession 把它们全用上了(源码事实,packages/coding-agent/src/core/agent-session.ts):

  • prepareRequest(608-633 行):每次请求前,用 SessionManager 重新投影出的消息整体替换上下文。这就是 6.8 SDK 要讲的「SessionManager 才是权威,改 agent.state.messages 不影响后续请求」的实现位置。
  • finishTurn(675-685 行):把扩展的 turn_end 处理接进来,扩展要求继续时返回 { action: "continue" }。
  • prepareNextTurnWithContext(687 行起):在下一轮开始前按需自动压缩,并刷新系统提示词与工具清单。

不一致之一:README 说 streamFn 必填,构造器却有兜底 ​

AgentOptions.streamFn 在类型上是必填的(agent.ts:118,没有 ?),README 的选项说明也写着 // Required stream function(官方说明,packages/agent/README.md:236)。但构造器实际写的是:

ts
// packages/agent/src/agent.ts:229-234(节选)
// Older compiled consumers may omit options or streamFn even though the current API requires them.
const runtimeOptions: Partial<AgentOptions> = options ?? {};
// …(省略:其余字段赋值)
this.streamFunction = runtimeOptions.streamFn ?? getDefaultStreamFn();
packages/agent/src/agent.ts · getDefaultStreamFn
earendil-works/pi@16787ad第 228–251 行在 GitHub 查看 ↗
构造器把 options 整个当作 Partial 处理,streamFn 缺席时回退到模块级默认值。注释直言这是为「更早编译出去的消费者」保留的。

这个默认值来自 stream-fn.ts 的一对函数:setDefaultStreamFn() 安装、getDefaultStreamFn() 取出(没装就抛错,stream-fn.ts:15-20)。coding-agent 在 packages/coding-agent/src/core/sdk.ts:39 调用 setDefaultStreamFn(streamSimple) 装上真实实现——这正是「agent core 对 provider 一无所知」的解耦手法:依赖注入为主,全局默认为辅。

所以准确的说法是:类型契约上必填,运行时有兜底。README 描述的是推荐用法,不是运行时约束(事实分级:前者是官方说明,后者是源码事实)。test/agent-loop.test.ts:86-87 与 test/agent.test.ts:107 各有一条用例 "uses the configured default when a legacy caller omits streamFn" 守着这个行为。

第三层:持久化编排层 AgentHarness ​

官方规范对它的定位是「面向 agent 对话的持久化运行时:把对话和操作状态持久化,让中断的工作能恢复,而不重复已经落定的副作用」(官方说明,packages/agent/docs/harness.md:21)。这句话里的关键词是「持久化」和「恢复」——前两层都没有这种能力:runLoop 的进度全在局部变量里(hasMoreToolCalls、pendingMessages),进程一崩就没了。

先看它长什么样:

earendil-works/pi@16787ad第 614–622 行在 GitHub 查看 ↗
AgentHarness 不是 class,而是一个只有 create 方法的常量对象。create 接收会话与模型等配置,返回 harness 本身和一组「上次没做完的操作」open。

agent-harness.ts 这个文件现在几乎只剩类型声明(AgentLane、AgentHarness 两个接口,HarnessEvent、HookMap 等);实现都在 harness/runtime/ 下。create(harness/runtime/harness.ts:375-408)先从会话存储里恢复每条 lane 的状态,再把仍在进行中的操作作为 open 交还给调用方——由调用方决定要不要接着 drive。

使用上有两个和前两层很不一样的地方:

  1. 以 lane 为单位干活:harness.lane("main", context) 拿到一条 AgentLane,prompt、steer、abort 这些方法都在 lane 上(接口见 agent-harness.ts:538-580)。一个会话里可以有多条 lane 各自运行。
  2. 每个异步方法都要显式传一个 Context:规范解释这是为了让并发调用各自带上自己的取消信号与遥测父节点(docs/harness.md 第 0.2 节)。

它不调用 runAgentLoop:一台持久化的状态机 ​

Harness 推进一次操作(operation,一次运行、压缩或会话树导航)用的是 driveOperation:

ts
// packages/agent/src/harness/runtime/drive.ts:48-105(节选,switch 分支压成了单行)
for (;;) {
	operation = currentOperation(lane, drive);
	const state = operation.state;
	let result: ProcedureResult;
	try {
		if (state.control.status === "cancel_requested") {
			result = await reconcileOperation(lane, drive);
		} else
			switch (state.at) {
				case "starting":        result = await startRun(lane, drive, state); break;
				case "checkpoint":      result = await runCheckpoint(lane, drive, state); break;
				case "assistant.ready":
				case "assistant.retry_wait":
					result = await runGeneration(lane, drive, state); break;
				case "tools":           result = await runTools(lane, drive, state); break;
				// …(省略:deferred、summary、navigation 等其余状态)
			}
	} catch (error) {
		// …(省略:中止请求在这里被吸收,继续下一圈)
	}
	if (result.kind === "settled") return { kind: "settled", outcome: result.outcome };
	// …(省略:waiting 时返回,以及「一圈下来状态没变」的防御性检查)
}
earendil-works/pi@16787ad第 28–106 行在 GitHub 查看 ↗
Harness 的主循环:一个 for 死循环加一个按 state.at 分派的 switch。每个分支做一小步、把新状态写进存储,然后回到循环顶部重新读状态。

和 runLoop 对比着看:runLoop 用控制流记录进度——走到第几行、hasMoreToolCalls 是多少,就是做到哪一步了;driveOperation 用数据记录进度——state.at 是写在存储里的值("checkpoint"、"assistant.ready"、"tools"……),每个分支做完一小步就把新状态提交进存储,再回到循环顶部重新读。进程崩了,下次 create 读出 state.at,就知道该从哪个分支接着做。从源码结构看,这就是它没法复用 runLoop 的根本原因:一个活在调用栈里,一个活在存储里。

一条 lane 同时只有一个操作 ​

旧的「相位锁」概念,在这里变成了一条更简单的规则:每条 lane 至多有一个进行中的操作。lane.prompt() 其实是两步——先 accept(把操作持久化地登记下来),再 drive(推进它直到落定或需要等待)(runtime/lane.ts:1158-1197 的 driveRunRequest)。accept 发现 lane 上已有操作时,返回一个 LaneBusy 错误(runtime/lane.ts:575-587)——注意是返回 Result,不是抛异常。

所有状态变更都走同一个入口 command(),它把「提交存储」和「发事件」排成固定顺序:

ts
// packages/agent/src/harness/runtime/lane.ts:351-361(节选)
case "commit": {
	const commit = await mutator.commit(decision.writes, context);   // 先原子提交
	this.state = decision.next;                                      // 再更新内存状态
	this.signalStateChange();
	const result = decision.materialize(commit);
	// …(省略:materialize 必须是同步的检查)
	const events = decision.events?.(commit) ?? [];                  // 最后才产出事件
	const delivery = events.length === 0 ? undefined : this.emitBatch(events, context);
	return { kind: "return", result, ...(delivery === undefined ? {} : { delivery }) };
}
earendil-works/pi@16787ad第 328–381 行在 GitHub 查看 ↗
lane 上所有状态变更的唯一入口:在会话的串行变更线上做决定,提交事务后才更新内存状态和发事件。

订阅者收到事件时,对应的写入已经落盘——这是一条全局规则,而不只是某几种事件的特例。还要注意 Harness 的事件不是 AgentEvent:HarnessEvent(agent-harness.ts:255-373 的 HarnessEventPayload)有自己的一套词汇,比如 run_start / run_end、tool_start / tool_end、queue_update、entry_added、retry_scheduled,外加 lane 字段标明来自哪条 lane。会话存储的格式属于 6.5 Session 存储格式与会话树 的范围。

三个队列的语义差异 ​

earendil-works/pi@16787ad第 1418–1432 行在 GitHub 查看 ↗
三个入队方法并排放着,都转给同一个 enqueue:消息被写进会话存储里的 pending 条目,并登记到 lane 的收件箱(inbox)。
队列入队条件何时被消费中止操作时
steer任何时候(lane 未关闭即可)每个边界(checkpoint)都会取(runtime/drive/boundary.ts:88-89);空闲时入队的,下次 accept 取从收件箱移除,并作为返回值交还调用方(runtime/lane.ts:1047-1060)
followUp任何时候只在运行本要结束时才取(boundary.ts:109-112)同上
nextRun任何时候只在下一次 accept 新操作时取(runtime/lane.ts:143-167 的 selectAcceptedInbox)保留

和 Agent 类最大的差别是队列本身是持久化的:enqueue(runtime/lane.ts:1433-1516)把消息写进会话存储,进程重启后队列还在。steer 与 followUp 的「边界取、结束前取」语义和 runLoop 的轮询点一脉相承。

中止的语义是「取消这次运行,steer 和 followUp 退还给你,排给下一次的 nextRun 仍然算数」。AbortResult 的类型把退还的两组消息写得明明白白(agent-harness.ts:99-102)。test/harness/runtime/drive-reconcile.test.ts:494 的用例 "durably drains abortable input once and preserves lane-owned input through terminal cleanup" 正是守这条规则的。

不一致之二:OperationStatus 里的 "running" 从未被产出 ​

操作状态的类型写着三个取值:

ts
// packages/agent/src/harness/agent-harness.ts:147
export type OperationStatus = "running" | "open" | "aborting";

但在 harness/runtime/ 里,给操作状态赋值的地方只产出两种:inspectExecution 按是否已请求取消给出 "aborting" 或 "open"(runtime/lane.ts:1116-1119),快照构建与事件归约也一样(runtime/lane.ts:1862、runtime/reducer.ts:34-61)。源码里其他的 status: "running" 属于别的类型——工具快照(LaneSnapshotTool)和操作的控制状态(control.status),不是 OperationStatus。

这一回官方规范自己承认了:「"running" has no defined producer and is tracked as contract cleanup」(官方说明,packages/agent/docs/harness.md:1136,在 0.9 节的已知问题清单 H1 里再次列出)。何时补上生产者或从类型里删掉,尚未确认。

⚠️ 常见误解把类型里的取值当成运行时一定会出现的状态
写 switch (status) 时如果为 "running" 分支准备了逻辑,在当前版本上它是死代码;反过来,如果因为「类型里有」就认为一定要处理,也会白写。判断一个取值是否真的会出现,唯一可靠的办法是搜赋值点,而不是读类型定义——而且要留意同名字面量是否属于同一个类型。

谁在用哪一层 ​

在本书锁定的版本上,packages/coding-agent 的主路径用的仍是 Agent 类(packages/coding-agent/src/core/sdk.ts:366 的 new Agent({)。AgentHarness.create(...) 只出现在 packages/agent 自己的测试、coding-agent 的一个测试夹具,以及 coding-agent 的 src/experimental/ 目录里(session-worker.ts、mini/worker/run.ts);而 coding-agent 发布的 npm 包把这个目录整个排除了(packages/coding-agent/package.json:32 的 "!dist/experimental")。这是源码事实,你可以用 grep -rln "AgentHarness.create(" packages --include="*.ts" 复核。

据此推断(尚未在源码中直接证实):Harness 是正在成型的下一代运行时,coding-agent 的正式迁移尚未发生——规范自述「Storage format 4 is still WIP (pre-stabilization)」(docs/harness.md:158),唯一还没实现的公开方法 watchSession 会抛 SliceNotImplemented(runtime/harness.ts:305-307),都支持这个方向,但迁移计划本身尚未确认。

实践任务 ​

🛠 实践任务用真实测试验证 steering 与 terminate 的两条规则

目标:不写一行新代码、不需要任何 API Key,用 Pi 仓库自带的单元测试确认两件事:① steering 消息一定在整批工具执行完之后才被注入;② 只有整批工具结果都要求 terminate 时循环才提前停。

前提:已按 4.1 的实践任务准备好 Pi 源码,并在仓库根目录执行过 npm install --ignore-scripts。另外还要先构建一次 pi-ai:npm run build:offline --workspace=@earendil-works/pi-ai——agent 包的源码会 import @earendil-works/pi-ai/utils/uuid 这类子路径,它们只指向 dist/,不构建就会报 Cannot find package '@earendil-works/pi-ai/utils/uuid'(作者本机实测)。这两个测试文件用的都是仓库内置的 mock 流与 faux provider,全程不联网、不读 API Key。

步骤 1:在 Pi 仓库根目录运行(注意 -- 之后的参数是传给 vitest 的):

npm test --workspace=@earendil-works/pi-agent-core -- \
  test/agent-loop.test.ts --reporter=verbose --testNamePattern="queued|terminate"

预期现象:五条用例被执行(✓),其余用例被跳过(↓)。作者在本机真实运行的结果如下(只保留了 ✓ 行与汇总,时间与耗时会不同):

text
 ✓ test/agent-loop.test.ts > agentLoop with AgentMessage > should inject queued messages after all tool calls complete 3ms
 ✓ test/agent-loop.test.ts > agentLoop with AgentMessage > picks up steering queued during prepareNextTurn before the next request 0ms
 ✓ test/agent-loop.test.ts > agentLoop with AgentMessage > should stop after a tool batch when every tool result sets terminate=true 0ms
 ✓ test/agent-loop.test.ts > agentLoop with AgentMessage > should stop after a blocked tool call when beforeToolCall sets terminate=true 0ms
 ✓ test/agent-loop.test.ts > agentLoop with AgentMessage > should continue after parallel tool calls when not all tool results terminate 0ms

 Test Files  1 passed (1)
      Tests  5 passed | 30 skipped (35)

步骤 2:打开 packages/agent/test/agent-loop.test.ts,读第一条用例(714 行起)末尾的三组断言,它们正好对应本章的三句话:

  • 793 行 expect(executed).toEqual(["first", "second"])——两个工具都跑完了;
  • 812-813 行——"interrupt" 这条插话在事件序列里排在两条 toolResult 之后;
  • 816 行 expect(sawInterruptInContext).toBe(true)——第二次模型请求的上下文里能看到它。

再回头看这条用例的 getSteeringMessages(747-754 行):它在第一个工具执行后就已经准备好返回消息了,但事件序列显示消息仍然排在第二个工具之后——把这个现象和 agent-loop.ts:294 那一行的位置对上,你就理解 steering 为什么不抢占工具。第二条用例(1532 行)则对应本章讲 prepareNextTurn 时提到的那次补充轮询(agent-loop.ts:203-205)。

步骤 3(选做):跑一条 Harness 的规则。harness 测试用的是另一份配置,命令不同:

npm run test:harness --workspace=@earendil-works/pi-agent-core -- \
  test/harness/runtime/drive-reconcile.test.ts --reporter=verbose \
  --testNamePattern="drains abortable input"

作者本机真实运行的结果是 Tests 1 passed | 9 skipped (10)。这条用例验证的就是上表最后一列:中止时 steer 与 followUp 被移出收件箱并退还,nextRun 保留。

如何判断成功:① 五条用例全绿;② 你能说出为什么「every tool result sets terminate=true」那条用例里 llmCalls 断言为 1(提示:terminate 让 hasMoreToolCalls 变成 false,用例又没有排队消息,内层循环条件不再成立,模型不会被问第二次);③ 你能指出「not all tool results terminate」那条用例如果改成源码「只要有一个 terminate 就停」会先挂在哪条断言上。

常见错误(以下三条都在本机复现过):

  • 忘了 --:写成 npm test --workspace=... test/agent-loop.test.ts --reporter=verbose,npm 会把 --reporter 当成自己的配置项,只打印一行 npm warn Unknown cli config "--reporter" 然后把它丢掉——测试照跑(整个文件 35 条全跑),但输出退回默认的点阵,你根本看不到用例名。
  • 模式串不加引号:--testNamePattern=queued|terminate 里的 | 会被 shell 当成管道,报 command not found: terminate。换成空格分词也不行——--testNamePattern=queued messages 会让 messages 变成额外的文件过滤参数,名字模式缩水成 queued,结果变成 2 passed | 33 skipped,和预期的 5 条对不上。
  • 把 test:harness 当成普通的 test:两者用的是不同配置文件。test:harness 走 packages/agent/vitest.harness.config.ts,它把 include 收窄到 test/harness/**/*.test.ts,也没有指定 reporter;npm test 走 vitest.config.ts,默认 reporter 是 dot。步骤 3 若误用 npm test 又漏了 --reporter=verbose,用例仍会通过,但你拿到的是点阵输出而不是用例名——照抄命令时别把 run test:harness 写丢。

对应源码位置:packages/agent/src/agent-loop.ts:294(steering 轮询点)、271 与 685-687(terminate 判定)、182(内层循环条件);packages/agent/src/harness/runtime/lane.ts:1047-1060(中止时退还队列)。测试文件:packages/agent/test/agent-loop.test.ts:714、1684、1851,packages/agent/test/harness/runtime/drive-reconcile.test.ts:494。

本章小结 ​

  • pi-agent-core 是三层结构:无状态的 runLoop(agent-loop.ts:162)、有状态的 Agent 类(agent.ts:187)、持久化编排层 AgentHarness(harness/agent-harness.ts:622,实现在 harness/runtime/)。只有 Agent 建在 runLoop 之上;Harness 另有一台持久化的 Drive 状态机,直接调 models.streamSimple。
  • runLoop 的内层 while 处理「工具调用 + 插话」,外层 while 只为 follow-up 续跑。一个 turn = 一次 assistant 响应 + 它引发的全部工具执行。三个钩子各守一个时间点:prepareNextTurn(下一轮开始前)、prepareRequest(每次请求前)、finishTurn(turn_end 前)。
  • 四个终止出口:stopReason 为 error/aborted、整批工具 terminate、finishTurn 返回 end、无工具且两个队列都空且没有 continue 要求。
  • 循环对外只发 10 种 AgentEvent;文本增量不是顶层事件,而是包在 message_update.assistantMessageEvent 里;system 消息也会有 message_start / message_end。
  • steering 在每个 turn 结束后被轮询,因此从不打断执行中的工具;follow-up 只在 Agent 本要停下时被读。Harness 的三个队列(steer / followUp / nextRun)都持久化在会话存储里,中止时退还前两个、保留 nextRun。
  • 两处「类型/文档与运行时对不上」:README 标 streamFn 为 Required 但构造器有 getDefaultStreamFn() 兜底;OperationStatus 含 "running" 但源码从未产出(规范已自认)。
  • 关键术语:Agent Loop(Agent 循环)、turn、steering(中途插话)、follow-up(收尾追问)、terminate(提前终止提示)、lane、operation(持久化的操作)、依赖注入的流函数(StreamFn)。
  • 关键源码索引:
    • packages/agent/src/agent-loop.ts:37-99(四个入口)、101-150(runAgentLoop / runAgentLoopContinue)、159-320(runLoop)、332-362(declareToolChanges)、380-466(streamAssistantResponse)、475-500(截断保护)、685-687(terminate 判定)
    • packages/agent/src/agent.ts:187-251(类与构造器)、263-365(订阅、队列与控制 API)、367-408(prompt / continue)、464-501(createLoopConfig)、561-608(processEvents)
    • packages/agent/src/types.ts:33-37(StreamFn 契约)、142-187(finishTurn / prepareRequest 相关类型)、189-338(AgentLoopConfig)、485-500(AgentEvent)
    • packages/agent/src/stream-fn.ts:11-20、packages/coding-agent/src/core/sdk.ts:39(默认流函数的安装点)
    • packages/agent/src/harness/agent-harness.ts:538-622(AgentLane / AgentHarness 接口与 create)、packages/agent/src/harness/runtime/drive.ts:28-106(Drive 状态机)、packages/agent/src/harness/runtime/lane.ts:328-381(command)、1418-1516(三队列与 enqueue)
    • 测试:packages/agent/test/agent-loop.test.ts、packages/agent/test/agent.test.ts、packages/agent/test/harness/runtime/
  • 自测问题:① 模型这一轮返回了 3 个工具调用,其中 2 个的结果带 terminate: true,循环会停吗?为什么?② 用户在工具执行到一半时按回车提交了新内容,这条消息最早可能在哪个事件之后进入上下文?③ agent_end 事件发出后,Agent 立刻就 idle 了吗?④ 为什么说 AgentHarness 不是「包在 Agent 外面的一层」?它没法直接复用 runLoop 的根本原因是什么?
  • 下一章:6.4 工具系统:定义、校验与执行——AgentTool 的形状、参数校验、串行与并行执行策略、beforeToolCall / afterToolCall 两个钩子的完整语义。本章尚未展开的内容:工具批次内部的准备/执行/收尾三段式(agent-loop.ts:505-861)留给 6.4;AgentMessage 靠声明合并扩展自定义消息类型、再由 convertToLlm 投影回 LLM 认识的四种角色(system / user / assistant / toolResult;类型在 packages/agent/src/types.ts:347-370,Harness 侧的四种自定义消息与投影函数在 packages/agent/src/harness/messages.ts:53-60、124-169),原理已在 2.2 interface、type 与函数类型 讲过,实际用法要到第七部分 7.1 Extension 系统 才会用上;会话落盘的格式留给 6.5 Session 存储格式与会话树,压缩与会话树操作留给 6.6 Context 构造与 Compaction;proxy.ts 那个浏览器端 StreamFn 实现属于支线,本书不展开。
✅ 自测问题参考答案先自己回答,再点开对照
  1. 不会停。判定函数 shouldTerminateToolBatch(agent-loop.ts:685-687)要求整批工具结果都设了 terminate: true 才算数,3 个里只有 2 个不满足,于是 hasMoreToolCalls 仍然是 true,内层循环条件成立,模型会被再问一次。这条规则由测试 "should continue after parallel tool calls when not all tool results terminate" 守着。设计意图是:一个工具说「到此为止」不该替另外两个工具做主。
  2. 最早是在这一整批工具全部执行完、finishTurn 与 turn_end 之后。steering 队列的轮询点在 agent-loop.ts:294,位于工具批次执行与回填之后,所以插话从不打断执行中的工具——哪怕消息在第一个工具刚跑完时就已经排好队,也要等第二个工具跑完。本章实践任务第 2 步那条用例正是断言这一点:"interrupt" 在事件序列里排在两条 toolResult 之后,然后出现在第二次模型请求的上下文里。
  3. 没有。agent_end 只表示循环不会再发事件了,被 await 的订阅者仍属于本次 run 的结算过程;Agent 要等所有监听器跑完、finishRun() 清完状态才算空闲。这时再调 reset() 才不会抛「Agent is already processing」。
  4. 因为 AgentHarness 既不 import Agent 类,也不调 runAgentLoop——它有自己的 driveOperation 状态机,直接调 models.streamSimple,队列、事件、中止都是自己的一套。根本原因在于进度保存的位置:runLoop 把「做到哪一步」存在调用栈和局部变量里,进程一崩就丢;Harness 要求崩溃后能恢复,于是把进度写成存储里的 state.at,每做一小步就提交一次,只能用「读状态 → 做一步 → 写状态」的状态机来组织。可以自己复核:grep -rn "agent-loop" packages/agent/src/harness 搜不到任何结果。

本书分析的 Pi 版本:earendil-works/pi@16787ad(2026-09-21)