Skip to content

Part II · 源码篇 / Ch 12

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

小A与老Z在多个工作站间传递发光接力棒和工具结果,展现 Agent 请求跨模块交接并循环推进。

本章导读: 源码路线的终点。本章不再读新文件,只把前十章走过的 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,前面没有跟读,这里也不补。”

一条正常路径 ​

text
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说:

text
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否
认证/providerapplyAuth 抛 ModelsErrorlazy 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 → Agentprompt、工具、system messageAgent(经 AgentSession 的插口)初始化失败不进入 loop
loop → ModelsContext、Model、AbortSignalModels.streamSimple未认证、未知 Provider、主动中止
Provider → loopAssistantMessage event streamrunAgentLooperror/aborted/length 停止原因
tool → looptool result message下一次 Context工具错误仍是模型可见结果
Agent → UI/store事件与会话条目interactive mode/sessionUI 观察不等于持久化成功

“中间最典型的一段是 ModelsImpl.streamSimple。”老Z打开 packages/ai/src/models.ts:

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否
abortedAbortSignalemit turn_end + agent_end否
lengthtoken 上限截断全部 tool call 标错后继续是
toolUse模型发 tool call执行工具,结果回传继续是
工具 isError工具抛错/被 blockpush 回 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 持有 currentContextserver 侧持有、client 持副本副本靠订阅快照重建
认证Models 直接解析模型凭据连接认证由传输层 listener 负责,协议里不带 token认证位置不同
工具执行本地进程内取决于 backend runtime需按 backend 核验
失败边界event sink/stopReasonframe/握手/附着/订阅排查层不同

“本地模式里 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,看这些边界里哪些连最小的产物也绕不开。

源码走查 ​

  1. 从 packages/coding-agent/src/main.ts 追到会话创建,记录每次传入的配置/资源对象。
  2. 在 agent-loop.ts 标出 assistant tool call 变为 tool result 的路径,再回到下一次模型流。
  3. 在 models.ts 跟到 Provider 的 streamSimple,为一次中止标出 AbortSignal 传递点。
  4. 观察交互模式如何订阅会话事件;若使用远程模式,再比对订阅快照与 service_update 的到达顺序。
  5. 对一次工具失败收集 loop 停止原因、tool result、session 条目和 UI 事件,验证四者没有混成同一种错误。