源码篇第12章 接力赛——End-to-End

本章导读: 源码路线的终点。本章不再读新文件,只把前十章走过的
models.ts→lazy.ts→agent-loop.ts→agent-session.ts→main.ts串起来。pi-mono 里并没有一个“总控函数”:一次完整请求是跨包的控制流拼接,每一站只交接不包办。
源码篇走完了。小A合上笔记本,长长舒了一口气:“所以 pi-mono 里一定有个‘总控函数’吧?我从命令行敲一句话进去,它就从第一行开始,一路跑完,把结果吐出来。”
“没有这样的函数。”老Z笑了,“这不算缺点,源码篇给你的本来就是一张地图:把不同包的真实调用点连起来,就是走查路线。下面这条基于上游 pi@v1.0.0(提交 a13d35a7)拼出。这一章不读新文件,只把第19到源码篇第11章的交接点串起来;远程宿主背后怎样跑 Agent,前面没有跟读,这里也不补。”
一条正常路径
CLI main/模式选择
→ 配置、资源与模型运行时
→ AgentSession
→ Agent 与 runAgentLoop
→ Models.streamSimple → Provider.streamSimple
→ 模型事件 → assistant message / tool call
→ 工具结果 → 后续模型消息
→ session entry 与 TUI 更新“packages/coding-agent/src/main.ts 与 cli.ts 是命令行启动路径;core/resource-loader.ts、core/model-runtime.ts、core/agent-session.ts 负责资源、模型和会话装配。packages/agent/src/agent.ts、agent-loop.ts 承接状态和循环;v1.0.0 的 pi-agent-core 不再有 harness,会话和压缩在 core/session-manager.ts、core/compaction/。packages/ai/src/models.ts 的 streamSimple 解析认证并委派 Provider 流。交互模式在 modes/interactive/interactive-mode.ts,TUI 基础设施在 packages/tui/src。”
比喻:接力赛
每个包只负责自己那一段接力棒(上下文、模型、事件、条目)。
“这条链路的每段都有独立入口,属于跨模块的控制流拼接。别把上面的箭头读成这些文件按排列顺序逐个互调。”
小A有点不放心:“没有总控,那每段怎么知道上一段把什么交给了自己?”
“靠类型契约。”老Z逐段点交接点,“CLI 层把 args 解析成配置对象,传给 resource-loader.ts;resource-loader 汇集成 Resources,传给 agent-session.ts;session 装配出 AgentContext(messages,其中 system message 携带 system prompt 和工具声明;以及可执行的 tools)、Model 和 AbortSignal,交给 Agent;loop 拿到这些后调 streamFunction,也就是 Models.streamSimple;Models 解析认证后委派给 Provider.streamSimple。每一段只消费上游显式传进来的参数,不读全局单例,所以替换 Provider 或换 TUI 都不用重写 loop,代价是排查故障得在交接点逐段关联。”
“把一次本地交互的完整形态画出来,是这样的。”老Z说:
main() ──args──> createAgentSessionFromServices ──> AgentSession(持有 Agent)
│ │
└──interactive 模式─────┘
InteractiveMode.init ──> AgentSession.subscribe(handleEvent)
用户输入 ──> Agent.runPromptMessages ──> runAgentLoop(prompts, context, config, emit, signal, streamFn)
├─ streamAssistantResponse ──> Models.streamSimple
│ │ lazyStream 同步返回,auth 后台解析
│ v
│ Provider.streamSimple ──> 真实 HTTP/SDK 流
│ v
│ assistant message(stop / toolUse / length / error / aborted)
v
tool call ──> executeToolCalls / failToolCallsFromTruncatedMessage
v
tool result 作为消息 push 回 context
v
下一次 stream → turn_end → agent_end
v
AgentSession 持久化 + InteractiveMode 刷新 TUI“注意这条图里没有一条线把‘CLI → 模型 → 工具 → 会话 → TUI’串成函数调用栈。”老Z说,“每条边都是独立入口:main() 装配,runAgentLoop 循环,Models.streamSimple 认证委派,AgentSession 持久化,InteractiveMode 订阅渲染。这张图要按‘谁在什么时机把什么交给谁’来读。”
“再看交接的数据形态。”老Z补充,“传给 Agent 的不是裸字符串 prompt,而是 AgentContext(messages 数组 + 可执行 tools;system prompt 和工具声明在 system message 里);模型层拿到的也不是裸 HTTP 请求,而是 Model 引用加 SimpleStreamOptions。失败语义也分属各段:CLI 失败是配置错误,session 初始化失败不进 loop,loop 失败是 stopReason 异常,Models 失败是认证或 provider 未知,四种错误不会混成一种。”
“把失败按层级捋一遍,关键分界是‘会不会进 loop’。”老Z边说边把六类失败归位:
| 失败层 | 典型错误 | 从哪里暴露 | 会不会进入 loop |
|---|---|---|---|
| CLI 配置 | 参数解析、资源读取、cwd 不存在 | main() 退出前 | 否 |
| session 装配 | 模型未选、服务初始化失败 | createAgentSessionFromServices | 否 |
| 认证/provider | applyAuth 抛 ModelsError | lazy stream 的 error 事件 | 是,以 error stopReason 出现 |
| 模型流 | HTTP 中断、限流、采样异常 | provider stream 事件 | 是,stopReason error/aborted |
| 工具执行 | 工具抛错 | executeToolCalls 捕获 | 是,isError tool result 回传 |
| 会话持久化 | JSONL 写失败 | SessionManager._persist 抛错,经 AgentSession 事件处理向上传播 | 可在 loop 中发生,并进入 Agent 失败处理;UI 可能已先收到消息事件 |
工具结果怎样返回循环
小A指着链路问:“tool call 发出去之后,loop 这边接着做哪些事?”
“助手流中的 tool call 被 Agent 循环交给已装配的工具,工具结果再作为消息进入后续模型上下文。”老Z说,“runAgentLoop 管理停止原因、中止和下一次流;AgentSession 负责把状态与条目交给存储。工具输出可能失败、被截断或被拒绝,正常路径必须把结果显式传回。”
小A想看得更细:“tool call 到底在 loop 的哪一步变成 tool result?”
“看 runAgentLoop 的内层循环。”老Z回到 agent-loop.ts,“stream 完一条 assistant message 后,先检查 stopReason:error 或 aborted 直接 emit agent_end 退出,不执行工具。正常情况下从 message 里 filter(c => c.type === "toolCall") 拿出 tool calls,接着是个关键分支:stopReason === "length" 时走 failToolCallsFromTruncatedMessage,把所有 tool call 标记为失败而不是执行。length 截断意味着 tool call 的 arguments 可能不完整,执行它会有语义错误。只有非 length 的 stopReason 才走 executeToolCalls 真正执行。”
“执行完的 tool results 被 push 进 currentContext.messages 和 newMessages。”老Z继续,“然后看 hasMoreToolCalls = !executedToolBatch.terminate:如果工具批次要求终止(比如某个工具返回了致命错误),内层循环就停。v1.0.0 里宿主还可以在 finishTurn 返回 { action: "end" } 提前结束。否则继续下一轮 stream,此时 context 里已经带着 tool result messages,模型看到它们再决定下一步。tool result 就是普通消息,模型把工具输出当成对话历史的一部分。”
小A数了起来:“一个 tool call 从消息里被拿出来,到变成 result,中间到底几步?”
“四步,每步都可能短路成 error result。”老Z把 executeToolCalls 的管线摊开,“第一步 prepareToolCall:按 name 找工具,找不到直接产 Tool not found;找到了就 prepareArguments 预处理、validateToolArguments 验证参数,再跑 beforeToolCall 钩子,钩子 block 就产出被阻止的 error result。第二步 tool.execute,抛错被捕获成 error result。第三步 finalizeExecutedToolCall 跑 afterToolCall 钩子,钩子抛错也把结果降级为 error。第四步 createToolResultMessage 把 outcome 变成 toolResult 消息。另外,顺序执行时每跑完一个工具就检查 signal.aborted:abort 短路后续工具,已执行的照常保留。”
小A抢答:“这个我猜是 some,只要有一个工具返回 terminate,整批就停?”
“看 shouldTerminateToolBatch。”老Z指回 agent-loop.ts,“条件是 finalizedCalls.length > 0 && finalizedCalls.every(f => f.result.terminate === true):批里每个工具的结果都标记了 terminate,整批才终止,是 every 不是 some。terminate 字段由工具返回,也可被 afterToolCall 改写。loop 自己不猜‘哪个错误该终止’,这是产品层通过工具显式表达的;length 分支正好相反,那边是 loop 代判参数不完整。”
中断归中断,恢复归恢复
小A又想到了一处:“那我 Ctrl-C 之后,重新打开进程,一切就自动恢复了对吧?”
“自动恢复谈不上。”老Z说,“AbortSignal 穿过模型流和 Agent 路径,模型或工具错误也会产生停止/错误状态。会话 JSONL 与 compaction 提供的是持久化和上下文管理;进程重新拉起后,外部世界不会因此自动回到正确状态:已经执行的文件写入、网络请求或远程命令,仍然要靠幂等、审计和产品层的恢复策略兜底。”
“远程模式还会加入 protocol frame、handshake 和服务订阅:订阅快照是完整状态,service_update 是在快照上的增量。”老Z又提醒一句,“这些都不改变本地工具副作用的事务语义。”
以本地交互为例逐段交接
小A想落到代码上:“拿一次真实的本地运行,把交接点挨个对到代码上,可以吗?”
“CLI 解析参数和配置后,resource-loader.ts 汇集项目资源,model-runtime.ts 暴露模型目录,agent-session.ts 将模型、系统提示、工具、会话服务和运行环境装配为 session。”
| 交接点 | 上游数据 | 下游消费者 | 失败/中止语义 |
|---|---|---|---|
| CLI → 配置 | args、cwd、配置路径 | resource/model runtime | 参数或资源读取失败,不能建 session |
| session → Agent | prompt、工具、system message | Agent(经 AgentSession 的插口) | 初始化失败不进入 loop |
| loop → Models | Context、Model、AbortSignal | Models.streamSimple | 未认证、未知 Provider、主动中止 |
| Provider → loop | AssistantMessage event stream | runAgentLoop | error/aborted/length 停止原因 |
| tool → loop | tool result message | 下一次 Context | 工具错误仍是模型可见结果 |
| Agent → UI/store | 事件与会话条目 | interactive mode/session | UI 观察不等于持久化成功 |
“中间最典型的一段是 ModelsImpl.streamSimple。”老Z打开 packages/ai/src/models.ts:
// packages/ai/src/models.ts,ModelsImpl.streamSimple(v1.0.0,节选)
const transcript = normalizeContext(context);
return lazyStream(model, async () => {
const provider = this.requireChatProvider(model);
const { requestModel, requestOptions } = await this.applyAuth(model, options);
return provider.streamSimple(requestModel, transcript, requestOptions as SimpleStreamOptions);
});“这里的跨包数据是 Model、规范化后的 transcript 与事件流。认证错误发生在 applyAuth,TUI 不参与判断;Provider 选定之后,才有具体的 HTTP/SDK 行为。”
小A盯着这层包装看了一会儿:“lazyStream 有什么特别的?不包行不行?”
“不包的话,streamSimple 得等认证解析完才能返回。”老Z翻出 packages/ai/src/api/lazy.ts,“lazyStream(model, setup) 同步返回一个空的 AssistantMessageEventStream,setup(requireChatProvider + applyAuth + provider.streamSimple)在后台跑,成功就由 forwardStream 转发事件,失败就把错误包装成 stopReason: "error" 的 assistant message 推给外层并 end()。所以认证和 provider 选择都属于流式的一部分:loop 拿到流就开始消费,认证失败以流里的 error 事件到达,不会在调用点抛异常。错误分类表里‘认证失败进入 loop’那一行,出处就在这里。applyAuth 还有一处细节:transformHeaders 在认证解析之后运行,因此 header 变换能看到最终要用的 auth header。”
工具、停止与会话记录
“runAgentLoop 接收消息、工具、流函数和中止信号,收集 assistant message,再把 tool result 放回待处理消息。”老Z先给它划好边界,“CLI 参数解析不归它管,终端渲染也不归它管,调试时要同时看 loop 的停止原因和 coding-agent 侧的工具实现。”
“那正常收尾长什么样?”小A问。
“正常路径中,模型流因 tool use 结束时,loop 执行相应工具并把结果变成后续上下文;因 stop 结束时,turn 收束。AbortSignal 请求当前 run 的流停止;会话后续处理由宿主决定,但不撤销已经发生的外部副作用;工具 error 不是 transport error,仍要保留给模型去调整下一步。”
小A想把两种停法归成一类:“abort 和工具出错,都算‘出错了停下来’吧?”
“不一样,路径不同。”老Z对着 runAgentLoop 说,“signal 传给 streamAssistantResponse,模型流内部检查 signal.aborted,abort 会让流产出 stopReason: "aborted" 的 assistant message。loop 看到 error 或 aborted 就 emit turn_end + agent_end 退出,不执行任何 tool call。工具 error 走的是另一条路:executeToolCalls 执行工具时,工具抛错被捕获并封装成 isError: true 的 tool result message,push 回 context,loop 继续下一轮让模型看到错误并调整。所以 abort 终止整个 agent,工具 error 只是一次可恢复的失败。两者的停止语义不能混:拿工具 error 当 abort,模型自我修正的机会就没了;拿 abort 当工具 error 去续跑,等于接着运行一个已被取消的会话。”
“这里要分清透传和检查。”老Z补了一句,“streamAssistantResponse 把 signal 传给 transformContext 和 streamFunction,自身不检查 signal.aborted;模型流的取消依赖下游实现响应 signal。工具准备层防住新执行,执行层短路剩余工具、保留已完成的。”
“把停止形态并排放在一起看。”老Z随手列了出来:
| 停止形态 | 由谁触发 | loop 行为 | 能否恢复 |
|---|---|---|---|
| error | 模型流/认证错误 | emit turn_end + agent_end | 否 |
| aborted | AbortSignal | emit turn_end + agent_end | 否 |
| length | token 上限截断 | 全部 tool call 标错后继续 | 是 |
| toolUse | 模型发 tool call | 执行工具,结果回传继续 | 是 |
| 工具 isError | 工具抛错/被 block | push 回 context,下一轮继续 | 是 |
“还有,AgentSession 在 message_end 时调 SessionManager.appendMessage 保存会话条目,走的是和 interactive mode 刷新不同的通道。”老Z说,“屏幕上已经看到输出,不能证明条目已经耐久化;反过来也一样。”
远程路径在哪里分叉
小A想起还没问的另一半:“换到远程用的时候,走的还是这条链路吗?”
“本地交互在 session/TUI 边界直接消费 Agent 事件。协议远程路径则加入 pi-protocol、pi-server、pi-client:对象先经 schema、CBOR、frame,client 先 hello,再附着会话、订阅服务,server 以订阅快照和 service_update 回传。”老Z说,“已确认的 server 边界是:它把服务调用交给宿主 openSession 返回的会话 handle。至于宿主在背后用什么跑 Agent,v1.0.0 里是移植到 pi-durable 的实验性实现——那条线属于源码篇第13章 先落盘,后可见——pi-durable,本主干不展开。”
小A接着确认:“远程模式下,本地 loop 和远程 runtime 是同一个东西吗?”
“不一定是。”老Z把 packages/server/src/types.ts 调了出来,“RoutedSessionHandle 和 RoutedSessionAttachment 只规定附着、调用服务、释放和关闭,连会话操作都没有固定下来,更不规定内部跑的是 runAgentLoop 还是别的循环。本地交互模式里 TUI 直接订阅 Agent 的 event sink,事件是内存里的对象引用;远程模式里状态变化要先编码成服务订阅的快照和 service_update envelope,过协议层到 client 再解码。同一个语义事件,两种传输形态,所以本地故障去 event sink 查,远程故障去协议边界查。”
“这也解释了为什么不能跨包共享状态。”老Z补充,“本地 loop 持有 currentContext 的对象引用;远程模式下 server 的 loop 持有 context,client 只拿到序列化后的快照副本。client 改本地副本动不到 server 的真实状态,只有再发服务调用才行。”
小A换了个工程视角:“本地和远程这两条路,维护成本差在哪?”
“差在‘对象引用’和‘序列化副本’之间的所有边界上。”老Z把取舍收进一张表:
| 决策点 | 本地交互 | 远程模式 | 复用/代价 |
|---|---|---|---|
| 事件传递 | emit 内存对象引用 | 订阅快照+service_update 编解码 | 语义相近,传输形态不同 |
| 状态权威 | loop 持有 currentContext | server 侧持有、client 持副本 | 副本靠订阅快照重建 |
| 认证 | Models 直接解析模型凭据 | 连接认证由传输层 listener 负责,协议里不带 token | 认证位置不同 |
| 工具执行 | 本地进程内 | 取决于 backend runtime | 需按 backend 核验 |
| 失败边界 | event sink/stopReason | frame/握手/附着/订阅 | 排查层不同 |
“本地模式里 session 直接在 AgentSession 上跑,远程模式里 server 把服务调用转给宿主的会话 handle。”老Z说,“复用的是抽象和协议;内存布局两边各是各的。”
“坏 frame 在 decoder 层终止连接,版本错误在握手层返回;断线后未完成 request 可能处于未知提交状态。”老Z接着交代,“订阅快照能重建显示状态,却不能回滚已执行写入。重新附着是新的协议操作,旧 socket 不会自动延续。”
“工程取舍是分层允许本地与远程复用 runtime,代价是故障要定位到配置、认证、流、loop、工具、session、UI 或协议边界。”老Z说,“所以日志应带上 session ID、request ID、tool call ID 与 Provider 标识。”
小结
走完一遍完整请求就能确认,pi-mono 里没有一个“总控函数”能从头跑到尾。一次请求是跨包的控制流拼接:CLI 解析参数并装配资源,AgentSession 组织会话状态和生命周期,loop 推动消息在模型与工具之间往返,Models 委派 Provider 发出真实的流,工具结果作为消息回到下一次上下文,TUI 订阅事件刷新界面。每段交接都有明确的上下游数据和失败语义:传给 Agent 的是 AgentContext、Model、工具和 AbortSignal;认证错误发生在 applyAuth;工具 error 不是 transport error,要作为结果显式回传给模型。中断和恢复也是两回事:AbortSignal 让流和会话停止,但不撤销已经发生的外部副作用;JSONL 和 compaction 提供持久化,不保证进程重启后外部世界自动回到正确状态。
这张地图的读法,是记住每段“只交接不包办”。loop 不管 CLI 参数解析,也不管终端渲染;Models 不管工具实现;Agent 不管 Provider 的 HTTP 细节。大家认的是同一份契约:Agent 把 AgentContext、工具、signal 交给 loop,loop 把 streamFunction 指向 Models,Models 把 Model 与规范化后的 transcript 交给 Provider。工具调用在 loop 里有四条独立路径:length 截断时全部标记失败,找不到工具立即报错,执行抛错封装成 isError,terminate 是整批都标了才停。这些都是产品策略在源码里的显式表达,循环本身不做猜测。
远程路径在协议层分叉:对象先经 schema、CBOR、frame,客户端先 hello,再附着和订阅,服务端以订阅快照和 service_update 回传;坏帧终止连接,版本错误在握手层返回,断线后未完成请求的提交状态未知。本地与远程复用同一个 Agent 抽象,但事件形态不同:本地是内存对象引用,远程是序列化 envelope 加快照重建,所以本地故障查 event sink,远程故障查协议边界。源码篇到此结束,留下的是一张地图:下次遇到故障或者想扩展,你知道该往哪个边界看,也知道日志里该带哪些 ID 才能定位。接下来的实现篇换一个方向:不再读别人的系统,而是从一个空目录出发,亲手写一个范围克制的 Agent,看这些边界里哪些连最小的产物也绕不开。
源码走查
- 从
packages/coding-agent/src/main.ts追到会话创建,记录每次传入的配置/资源对象。 - 在
agent-loop.ts标出 assistant tool call 变为 tool result 的路径,再回到下一次模型流。 - 在
models.ts跟到 Provider 的streamSimple,为一次中止标出AbortSignal传递点。 - 观察交互模式如何订阅会话事件;若使用远程模式,再比对订阅快照与
service_update的到达顺序。 - 对一次工具失败收集 loop 停止原因、tool result、session 条目和 UI 事件,验证四者没有混成同一种错误。
