π Pi Agent 源码学习课程 阶段三 · 6/14
阶段三 · Agent 内核 · PI-AGENT-CORE(上)

第 6 讲Agent Loop 与事件流

Agent 系统的心脏只有一个:请求 → 工具 → 回填 → 再请求。本讲精读这条循环的内核实现——状态、事件、队列与钩子。

4 小时预计学习时长
4 个文件agent.ts · agent-loop.ts · types.ts · stream-fn.ts
2 张序列图无工具 / 有工具完整事件时间线
runLoop() 内外双层循环 用户消息 message_start / end LLM 流式响应 message_update × N 工具执行 tool_execution_* 三事件 toolResult 回填 消息事件 + turn_end steering 轮次间隙注入 followUp 停止前注入 abort() 随时打断

§6.0学习目标

说清 Agent 与低层 agentLoop() 的分层关系:一个是有状态外壳,一个是单次运行的纯过程。

默写消息流水线 AgentMessage[] → transformContext → convertToLlm → Message[] → LLM,并解释两个钩子「可选 / 必需、不得 throw、不落盘」的契约差异。

对照源码完整画出无工具与有工具两条事件序列,解释 parallel 模式下完成事件序与持久化序为何分离。

掌握 StreamFn「错误编码进事件流」的契约,以及 QueueMode、steering / followUp 的注入时机与 message_end barrier 语义。

正确使用 finishTurn / prepareRequest / prepareNextTurn 三个钩子与 agentLoopContinue 的重试约束。

§6.1Agent 类:套在无状态循环外的有状态外壳

第 4、5 讲的 pi-ai 是一个无状态的请求-响应库;agent-loop.ts 里的 agentLoop() 也只是「一次运行」的纯过程——传入上下文与配置,吐出事件流。而 agent.ts 的 Agent 类在两者之外再包一层:持有会话记录(transcript)、订阅者集合、两条待注入队列和一个 AbortController,把每次运行归约为可观测的状态机。

先给一句类比:低层 agentLoop() 相当于一个无状态的静态工具方法;Agent 则是管理它的有状态 Service——字段是会话状态,公开方法是生命周期入口。Java 工程师可以这样建立直觉:Agent.state.messages 就是会话 Bean 的内存字段,subscribe() 是事件监听器注册,prompt() 是一次同步入口方法(返回 Promise)。

构造与 AgentOptions

构造函数只接收一个 AgentOptions 对象。核心字段:initialState(systemPrompt、model、thinkingLevel、tools、messages)、必需的 streamFn、可选的 convertToLlm 与 transformContext、两个队列模式 steeringMode / followUpMode(默认 "one-at-a-time")、工具执行模式 toolExecution(默认 "parallel")、以及一批请求级钩子与透传项(sessionId、identity、getApiKey、thinkingBudgets、transport、onPayload / onResponse / onProviderStreamEvent 等)。

一个容易忽略的细节:systemPrompt 与 tools 并不是独立字段,而是被编译成 transcript 的首条 system message(除非 messages 已经以 system 开头)。这就是 createMutableAgentState() 里 createInitialSystemMessage(...) 的职责——提示词与工具声明由「记录本身」携带,而不是请求参数。

packages/agent/src/agent.ts节选 · 注释为本讲所加
function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
  return messages.filter(
    (message) =>
      message.role === "system" || message.role === "user" ||
      message.role === "assistant" || message.role === "toolResult",
  );
}

function createMutableAgentState(initialState?: AgentInitialState): MutableAgentState {
  let tools = initialState?.tools?.slice() ?? [];
  let messages = initialState?.messages?.slice() ?? [];
  // systemPrompt + tools 编译为首条 system message,写入 transcript
  const initialMessage = createInitialSystemMessage(
    initialState?.systemPrompt, tools.map(toToolDeclaration));
  if (messages[0]?.role !== "system" && initialMessage) messages.unshift(initialMessage);

  return {
    get systemPrompt() { return getCurrentSystemPrompt(messages); },  // 只读:重放 transcript
    get tools() { return tools; },
    set tools(next) { tools = next.slice(); },      // 赋值时拷贝顶层数组
    get messages() { return messages; },
    set messages(next) { messages = next.slice(); },
    isStreaming: false, streamingMessage: undefined,
    pendingToolCalls: new Set<string>(), errorMessage: undefined,
    /* model / thinkingLevel 等省略 */
  };
}

AgentState:状态字段逐个看

