6.3 pi-agent-core:Agent 与循环
本页分析版本earendil-works/pi@c13ffe12026-07-30本章解决什么问题:
@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;② 逐段读懂runLoop的内外双层循环与四个终止出口;③ 背下AgentEvent的 10 种事件与各自的发射时机;④ 讲清 steering / follow-up / nextTurn 三个队列的语义差异;⑤ 亲手跑通描述这些行为的真实单元测试。
建立直觉:一个循环,三种驾驶舱
3.5 讲过 Agent Loop 的原理:拿上下文问模型 → 模型要么回答要么请求工具 → 执行工具 → 把结果塞回上下文 → 再问一次,直到模型不再要工具。这段逻辑本身只有几十行,难的是它周围的东西:谁记着 transcript?谁把消息写进磁盘?用户中途插一句话该塞到哪?谁能按下取消?
pi-agent-core 的做法是把这些问题分给三层,每层都能独立驾驶同一个循环:
agent-loop.ts):一个模块内私有的函数 runLoop,进去的是「上下文 + 配置 + 事件回调」,出来的是一串事件和新消息。它不持有任何跨调用的字段,跑完就忘。有状态层(
agent.ts 的 Agent 类):在循环外面包一层,持有 transcript、维护 AgentState、提供 subscribe() 事件订阅和两个消息队列。编排层(
harness/agent-harness.ts 的 AgentHarness):再往外一层,负责会话持久化、相位锁、扩展 hook、三个消息队列。 反直觉的地方在这里:AgentHarness 并不使用 Agent 类,它直接调用最底层的 runAgentLoop(源码事实,harness/agent-harness.ts:11 的 import 与 615 行的调用点)。也就是说第二层和第三层是并列的两个驾驶舱,不是套娃。
图 6.3-1 三层的调用关系:两个驾驶舱,一个引擎
箭头是「调用」方向。请重点看两条并列的箭头:AgentHarness 与 Agent 都直接指向底层循环,彼此之间没有箭头。最底下 streamFn 是唯一与模型服务接触的出口,对应 6.1 讲的 streamSimple。
这张图的每个节点都有对应源码:runLoop 在 agent-loop.ts:155,Agent 类在 agent.ts:171,AgentHarness 在 harness/agent-harness.ts:171。三者全部从包的 index.ts 导出(packages/agent/src/index.ts:3-6),对外都是一等公民。
第一层:无状态的 runLoop
runLoop 本身没有 export,包对外只暴露四个入口:agentLoop(新提示词,返回事件流)、agentLoopContinue(从现有上下文续跑)、runAgentLoop 与 runAgentLoopContinue(同样两件事,但用回调收事件而不是返回流)。前两个内部就是 void runAgentLoop(...).then(stream.end)(agent-loop.ts:40-51)。
runAgentLoop 在进入循环前先把开场事件发完:agent_start → turn_start → 每条 prompt 的 message_start / message_end(agent-loop.ts:109-114),然后才把控制权交给 runLoop。
内外双层 while
// packages/agent/src/agent-loop.ts:170-194(节选)
while (true) { // 外层:follow-up 续跑
let hasMoreToolCalls = true;
while (hasMoreToolCalls || pendingMessages.length > 0) { // 内层:工具与插话
if (!firstTurn) {
await emit({ type: "turn_start" });
} else {
firstTurn = false;
}
if (pendingMessages.length > 0) {
for (const message of pendingMessages) {
await emit({ type: "message_start", message });
await emit({ type: "message_end", message });
currentContext.messages.push(message);
newMessages.push(message);
}
pendingMessages = [];
}
const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction);
newMessages.push(message);
// …(省略:错误与中止的提前返回,见下一段摘录)runLoop三个细节值得停一下:
firstTurn标志(175-179 行):runAgentLoop已经在 110 行发过一次turn_start,所以内层循环第一圈跳过发射,避免重复。- 排队消息在请求模型之前注入(182-190 行):它们被当作普通消息发一遍
message_start/message_end,然后同时推进currentContext.messages(模型看得到)和newMessages(返回给调用方)。这两个数组的分工贯穿整个函数。 streamAssistantResponse是唯一的模型出口(193 行):它内部先transformContext(291 行)、再convertToLlm(295 行)把AgentMessage[]投影成 LLM 认识的Message[],最后调streamFunction(308-312 行)。
一个 turn 里发生了什么
源码注释把 turn 定义得很清楚:「a turn is one assistant response + any tool calls/results」(types.ts:426)。也就是说一次模型响应加上它引发的全部工具执行算一个 turn,而不是一问一答。
// packages/agent/src/agent-loop.ts:196-224(节选)
if (message.stopReason === "error" || message.stopReason === "aborted") {
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;
for (const result of toolResults) {
currentContext.messages.push(result);
newMessages.push(result);
}
}
await emit({ type: "turn_end", message, toolResults });hasMoreToolCallshasMoreToolCalls 在 206 行被先置为 false——没有工具调用,内层循环就会在下一次判断条件时退出。有工具调用时它取决于 executedToolBatch.terminate:只有当这一批工具结果每一个都带 terminate === true 才算终止(shouldTerminateToolBatch,agent-loop.ts:582-584,在串行与并行两条执行路径末尾各调一次,485 与 552 行)。部分工具想终止是不够的。
另外注意 stopReason === "length" 这个分支:模型输出被 token 上限截断时,工具参数可能是残缺的,于是全批直接失败而不执行(failToolCallsFromTruncatedMessage,381-406 行)。这是一个很容易被忽略的安全设计。
出口与队列轮询
// packages/agent/src/agent-loop.ts:247-274(节选,原文的多行调用压成了一行)
if (await config.shouldStopAfterTurn?.({ message, toolResults, context: currentContext, newMessages })) {
await emit({ type: "agent_end", messages: newMessages });
return;
}
pendingMessages = (await config.getSteeringMessages?.()) || [];
} // 内层 while 结束
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
if (followUpMessages.length > 0) {
pendingMessages = followUpMessages;
continue; // 回到内层
}
break;
} // 外层 while 结束
await emit({ type: "agent_end", messages: newMessages });getFollowUpMessages在 shouldStopAfterTurn 之前还有一步 prepareNextTurn(226-245 行),它允许调用方换掉下一轮的上下文、模型和思考等级——Harness 就是靠这个在每个 turn 边界重新读一遍配置。官方 README 描述了 shouldStopAfterTurn 在 turn_end 之后运行(README.md:126-144),但没有提 prepareNextTurn 排在它前面;这个先后顺序只能从源码读出来(分析解释)。
于是 runLoop 一共有四个出口:
| # | 出口 | 位置 |
|---|---|---|
| 1 | 模型响应 stopReason 为 error 或 aborted | agent-loop.ts:196-200 |
| 2 | 整批工具结果都要求 terminate | agent-loop.ts:216 + 582-584 |
| 3 | shouldStopAfterTurn 返回 true | agent-loop.ts:247-257 |
| 4 | 没有工具调用、没有 steering、也没有 follow-up | 内层条件 174 不成立 + 外层 271 break |
图 6.3-2 runLoop 的内外双层循环与四个出口
从上到下读。Inject → Stream → Tools → TurnEnd → Next → PollSteer 回到 Inject 是内层循环;PollFollow → Inject 是外层循环。三条指向 End 的箭头加上 PollFollow 的空队列出口,正好是上表的四个终止条件。P0 对应 agent-loop.ts:167 那次容易被忽略的开局轮询。
AgentEvent:循环对外说话的 10 个词
runLoop 不返回中间状态,它只发事件。AgentEvent 是一个可辨识联合,恰好 10 种:
AgentEvent| 事件 | 含义 | 发射点(agent-loop.ts) |
|---|---|---|
agent_start | 一次 run 开始 | 109、138 |
turn_start | 一个 turn 开始 | 110、139、176 |
message_start | 一条消息(user / assistant / toolResult)开始 | 112、184、323、355、368、790 |
message_update | 仅 assistant 流式期间,携带 pi-ai 的 AssistantMessageEvent | 338 |
message_end | 一条消息定稿 | 113、185、357、370、791 |
tool_execution_start | 一次工具执行开始 | 388、446、501 |
tool_execution_update | 工具汇报中间进度 | 684 |
tool_execution_end | 一次工具执行结束 | 765 |
turn_end | 本 turn 的 assistant 消息与全部工具结果已齐 | 197、224 |
agent_end | 本次 run 不会再有事件 | 198、255、274 |
message_update 是一个「信封」:里面装的是 6.1 讲过的 12 种 AssistantMessageEvent(packages/ai/src/types.ts:501-513),其中 9 种「块内事件」(text_delta、toolcall_delta 等)被统一折叠进这一个 message_update,原事件放在 assistantMessageEvent 字段里随行(agent-loop.ts:326-344)。5.4 流式事件如何传播到界面 已经把这层映射讲透,这里不再展开。
steering:用户中途插话如何进入循环
这是 Pi 交互体验里最容易被当成「魔法」的一块:模型正在跑工具,你在输入框里又敲了一句话回车,它没有打断当前动作,但下一轮就带上了你的新要求。
机制其实很朴素——循环在三个固定位置去问一句「队列里有东西吗」:
agent-loop.ts:167,开局前一次(注释原文说明这是为了接住「用户在等待时打的字」);agent-loop.ts:259,每个 turn 结束后一次;agent-loop.ts:263,外层的 follow-up 轮询。
关键是位置:steering 的轮询在 turn_end 之后,也就是当前这条 assistant 消息的工具已经全部跑完才去取。所以 steering 从不打断执行中的工具,只在 turn 边界插队。
图 6.3-3 一次 steering 插话的时序
请注意用户的入队动作发生在最上方,而队列被读走发生在两次工具执行之后。这正是 agent-loop.ts:259 那一行的位置带来的语义:插话不抢占工具,只在 turn 边界生效。这条时序被 test/agent-loop.test.ts:681 的用例逐条断言过。
排水的粒度由 QueueMode 控制(types.ts:50):"one-at-a-time"(默认)每个排水点只取最旧的一条,"all" 一次全取。默认值定在 agent.ts:224-225 与 harness/agent-harness.ts:221-222。一种看法是默认值选得保守:一次只喂一条能让模型逐条消化用户的连续插话,代价是排队多时要多跑几个 turn 才能全部消费。
follow-up 的差别只有一个:它在内层循环已经退出、Agent 本来要停下时才被读(263 行)。「现在就改方向」用 steering,「等你忙完再说」用 follow-up。
第二层:有状态的 Agent 类
Agent 类的自述是「低层 agent loop 的有状态包装」(agent.ts:165-170 注释)。它把三样东西补给了循环:transcript、事件订阅、消息队列。
prompt它的公开 API 可以按用途分成四组:
- 发起:
prompt(文本 | 消息 | 消息数组)、continue()。continue()要求末条消息不是 assistant;如果是 assistant,它会先尝试排干 steering 队列(agent.ts:361-364,带skipInitialSteeringPoll: true),再尝试 follow-up 队列(367-370),都空才抛错。 - 插话:
steer(message)/followUp(message)(276-283 行)以及clearSteeringQueue/clearFollowUpQueue/hasQueuedMessages。 - 观察:
subscribe(listener)(243-246 行)返回取消函数;监听器按注册顺序被await(573-575 行)。 - 控制:
abort()(312-314 行)、waitForIdle()(321-323 行)、reset()。
队列和循环的接线只有八行:
// packages/agent/src/agent.ts:460-467(createLoopConfig 内)
getSteeringMessages: async () => {
if (skipInitialSteeringPoll) {
skipInitialSteeringPoll = false;
return [];
}
return this.steeringQueue.drain();
},
getFollowUpMessages: async () => this.followUpQueue.drain(),getSteeringMessages事件处理在 processEvents(529-576 行)里分两步:先用 switch 把事件归约进 _state(例如 message_end 时把消息推进 transcript、tool_execution_start 时把 id 加入 pendingToolCalls),再逐个 await 订阅者。顺序很重要:订阅者被调用时看到的一定是已更新的状态。也因为监听器是被 await 的,agent_end 发出后 Agent 还不算 idle——要等所有监听器结算完(agent.ts:233-242 的注释与 README.md:174 一致)。
不一致之一:README 说 streamFn 必填,构造器却有兜底
AgentOptions.streamFn 在类型上是必填的(agent.ts:101,没有 ?),README 的 Quick Start 也写着 // Required stream function(官方说明,packages/agent/README.md:201)。但构造器实际写的是:
// packages/agent/src/agent.ts:211-216(节选)
// 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();getDefaultStreamFn这个默认值来自 stream-fn.ts 的一对函数:setDefaultStreamFn() 安装、getDefaultStreamFn() 取出(没装就抛错,stream-fn.ts:15-19)。coding-agent 在 packages/coding-agent/src/core/sdk.ts:36 调用 setDefaultStreamFn(streamSimple) 装上真实实现——这正是「agent core 对 provider 一无所知」的解耦手法:依赖注入为主,全局默认为辅。
所以准确的说法是:类型契约上必填,运行时有兜底。README 描述的是推荐用法,不是运行时约束(事实分级:前者是官方说明,后者是源码事实)。test/agent-loop.test.ts:84-85 有一条专门的用例 "uses the configured default when a legacy caller omits streamFn" 守着这个行为。
第三层:编排层 AgentHarness
AgentHarness 的自述是「低层 agent loop 之上的编排层,负责会话持久化、运行时配置、资源解析、操作加锁与面向扩展的变更语义」(官方说明,packages/agent/docs/agent-harness.md:3)。它调用循环的姿势是这样的:
// packages/agent/src/harness/agent-harness.ts:613-622(节选)
const runResultPromise = (async () => {
try {
return await runAgentLoop(
messages,
this.createContext(turnState, beforeResult?.systemPrompt),
this.createLoopConfig(getTurnState, setTurnState),
(event) => this.handleAgentEvent(event, abortController.signal),
abortController.signal,
this.createStreamFn(getTurnState),
);
// …(省略:catch 分支把异常转成一条失败 assistant 消息)runAgentLoop相位锁
Harness 用一个字段串起所有互斥:phase(agent-harness.ts:179)。prompt() 的头两行是全部结构性操作的模板——先断言 idle,再在第一个 await 之前同步改相位:
// packages/agent/src/harness/agent-harness.ts:658-664(节选)
async prompt(text: string, options?: { images?: ImageContent[] }): Promise<AssistantMessage> {
if (this.phase !== "idle") throw new AgentHarnessError("busy", "AgentHarness is busy");
this.phase = "turn";
const finishRunPromise = this.startRunPromise();
try {
const turnState = await this.createTurnState();
return await this.executeTurn(turnState, text, options);
// …(省略:catch 复位相位、finally 结算 runPromise)skill()、promptFromTemplate()、compact()、navigateTree() 用同一套写法(674-675、691-692、737-738、795-796 行)。相位在 agent_end 事件处理里被复位(559 行)。
事件落盘的顺序
// packages/agent/src/harness/agent-harness.ts:538-564(节选)
if (event.type === "message_end") {
await this.session.appendMessage(event.message); // 先持久化
await this.emitAny(event, signal); // 再转发给订阅者
return;
}
if (event.type === "turn_end") {
// …(省略:先 emitAny,异常先存起来)
const hadPendingMutations = this.pendingSessionWrites.length > 0;
await this.flushPendingSessionWrites();
await this.emitOwn({ type: "save_point", hadPendingMutations });
return;
}
if (event.type === "agent_end") {
await this.flushPendingSessionWrites();
this.phase = "idle";
await this.emitAny(event, signal);
await this.emitOwn({ type: "settled", nextTurnCount: this.nextTurnQueue.length }, signal);
return;
}
await this.emitAny(event, signal);handleAgentEventsave_point 与 settled 都不是 AgentEvent,而是 Harness 自有的事件——订阅者拿到的是两类事件的并集 AgentHarnessEvent(harness/types.ts:742-744)。会话文件格式与 pendingSessionWrites 的排队规则属于 6.5 Session 存储格式与会话树 的范围。
三个队列的语义差异
nextTurn| 队列 | 入队条件 | 何时被消费 | abort() 时 |
|---|---|---|---|
steerQueue | 必须非 idle(708 行) | 每个 turn 结束后(agent-loop.ts:259) | 被清空(1028 行) |
followUpQueue | 必须非 idle(714 行) | Agent 本要停下时(agent-loop.ts:263) | 被清空(1029 行) |
nextTurnQueue | 任何时候(719-722 行) | 下次用户 prompt() 时,插在用户消息之前(588-597 行) | 不清空 |
前两个队列怎么接进循环,和 Agent 类是同一个套路——createLoopConfig 里两行(agent-harness.ts:495-496)把它们分别绑到 getSteeringMessages 与 getFollowUpMessages。第三个队列则是 Harness 独有的,Agent 类没有对应物:它不经过循环,而是在下一次 executeTurn 组装首批消息时被 splice 出来排在用户消息前面(588-597 行)。
abort()(1025-1052 行)清前两个而保留 nextTurnQueue——语义是「取消这次运行,但你排给下一次的话仍然算数」。test/harness/agent-harness.test.ts:188 的用例 "abort clears steer and follow-up queues but preserves next-turn messages" 正是守这条规则的。
不一致之二:phase 枚举里的 retry 从未被赋值
相位类型写着五个取值:
// packages/agent/src/harness/types.ts:553
export type AgentHarnessPhase = "idle" | "turn" | "compaction" | "branch_summary" | "retry";但在 agent-harness.ts 里,this.phase = ... 的赋值只有四种取值:"turn"(660、675、692)、"compaction"(738)、"branch_summary"(796)、"idle"(559 及各处复位)。"retry" 一次都没出现(源码事实,可用 grep -n 'this.phase = ' packages/agent/src/harness/agent-harness.ts 自行复核)。官方文档在「Operation phases」一节(packages/agent/docs/agent-harness.md:102-108)直接贴出了含 retry 的枚举(同文件 107 行),却没有说明它未被使用;同一份文档在 236 行写着「Auto-compaction and retry decision points are not implemented in AgentHarness yet」。据此推断(尚未在源码中直接证实)"retry" 是为将来的自动压缩/重试决策点预留的类型位;何时启用尚未确认。
switch (phase) 时如果为 "retry" 分支准备了逻辑,在当前版本上它是死代码;反过来,如果因为「类型里有」就认为一定要处理,也会白写。判断一个取值是否真的会出现,唯一可靠的办法是搜赋值点,而不是读类型定义。 谁在用哪一层
在本书锁定的 commit 上,new AgentHarness(...) 只出现在 packages/agent 自己的测试与 scratch 文件里(test/harness/agent-harness.test.ts、test/harness/agent-harness-stream.test.ts、test/harness/tool-context.types.ts、test/scratch/simple.ts),仓库里其他 package 一处都没有;而 packages/coding-agent 用的是 Agent 类(packages/coding-agent/src/core/sdk.ts:294)。这是源码事实,你可以用 grep -rln "new AgentHarness(" packages --include="*.ts" 复核(务必带上左括号:漏了它会把源码里几十处 new AgentHarnessError(...) 也一并搜出来,看上去像是 Harness 被大量使用)。据此推断(尚未在源码中直接证实):Harness 是正在成型的下一代编排层,coding-agent 的迁移尚未发生——文档自述「Phase/settlement semantics are still provisional」也支持这个方向,但迁移计划本身尚未确认。
实践任务
目标:不写一行新代码、不需要任何 API Key,用 Pi 仓库自带的单元测试确认两件事:① steering 消息一定在整批工具执行完之后才被注入;② 只有整批工具结果都要求 terminate 时循环才提前停。
前提:已按 4.1 的实践任务准备好 Pi 源码,并在仓库根目录执行过 npm install --ignore-scripts。这两个测试文件用的都是仓库内置的 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"预期现象:三条用例被执行(✓),其余用例被跳过(↓)。作者在本机真实运行的结尾如下(真实输出,时间与耗时会不同):
✓ 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 > should stop after a tool batch when every tool result 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 3 passed | 18 skipped (21)步骤 2:打开 packages/agent/test/agent-loop.test.ts,读第一条用例(681 行起)末尾的三组断言,它们正好对应本章的三句话:
761行expect(executed).toEqual(["first", "second"])——两个工具都跑完了;779-781行——"interrupt"这条插话在事件序列里排在两条toolResult之后;784行expect(sawInterruptInContext).toBe(true)——第二次模型请求的上下文里能看到它。
再回头看这条用例的 getSteeringMessages(715-722 行):它在第一个工具执行后就已经准备好返回消息了,但事件序列显示消息仍然排在第二个工具之后——把这个现象和 agent-loop.ts:259 那一行的位置对上,你就理解 steering 为什么不抢占工具。
步骤 3(选做):跑一条 Harness 的规则。harness 测试用的是另一份配置,命令不同:
npm run test:harness --workspace=@earendil-works/pi-agent-core -- \
test/harness/agent-harness.test.ts --reporter=verbose \
--testNamePattern="abort clears"作者本机真实运行的结果是 Tests 1 passed | 22 skipped (23)。这条用例验证的就是上表最后一列:abort 清空 steer 与 follow-up,保留 nextTurn。
如何判断成功:① 三条用例全绿;② 你能说出为什么第二条用例里 llmCalls 断言为 1(提示:terminate 让 hasMoreToolCalls 变成 false,内层循环条件不再成立,模型不会被问第二次);③ 你能指出第三条用例(部分 terminate)如果改成源码「只要有一个 terminate 就停」会先挂在哪条断言上。
常见错误(以下三条都在本机复现过):
- 忘了
--:写成npm test --workspace=... test/agent-loop.test.ts --reporter=verbose,npm 会把--reporter当成自己的配置项,只打印一行npm warn Unknown cli config "--reporter"然后把它丢掉——测试照跑,但输出退回默认的点阵(·····),你根本看不到用例名。 - 模式串不加引号:
--testNamePattern=queued|terminate里的|会被 shell 当成管道,报command not found: terminate。换成空格分词也不行——--testNamePattern=queued messages会让messages变成额外的文件过滤参数,名字模式缩水成queued,结果变成1 passed | 20 skipped,和预期的 3 条对不上。 - 把
test:harness当成普通的test:两者用的是不同配置文件。test:harness走packages/agent/vitest.harness.config.ts,它额外把@earendil-works/pi-agent-core这个包名别名到src/index.ts,并把include收窄到test/harness/**/*.test.ts;npm test走vitest.config.ts,默认 reporter 是dot。步骤 3 若误用npm test,用例仍会通过(当前 harness 测试全部用相对路径 import 源码),但你拿到的是点阵输出而不是用例名——照抄命令时别把run test:harness写丢。
对应源码位置:packages/agent/src/agent-loop.ts:259(steering 轮询点)、216 与 582-584(terminate 判定)、174(内层循环条件);packages/agent/src/harness/agent-harness.ts:1025-1052(abort)。测试文件:packages/agent/test/agent-loop.test.ts:681、1201、1253,packages/agent/test/harness/agent-harness.test.ts:188。
本章小结
- pi-agent-core 是三层结构:无状态的
runLoop(agent-loop.ts:155)、有状态的Agent类(agent.ts:171)、编排层AgentHarness(harness/agent-harness.ts:171)。后两者是并列的驾驶舱——Harness 直接调runAgentLoop,不经过Agent。 runLoop的内层 while 处理「工具调用 + 插话」,外层 while 只为 follow-up 续跑。一个 turn = 一次 assistant 响应 + 它引发的全部工具执行。- 四个终止出口:
stopReason为 error/aborted、整批工具terminate、shouldStopAfterTurn返回 true、无工具且两个队列都空。 - 循环对外只发 10 种
AgentEvent;文本增量不是顶层事件,而是包在message_update.assistantMessageEvent里。 - steering 在每个 turn 结束后被轮询,因此从不打断执行中的工具;follow-up 只在 Agent 本要停下时被读;
nextTurn是 Harness 独有的第三个队列,abort()不清它。 - 两处「文档与源码对不上」:README 标
streamFn为 Required 但构造器有getDefaultStreamFn()兜底;AgentHarnessPhase含"retry"但源码从未赋值。 - 关键术语:Agent Loop(Agent 循环)、turn、steering(中途插话)、follow-up(收尾追问)、
terminate(提前终止提示)、相位(phase)、保存点(save point)、依赖注入的流函数(StreamFn)。 - 关键源码索引:
packages/agent/src/agent-loop.ts:31-93(四个入口)、95-143(runAgentLoop / runAgentLoopContinue)、155-275(runLoop)、281-372(streamAssistantResponse)、381-406(截断保护)、582-584(terminate 判定)packages/agent/src/agent.ts:171-231(类与构造器)、243-334(订阅与队列 API)、337-377(prompt / continue)、434-469(createLoopConfig)、529-576(processEvents)packages/agent/src/types.ts:28-32(StreamFn 契约)、144-287(AgentLoopConfig)、422-437(AgentEvent)packages/agent/src/stream-fn.ts:11-20、packages/coding-agent/src/core/sdk.ts:36(默认流函数的安装点)packages/agent/src/harness/agent-harness.ts:538-565(事件落盘)、581-671(executeTurn / prompt)、707-722(三队列)、1025-1056(abort / waitForIdle);packages/agent/src/harness/types.ts:553(phase)- 测试:
packages/agent/test/agent-loop.test.ts、packages/agent/test/agent.test.ts、packages/agent/test/harness/agent-harness.test.ts
- 自测问题:① 模型这一轮返回了 3 个工具调用,其中 2 个的结果带
terminate: true,循环会停吗?为什么?② 用户在工具执行到一半时按回车提交了新内容,这条消息最早可能在哪个事件之后进入上下文?③agent_end事件发出后,Agent立刻就 idle 了吗?④ 为什么说AgentHarness和Agent是并列关系而不是继承或包装关系? - 下一章:6.4 工具系统:定义、校验与执行——
AgentTool的形状、参数校验、串行与并行执行策略、beforeToolCall/afterToolCall两个钩子的完整语义。本章尚未展开的内容:工具批次内部的准备/执行/收尾三段式(agent-loop.ts:600-754)留给 6.4;AgentMessage靠声明合并扩展自定义消息类型、再由convertToLlm投影回 LLM 认识的三种角色(类型在packages/agent/src/types.ts:296-319,Harness 侧的四种自定义消息与投影函数在packages/agent/src/harness/messages.ts:54-61、120-164),原理已在 2.2 interface、type 与函数类型 讲过,实际用法要到第七部分 7.1 Extension 系统 才会用上;会话落盘的格式留给 6.5 Session 存储格式与会话树,Harness 的压缩与会话树操作留给 6.6 Context 构造与 Compaction;proxy.ts那个浏览器端StreamFn实现属于支线,本书不展开。