字段语义与注意事项
model / thinkingLevel直接可写:agent.state.model = getModel(...)、agent.state.thinkingLevel = "medium"。thinkingLevel 取 "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"。
tools可执行工具集。赋值会拷贝顶层数组;getter 返回内部引用,直接 push 也会修改状态。与 transcript 声明的差异会在下一次请求前由 declareToolChanges() 以 system message(toolsAdded / toolsRemoved)播报给模型。
messages会话记录本体,同样「赋值拷贝、引用可变」。系统消息承载提示词与工具声明。
systemPrompt(只读)由 transcript 的 system messages 重放得到。要改提示词,追加一条带 content(增量)或 sections(具名替换)的 system message。
isStreaming(只读)直到 agent_end 的异步监听器全部结算后才变 false——比事件本身更晚。
streamingMessage(只读)当前流式中的 assistant 部分消息;message_end 后清空并入列。
pendingToolCalls(只读)正在执行的 toolCallId 集合,随 tool_execution_start / tool_execution_end 增删。
errorMessage(只读)最近一次失败 / 中止的 assistant 消息错误文本。

公开 API 速查

大纲里提到的 setModel() / setTools() / setSystemPrompt() 在当前源码中已演进为「状态赋值 + 追加 system message」的形式,学习时以仓库为准:

分组API
订阅subscribe(listener) 返回退订函数;监听器按注册顺序被 await,并收到当前运行的 abort signal。
输入prompt("文本" | 消息 | 消息数组, images?);continue() 从现有 transcript 续跑(详见 §6.5)。
队列steer(msg) / followUp(msg) / peekQueuedMessages() / clearSteeringQueue() / clearFollowUpQueue() / clearAllQueues() / hasQueuedMessages(),以及 steeringMode / followUpMode 两个可写属性。
控制abort()、waitForIdle()、signal(当前运行的 signal)、reset()(保留重放后的提示词 / 工具基线,清空队列;运行中调用会抛错)。
运行时配置convertToLlm、transformContext、streamFunction、toolExecution、beforeToolCall / afterToolCall、finishTurn、prepareRequest、prepareNextTurn(WithContext)、sessionId、thinkingBudgets、transport、identity、getApiKey 等均为可赋值属性。

注意并发约束:同一时刻只允许一个活跃运行(activeRun),运行中再次 prompt() / continue() / reset() 会抛 "Agent is already processing...";想插话请用 steer() / followUp(),想停就 abort()。

JAVA 视角 · ①

Agent 与 agentLoop() 的关系,像 Spring 中有状态的 Service Bean 包着一个无状态静态工具方法:工具方法每次拿全量入参、返回完整结果;Service 持有会话字段、依赖注入(streamFn)、事件监听器(subscribe)与生命周期管理(activeRun / AbortController)。差异在于:Java Bean 的字段通常是可任意写的 POJO,而这里的 systemPrompt 是由 transcript 重放出来的只读视图——状态即事件日志,类似 Event Sourcing 的投影。

§6.2消息流水线:AgentMessage 与 LLM Message 的两级清洗

Agent 内部维护的是 AgentMessage[]——它不只是标准 LLM 消息(system / user / assistant / toolResult),还允许应用通过 declaration merging 扩展自定义角色(例如 UI 通知、状态标记)。而 LLM 只认识标准角色。两者之间隔着一条固定的流水线:

agent.state.messages —— 持久会话记录:两个钩子都只是「出站视图」的加工,不修改 transcript 本身 AgentMessage[] transcript(含自定义角色) AgentMessage[] 出站视图(裁剪/注入后) Message[] 标准四角色 LLM Provider streamFn() 发起请求 transformContext 可选 · 裁剪 / 注入 · 不落盘 convertToLlm 必需 · 过滤 / 转换自定义类型 normalizeContext
每个 provider 请求前都会完整走一遍这条流水线。测试 agent-loop.test.ts › should apply transformContext before convertToLlm 用「只保留最后两条消息」的裁剪断言了执行顺序与数据流向。

transformContext:可选的 AgentMessage 层变换

签名 (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>。定位是上下文窗口管理:裁剪旧消息(compaction 的落点)、注入外部上下文。契约写在 types.ts 的注释里:不得 throw 或 reject,失败时返回原数组或其他安全回退值——throw 会直接打断低层循环,破坏正常事件序列。

convertToLlm:必需的角色翻译器

签名 (messages: AgentMessage[]) => Message[] | Promise<Message[]>。每个 AgentMessage 必须转换为 LLM 认识的四种消息之一,或被过滤掉(UI 通知、状态消息);自定义类型要么映射成标准消息,要么丢弃。不提供时使用 defaultConvertToLlm——直接过滤出四种标准角色。契约同样是不得 throw。

为什么一个可选一个必需?因为「会话记录里允许出现什么」是框架的开放能力(所以裁剪可选、默认透传),而「发给 LLM 的必须是标准消息」是 provider 协议的硬约束(必须有兜底实现,且一旦有自定义类型就必须由你接管翻译)。这也是大纲自测第 ① 题的答案内核。

packages/agent/src/agent-loop.ts节选 · streamAssistantResponse 开头(消息边界)
async function streamAssistantResponse(context, config, signal, emit, streamFunction) {
  // ① AgentMessage 层:可选的上下文变换(裁剪、注入)——不写回 context.messages
  let messages = context.messages;
  if (config.transformContext) {
    messages = await config.transformContext(messages, signal);
  }

  // ② LLM 层:必需的格式转换(过滤 UI 消息、转换自定义类型)
  const llmMessages = await config.convertToLlm(messages);
  const llmContext = normalizeContext({ messages: llmMessages });

  // ③ 每次请求前动态解析 API key(应对会过期的 OAuth token)
  const resolvedApiKey =
    (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined)
    || config.apiKey;

  const response = await streamFunction(config.model, llmContext, {
    ...config, apiKey: resolvedApiKey,
    identity: mergeClientIdentity(DEFAULT_AGENT_IDENTITY, config.identity),
    signal,
  });
  /* 之后 for await 消费 provider 事件流:start / *_delta / done / error …… */
}
JAVA 视角 · ②

这两个钩子相当于 Servlet 的出站 Filter 链(或 Spring 的 HandlerInterceptor postHandle):请求离开系统前依次被装饰、裁剪。两个关键差异要记牢——其一,Java Filter 允许抛 IOException / ServletException 由容器兜底,而 Pi 的契约是钩子自己不许抛,失败要以安全回退值的形式「当作数据」返回;其二,Filter 修改的往往就是响应本体,这里修改的只是发往 LLM 的请求副本,持久 transcript(agent.state.messages)分毫不动。

§6.3事件序列精读:两条完整时间线

Agent 的全部对外表现就是十个事件类型。先记总表,再看两条时间线。

事件触发时机与载荷
agent_start一次运行(一次 prompt() / continue())开始。
agent_end运行最终事件,载荷 messages 为本次运行新增的消息。它是最后一个被发出的事件,但运行要等它的异步监听器结算才算结束。
turn_start一个 turn 开始——一个 turn = 一次 LLM 请求 + 其触发的工具执行。
turn_end载荷 { message, toolResults }:本 turn 的 assistant 消息与全部 toolResult 消息。
message_start / message_end任何消息(system / user / assistant / toolResult)入列前后各一次;message_end 即消息定稿。
message_update仅 assistant 流式期间,载荷含 assistantMessageEvent(text/thinking/toolcall 的 start/delta/end)与当前部分消息。
tool_execution_start载荷 { toolCallId, toolName, args }。
tool_execution_update工具通过 onUpdate 回调流式上报部分结果。
tool_execution_end载荷 { toolCallId, toolName, result, isError, durationMs? }。

时间线 A:无工具的一次 prompt("Hello")

  1. agent_startrunWithLifecycle 就绪:isStreaming = true,创建 AbortController
  2. turn_startTurn 1 开始(一次 LLM 请求)
  3. message_start { userMessage }你的 prompt 先作为消息入列
  4. message_end { userMessage }用户消息定稿(无流式过程)
  5. message_start { assistantMessage }收到 provider 的 start 协议事件,部分消息入列
  6. message_update × Ntext_delta / thinking_delta / toolcall_delta …… 逐块流式,仅 assistant 有此事件
  7. message_end { assistantMessage }done 协议事件:最终消息(含 stopReason、usage)定稿并入列
  8. turn_end { message, toolResults: [] }finishTurn 钩子在此事件之前运行(§6.6)
  9. agent_end { messages }无 followUp / continue 决策,运行结束;等待其异步监听器结算后才 isStreaming = false
对应测试:agent-loop.test.ts › should emit events with AgentMessage types 与 agent.test.ts › should subscribe to events。

时间线 B:带工具调用的 prompt("Read config.json")(默认 parallel 模式,两个 turn)

  1. agent_start
  2. turn_startTurn 1
  3. message_start / message_end { userMessage }
  4. message_start { assistantMessage(含 toolCall)}
  5. message_update × N文本与 toolcall 参数增量都在这里流式
  6. message_end { assistantMessage }stopReason = "toolUse";本事件被 Agent 类作为 barrier 处理(§6.5)
  7. tool_execution_start { toolCallId, toolName, args }每个工具调用一次;参数已通过 schema 校验与 beforeToolCall 预检
  8. tool_execution_update { partialResult }可选:工具经 onUpdate 流式上报进度
  9. tool_execution_end { toolCallId, result, isError, durationMs }parallel 模式下按完成顺序发出(谁先 finalize 谁先发)
  10. message_start / message_end { toolResultMessage }toolResult 消息按 assistant 源码顺序成对发出并持久化(与上一行的完成序解耦)
  11. turn_end { message, toolResults: [toolResult] }
  12. ── TURN 2 ──
  13. turn_start工具结果回填后自动开启下一 turn:prepareNextTurn → (可能再 poll steering)→ 请求 LLM
  14. message_start { assistantMessage }
  15. message_update × N
  16. message_end { assistantMessage }stopReason = "stop",不再有工具调用
  17. turn_end { message, toolResults: [] }
  18. agent_end { messages }中途若查到 steering 消息,会先注入再进入下一 turn;查到 followUp 则在「本该停止」时注入
agent 级 turn 级 message 级 tool 级
sequential 模式(或批次中任一工具声明 executionMode: "sequential")时,每个工具的 start → end → toolResult 消息紧密相邻成组;parallel 模式则如上:完成事件按完成序、toolResult 消息按源码序。另有特例:assistant 消息 stopReason === "length"(输出被 token 上限截断)时,所有工具调用不执行,直接批量生成「参数可能被截断」的错误结果。

订阅语义:Agent.subscribe() 的监听器按注册顺序被逐一 await,且被计入本次运行的结算——agent_end 只表示「不再有循环事件」,而 await agent.prompt(...) 与 waitForIdle() 要等 agent_end 的监听器也跑完才 resolve(测试 agent.test.ts › should await async subscribers before prompt resolves 专门验证了这一点)。这让「监听器里落盘会话」成为安全操作。

§6.4StreamFn 契约:错误是数据,不是异常

StreamFn 是 Agent 与 provider 层之间唯一的接缝:拿到 model、TranscriptContext 与 options,返回一个 AssistantMessageEventStream。它通常直接绑定 models.streamSimple,也可以替换成代理(streamProxy)或测试桩。它的契约是本讲最值得咀嚼的设计:

packages/agent/src/types.ts节选 · StreamFn 契约原文
/**
 * The loop passes a normalized transcript: the system prompt and tool
 * declarations are carried by the transcript's system messages, never by
 * `context.systemPrompt` or `context.tools`.
 *
 * Contract:
 * - Must not throw or return a rejected promise for request/model/runtime failures.
 * - Must return an AssistantMessageEventStream.
 * - 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: TranscriptContext,
  options?: SimpleStreamOptions,
) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;

把三条翻译成工程语言:

stream-fn.ts 只有 20 行,却定义了默认实现的注入机制:setDefaultStreamFn() 允许宿主(例如 pi 的编码代理入口)安装默认的流函数,之后省略 streamFn 的旧式调用方也能工作;未配置时 getDefaultStreamFn() 直接抛出 "No default stream function configured..."——注意这是「构造期配置错误」,与运行期请求失败的契约是两回事。

  • 不许 throw、不许返回 rejected promise——请求失败、模型不存在、运行时错误,一律不许用异常传播;
  • 必须返回事件流——哪怕是失败的流;
  • 失败编码在流里——先走 error 协议事件,最后一条 AssistantMessage 带 stopReason: "error"(或 "aborted")与 errorMessage。

为什么这么苛刻?因为 runLoop 是一个必须走完全程的状态机:事件序列是 UI、会话持久化、扩展系统共同依赖的「总线」。如果 streamFn 半路 throw,低层 agentLoop() 的事件流永远等不到 agent_end,消费端(如会话树写入器)会挂在等待上;循环本身的清理逻辑也被跳过。把错误降格为一条内容为空的失败 assistant 消息,循环就能照常走 message_end → turn_end → agent_end 的完整收尾,所有下游逻辑不分支。

Agent 类对「漏网之鱼」仍有兜底:如果 streamFn(或任何运行期代码)真的 reject 了,runWithLifecycle 会捕获并交给 handleRunFailure(),合成一条 stopReason: "error"(abort 时为 "aborted")的 assistant 消息,补发 message_start → message_end → turn_end → agent_end 后再结束运行——测试 agent.test.ts › emits full lifecycle events for thrown run failures 即验证此路径。

packages/agent/src/agent.ts节选 · handleRunFailure 兜底序列
private async function handleRunFailure(error: unknown, aborted: boolean): Promise<void> {
  const failureMessage = {
    role: "assistant",
    content: [{ type: "text", text: "" }],
    stopReason: aborted ? "aborted" : "error",
    errorMessage: error instanceof Error ? error.message : String(error),
    /* usage / model 等字段省略 */
  };
  await this.processEvents({ type: "message_start", message: failureMessage });
  await this.processEvents({ type: "message_end",   message: failureMessage });
  await this.processEvents({ type: "turn_end", message: failureMessage, toolResults: [] });
  await this.processEvents({ type: "agent_end", messages: [failureMessage] });
}

(源码中该方法为类方法,此处为排版对签名做了等价简化。)

JAVA 视角 · ③

这就是「errors as data」:类似 gRPC 把失败编码成消息里的 Status,或函数式语言的 Result<T, E>,而不是 Java 的 throw / CompletionStage.exceptionally。区别于 CompletableFuture.completeExceptionally()(异常会跳过所有 thenApply 链),Pi 选择 complete(失败消息)——下游的每一段(UI 渲染、JSONL 落盘、扩展)都无差别地收到一条正常消息,只是这条消息自带失败标记。状态机因此永远有完整出口。

§6.5队列注入、barrier 语义与中断

这是本讲最容易踩坑、也最能体现工程功力的部分:什么时候允许用户插话?插入的消息何时真正生效?异步监听器与循环推进之间如何不竞态?

QueueMode:一次 drain 放多少条

QueueMode = "all" | "one-at-a-time"。Agent 内部的 PendingMessageQueue 在每个 drain 点根据模式决定注入量:"all" 一次取光全部排队消息;"one-at-a-time"(默认)只取最旧的一条,其余留给下一个 drain 点。steering 与 followUp 两条队列各有独立的模式属性(agent.steeringMode / agent.followUpMode),peekQueuedMessages() 也按同一规则预览而不消费。

drain 点在哪:内层循环与外层循环

agent_start / turn_start 注入 pending 消息 (P1 运行开始时先 poll 一次 steering) prepareRequest 每次 provider 请求前 · 不 poll 队列 streamFn 流式响应 message_update × N → message_end 执行工具批 parallel / sequential(§6.3) finishTurn → turn_end (P3 turn_end 后 poll steering) agent_end 有 toolCall → hasMoreToolCalls = true 内层循环继续(turn_start) 条件:还有工具调用,或 P3 查到了 steering; 新一轮开头会跑 prepareNextTurn(P2 补 poll) 内层循环退出:无工具、无 steering P4 · poll followUp(外层循环) 仅当没有更多工具调用、也没有 steering 时; 取到则作为 pending 注入并回到内层循环 followUp 注入后重启内层循环 · steering 在「当前 assistant turn 的全部工具调用结束之后」注入, 不会打断已开始的工具批(types.ts: getSteeringMessages 注释) · finishTurn 返回 {action:"end"} 时,P3 / P4 都不会执行,队列保留
对应测试:should inject queued messages after all tool calls complete(工具执行中入队的 steering 等批结束后注入)、does not poll steering after prepareRequest、picks up steering queued during prepareNextTurn、keeps queues when finishTurn ends the run。

steering 与 followUp 的分工:steer() 用于「代理正在干活时插话」,注入点在当前 assistant turn 的所有工具调用完成之后、下一请求之前——工具批不会被跳过;followUp() 用于「排一件事等它忙完再说」,只有当没有更多工具调用、也没有 steering 时才被 poll。这正是测试 should support steering message queue / should support follow-up message queue 的断言场景。

message_end 的 barrier 语义

Agent 类把每个事件的处理都做成了 barrier:低层循环通过 emit 回调发事件,而 Agent 传入的回调是 processEvents()——先更新内部状态,再按注册顺序 await 全部监听器,返回后循环才继续。于是「assistant message_end 的监听器全部结算」就成了工具预检(beforeToolCall)之前的栅栏:钩子看到的 agent 状态一定已包含发起工具调用的那条 assistant 消息。

相反,低层 agentLoop() / agentLoopContinue() 返回的 EventStream 是观测性的:它保留事件顺序,但不等待你消费端的异步处理完成,生产端照常推进。README 原话说得很直白——如果需要消息处理在工具预检前形成 barrier,请用 Agent 类而不是裸 agentLoop()。这一语义解决的具体竞态是:持久层 / 扩展在 message_end 里异步写入会话树时,工具批已经开始改状态,两者交错会造成树结构错乱或钩子读到旧状态。

packages/agent/src/agent.ts节选 · processEvents(barrier 的实现点)
private async function processEvents(event: AgentEvent): Promise<void> {
  switch (event.type) {
    case "message_end":
      this._state.streamingMessage = undefined;
      this._state.messages.push(event.message);   // 定稿并入列
      break;
    /* message_start/update、tool_execution_start/end、turn_end、agent_end 等省略 */
  }
  for (const listener of this.listeners) {
    await listener(event, signal);   // 逐个 await:每个事件都是一道 barrier
  }
}

(源码中为类方法;此处为排版对签名做等价简化。)

中断:AbortSignal 贯穿一切

每次运行创建一个 AbortController;agent.abort() 触发后,同一个 signal 会传给 streamFn(在 options 里)、每个工具的 execute(id, params, signal, onUpdate)、以及所有钩子与监听器——工具与钩子「有责任响应它」。中断后的收尾仍走完整事件序列:provider 侧产生 stopReason: "aborted" 的最终消息,工具侧未完成调用会以 "Operation aborted" 错误结果落地(parallel 预检循环里每次 signal.aborted 检查后 break)。

continue() 与 agentLoopContinue 的重试约束

重试/续跑入口是 agentLoopContinue(context, config, signal, streamFn),它的前置约束写在函数开头:

packages/agent/src/agent-loop.ts节选 · agentLoopContinue 守卫
if (context.messages.length === 0) {
  throw new Error("Cannot continue: no messages in context");
}
if (context.messages[context.messages.length - 1].role === "assistant") {
  throw new Error("Cannot continue from message role: assistant");
}
/**
 * **Important:** The last message in context must convert to a `user` or
 * `toolResult` message via `convertToLlm`. If it doesn't, the LLM provider
 * will reject the request.(自定义角色能否续跑由调用方负责——
 * 此处无法校验,因为 convertToLlm 每个 turn 只调用一次。)
 */

也就是三条:① 上下文不能为空;② 最后一条不能是 assistant(多数 provider 拒绝 assistant 结尾的请求);③ 自定义角色结尾时,必须保证 convertToLlm 把它翻成 user / toolResult,责任在调用方——因为校验只发生在实际转换时。agentLoopContinue 与 agentLoop 的另一区别是不为已有消息发事件(测试 should continue from existing context without emitting user message events),返回值也只含本次新增消息。

Agent.continue() 在此之上再加一层策略:transcript 为空或只有 system 消息时直接拒绝且不消费队列;结尾是 user / toolResult 时走 agentLoopContinue(启动时 poll steering,followUp 等自然停止);结尾是 assistant 时无法直接续跑,依次回退到「一批排队 steering(并跳过启动 poll)」「一批排队 followUp」,都没有则抛 "Cannot continue from message role: assistant"。README 给出的惯用法是:重试前先 followUp() 一条消息,再 continue()——第一次请求就会用现有 user / toolResult 尾巴重试。

§6.6finishTurn / prepareRequest / prepareNextTurn

三个钩子分别卡在一次请求生命周期的三个刻度上。README 的生命周期链值得背下来:

packages/agent/README.md每 turn 生命周期
selected input events → prepareRequest → provider response
→ tool results → finishTurn → turn_end
→ existing continuation scheduling or agent_end
钩子时机能改什么 / 返回值语义
prepareRequest 每次 provider 请求之前,含第一次;此时排队消息已入列并已发事件 可整体替换 context、model、thinkingLevel(对本次及本次运行内后续请求生效)。典型用途:从会话存储重装「权威持久上下文」。它不 poll 队列——期间入队的 steering 等下一个正常 drain 点。
finishTurn assistant 消息与全部 toolResult 消息定稿之后、turn_end 之前;正常 / error / aborted 都会跑 返回 undefined:保持正常调度;{ action: "continue" }:保证「再来一次请求」——若工具结果、steering 或 followUp 本来就会触发下一请求,则由其满足,不额外加请求,否则补一次仅上下文请求;{ action: "end" }:turn_end 后立即结束,不 poll 队列(队列保留)。error / aborted 是硬出口,返回的决策被忽略;无条件返回 continue 会造成死循环。
prepareNextTurn turn_end 之后、循环确定要继续时、下一 turn_start 之前 可返回替换的 context、追加的 messages(带完整生命周期事件)、model、thinkingLevel。典型用途:compaction 前后换上下文、切模型。期间入队的 steering 会在其后被补 poll(仅当前一次 poll 为空,避免 one-at-a-time 一轮塞两条)。

finishTurn 是从旧钩子 shouldStopAfterTurn 迁移来的,README 给出了迁移模板——先挡掉 error / aborted,再决定 end:

packages/agent/src/agent-loop.ts节选 · runLoop 的调度核心(本讲最重要的一段)
// 内层循环:还有工具调用或 steering 时继续
while (hasMoreToolCalls || pendingMessages.length > 0) {
  let preparedMessages: AgentMessage[] = [];
  if (lastCompletedTurn) {
    const snapshot = await config.prepareNextTurn?.(lastCompletedTurn); // 可换 context/model/思维档
    if (snapshot) { /* 应用 snapshot 省略 */ }
    // prepareNextTurn 可能耗时很长(例如 compaction):补 poll steering,
    // 但仅当前一次 poll 为空——否则 one-at-a-time 会在本轮送出两条
    if (pendingMessages.length === 0) {
      pendingMessages = (await config.getSteeringMessages?.()) || [];
    }
    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);
  if (requestUpdate) { /* 替换 context/model/thinkingLevel 省略 */ }

  // ① 流式拿 assistant 消息(消息流水线在此执行,§6.2)
  const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction);
  newMessages.push(message);

  // ② error / aborted 是硬出口:finishTurn 照跑,但决策被忽略
  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;
  }

  // ③ 执行工具批(parallel / sequential / length 截断批量失败)
  const toolCalls = message.content.filter((c) => c.type === "toolCall");
  const toolResults: ToolResultMessage[] = [];
  hasMoreToolCalls = false;
  if (toolCalls.length > 0) {
    const batch = message.stopReason === "length"
      ? await failToolCallsFromTruncatedMessage(toolCalls, emit)   // 参数可能被截断:不执行
      : await executeToolCalls(currentContext, message, config, signal, emit);
    toolResults.push(...batch.messages);
    hasMoreToolCalls = !batch.terminate;    // 全批 terminate 才提前停
    for (const r of toolResults) { currentContext.messages.push(r); newMessages.push(r); }
  }

  // ④ turn 收尾钩子 + turn_end + P3 poll steering
  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;                                 // 队列不 poll、不消费
  }
  explicitContinuation = decision?.action === "continue";
  pendingMessages = (await config.getSteeringMessages?.()) || [];
  if (hasMoreToolCalls || pendingMessages.length > 0) explicitContinuation = false; // 自然调度优先
}

// 外层循环:本该停止时才查 followUp;否则若 finishTurn 要过 continue,补一次仅上下文请求
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
if (followUpMessages.length > 0) { pendingMessages = followUpMessages; continue; }
if (explicitContinuation) { explicitContinuation = false; continue; }
break;
/* 循环退出后 emit agent_end(略) */

注意 thinkingLevel 的换算细节:配置里 "off" 会转成 reasoning: undefined(不请求思维链),prepareNextTurn / prepareRequest 返回的 thinkingLevel 也遵循同一规则;流式结束后最终消息会被补记实际生效的档位。

§6.7动手实践

目标:50 行以内,跑通「订阅时间线 + calculate 工具 + 两个流水线钩子」。先写最小骨架,再按步骤做实验。

  1. 搭骨架:订阅事件,打印完整时间线

    创建 Agent,subscribe 里对每个事件打印 event.type(message_update 建议只打点计数)。接真实 provider 或第 4 讲的 mock streamFn 均可。

  2. 注册 calculate 工具并发起带工具调用的 prompt

    用 obj/num/str 构造参数 schema,发起「计算 12*7」。对照 FIG. 6-3 检查你的输出:是否出现两个 turn、tool_execution_start/update/end 是否齐全、toolResult 消息是否成对。

  3. 实验一:transformContext 注入一条假消息

    实现 transformContext: async (msgs) => [{ role:"user", content:[{type:"text",text:"(系统备忘:用户偏好中文)"}], timestamp:Date.now() }, ...msgs]。在 streamFn 里打印收到的 messages,随后打印 agent.state.messages——请求里多了一条,会话记录一条没多。

  4. 实验二:自定义消息类型 + convertToLlm 过滤

    用 declaration merging 声明 notification 角色,往 state.messages 里 push 一条,再实现 convertToLlm 用 flatMap 把它过滤掉。观察:transcript 有它,LLM 请求没有。

  5. 实验三:队列与 barrier

    在工具的 execute 里 await 一个 2 秒延迟;期间调用 agent.steer(...) 插话,观察 steering 消息的事件出现在工具批结束之后、下一 turn 请求之前。再把 steeringMode = "all",连插两条对比注入条数。

练习脚本(可直接保存为 explore-agent.ts 运行)约 45 行
import { Agent, type AgentEvent } from "@earendil-works/pi-agent-core";
import { obj, num, str } from "@earendil-works/pi-schema";

const calcParams = obj({ a: num(), b: num(), op: str({ description: "+ - * /" }) });
const calculate = {
  name: "calculate", label: "Calculate",
  description: "计算两个数的四则运算", parameters: calcParams,
  async execute(_id, p) {
    const r = p.op === "+" ? p.a + p.b : p.op === "*" ? p.a * p.b : await Promise.reject(new Error("unsupported"));
    return { content: [{ type: "text", text: String(r) }], details: { r } };
  },
};

const agent = new Agent({
  initialState: { systemPrompt: "你是计算助手,必须用工具计算。", model, tools: [calculate] },
  streamFn: models.streamSimple.bind(models),
  transformContext: async (msgs) => [/* 实验三:在此 prepend 一条备忘消息 */ ...msgs],
  convertToLlm: (msgs) => msgs.flatMap((m) => m.role === "notification" ? [] : [m]),
});

const t0 = Date.now();
const off = agent.subscribe((e: AgentEvent) =>
  console.log(String(Date.now() - t0).padStart(5), e.type));

setTimeout(() => agent.steer({ role: "user",
  content: [{ type: "text", text: "顺便用中文解释结果" }], timestamp: Date.now() }), 300);

await agent.prompt("计算 12*7");
off();

§6.8常见误区

误区一:以为 transformContext 修改了会话历史

它只加工「发往 LLM 的请求副本」,返回值不写回 context.messages,更不写回 agent.state.messages。裁剪旧消息只是让本次请求变短;持久 transcript 原样保留。想让记录真正瘦身,要改 transcript 本身(这正是第 9 讲 compaction 与 prepareRequest 重装上下文的分工)。

误区二:在低层 agentLoop() 消费端做异步落盘,还指望它挡住工具执行

低层流是观测性的,生产端不会等你。需要「message_end 处理完才开始工具预检」的 barrier,请使用 Agent 类(其 processEvents 逐事件 await 全部监听器),或自己在外层实现等价的 await 链。

误区三:在 streamFn / 钩子里抛异常「报告失败」

StreamFn、transformContext、convertToLlm、getApiKey、队列 getter 的契约都是不得 throw / reject。失败要编码进返回值:streamFn 用协议事件 + stopReason: "error" 的最终消息;其余钩子返回安全回退值。工具 execute 是唯一「该 throw 就 throw」的地方——循环会把它转成 isError: true 的 toolResult。

误区四:以为 steer() 会立刻打断工具执行

steering 的注入点是当前 assistant turn 的全部工具调用结束之后——工具批从不被跳过。要立刻停,用 abort()(signal 贯穿工具与 provider 流);只想排到「自然停止之后」,用 followUp()。

§6.9自测问答

Q1为什么 convertToLlm 是必需的,而 transformContext 是可选的?

「会话记录里允许出现什么」是框架的开放能力:默认情况下标准四角色直接透传即可,所以裁剪 / 注入类的 transformContext 可以省略。而「发给 LLM 的必须是 system/user/assistant/toolResult」是 provider 协议的硬约束:必须有兜底实现(defaultConvertToLlm 过滤标准角色),一旦引入自定义消息类型就必须由你提供翻译。配置层面因此是「可选钩子 + 必填字段」的组合,且两者契约一致:不得 throw,失败返回安全回退。

Q2parallel 模式下,tool_execution_end 按什么顺序发出?toolResult 消息按什么顺序持久化?

tool_execution_end 在每个工具 finalize 后立刻发出,因此按完成顺序;toolResult 消息则要等整批 Promise.all 收齐后,按 assistant 消息里的源码顺序(toolCall 出现顺序)成对发出 message_start/message_end 并入列。事件序与持久化序刻意解耦:UI 可以即时显示最先完成的工具,而发给模型的上下文保持与 assistant 消息的 toolCall 顺序一致。对应测试:should emit tool_execution_end in completion order but persist tool results in source order。若批次中任一工具声明 executionMode: "sequential",整批退化为顺序执行。

Q3「barrier」语义解决的是什么竞态?

解决「事件消费与循环推进」的竞态:低层 agentLoop() 的事件流是观测性的,不等待异步消费者;如果持久层 / 扩展在 assistant message_end 里异步写会话树,工具批可能已经开始执行并修改状态,导致树结构错乱或 beforeToolCall 读到不含该 assistant 消息的旧状态。Agent 的 processEvents 对每个事件先更新内部状态、再按注册顺序 await 全部监听器,之后循环才继续——于是 message_end 成为工具预检前的栅栏,且 prompt() / waitForIdle() 要等 agent_end 监听器结算才 resolve。

Q4为什么 StreamFn 不许 throw?如果它真的 reject 了会怎样?

因为 runLoop 是必须走完全程的状态机,事件序列是 UI / 持久化 / 扩展共同依赖的总线。throw 会让低层事件流永远等不到 agent_end,消费端挂起、清理逻辑被跳过。所以失败必须编码为流内的 error 协议事件 + 最终 AssistantMessage(stopReason: "error" | "aborted"、errorMessage)。若真有漏网异常,Agent 类的 runWithLifecycle → handleRunFailure 会兜底:合成一条空内容失败 assistant 消息,补发 message_start → message_end → turn_end → agent_end 后结束。

Q5steering 与 followUp 分别在什么时机被注入?QueueMode 控制什么?

steering 的 poll 点有三个:运行开始时、prepareNextTurn 结束后(仅当前一次 poll 为空,避免 one-at-a-time 一轮送两条)、turn_end 之后;注入发生在当前 assistant turn 的全部工具调用完成之后、下一请求之前,工具批不被跳过。followUp 只在内层循环自然退出(无工具调用、无 steering)时被 poll,取到则作为 pending 重启内层循环。QueueMode("all" / "one-at-a-time")控制每个 drain 点一次注入多少条排队消息。另外 prepareRequest 不 poll 队列;finishTurn 返回 end 时两个队列都不 poll、消息保留。

Q6agentLoopContinue 的重试约束是什么?Agent.continue() 遇到 assistant 结尾会怎么处理?

agentLoopContinue 要求:上下文非空;最后一条消息角色不是 assistant;且最后一条经 convertToLlm 必须翻成 user / toolResult(自定义角色结尾时由调用方负责保证,因为校验只发生在实际转换处)。它不为已有消息发事件,返回值只含本次新增消息。Agent.continue() 在 assistant 结尾时无法直接续跑,会依次回退消费一批排队 steering(跳过启动 poll)或一批 followUp,都没有则抛错;空 / 仅 system 的 transcript 直接拒绝且不消费队列。重试惯用法:followUp() 一条消息再 continue()。