从源码看 pi 的 AI Agent 编排架构
AI Agent 项目的复杂性往往不在“调用一次大模型”本身,而在如何把用户输入、上下文、工具调用、流式输出、错误恢复、会话持久化和扩展系统组织成一个可控的运行时。pi 这个项目的 Agent 部分正是这种复杂性的集中体现。它不是一个简单的 prompt wrapper,而是把通用 Agent loop、编码场景 runtime、TUI/RPC/print 模式、扩展机制、工具系统和会话树组织成了一套完整的 Agent 编排框架。
packages
├── agent
│ ├── CHANGELOG.md
│ ├── docs
│ ├── package.json
│ ├── README.md
│ ├── src
│ ├── test
│ ├── tsconfig.build.json
│ ├── vitest.config.ts
│ └── vitest.harness.config.ts
├── ai
│ ├── bedrock-provider.d.ts
│ ├── bedrock-provider.js
│ ├── CHANGELOG.md
│ ├── package.json
│ ├── README.md
│ ├── scripts
│ ├── src
│ ├── test
│ ├── tsconfig.build.json
│ └── vitest.config.ts
├── coding-agent
│ ├── CHANGELOG.md
│ ├── docs
│ ├── examples
│ ├── npm-shrinkwrap.json
│ ├── package.json
│ ├── README.md
│ ├── scripts
│ ├── src
│ ├── test
│ ├── tsconfig.build.json
│ ├── tsconfig.examples.json
│ └── vitest.config.ts
└── tui
├── CHANGELOG.md
├── native
├── package.json
├── README.md
├── src
├── test
├── tsconfig.build.json
└── vitest.config.ts
从源码结构看,最核心的分层是 packages/agent 和 packages/coding-agent。前者提供通用的 Agent core,关注模型交互和工具循环。后者提供面向编码助手的产品运行时,关注工作目录、会话、系统提示词、工具注册、扩展、自动压缩、重试和界面事件。理解这两层的边界,是理解整个 Agent 架构的入口。
一、项目中的 Agent 分层
packages/agent 可以看作 Agent 编排的最小内核。它不关心 TUI,不关心 session 文件怎么存,不关心 slash command,也不关心项目目录里的 AGENTS.md。它关心的是一件事:给定上下文、模型、工具和若干 hook,如何驱动一次或多次 LLM turn,如何执行工具,如何把结果回填给模型,并把整个过程以事件流的形式暴露出去。
这一层的主要文件是:
packages/agent/src/agent-loop.tspackages/agent/src/agent.tspackages/agent/src/types.ts
其中 agent-loop.ts 是低层状态机,agent.ts 是有状态封装,types.ts 定义 AgentMessage、AgentTool、AgentEvent 等核心类型。
packages/coding-agent 则是在 core Agent 之上构建的产品层。它负责把一个通用 Agent 变成“编码 Agent”。这里出现了更多具体概念:当前工作目录、session manager、resource loader、settings manager、model registry、extension runner、built-in tools、prompt templates、skills、compaction、bash execution、interactive mode、RPC mode 等。
这一层的核心文件包括:
packages/coding-agent/src/core/sdk.tspackages/coding-agent/src/core/agent-session.tspackages/coding-agent/src/core/agent-session-runtime.tspackages/coding-agent/src/core/messages.tspackages/coding-agent/src/core/tools/index.tspackages/coding-agent/src/core/extensions/*
如果用一句话概括,两层关系是:packages/agent 负责“Agent 怎么跑”,packages/coding-agent 负责“一个编码助手产品应该如何使用这个 Agent”。
二、一次 Prompt 的完整生命周期
用户输入并不会直接进入大模型。它首先进入 AgentSession.prompt()。这是编码 Agent 的真正入口。
这一阶段的目标不是立刻调用模型,而是做一系列前置编排。源码中的顺序很关键,因为它决定了谁有权改写输入、谁有权拦截命令、谁有权修改系统提示词。
第一步是扩展命令处理。如果用户输入以 / 开头,AgentSession 会先检查它是否是扩展注册的 command。如果是,command handler 会立即执行,并且不会进入模型。这意味着扩展命令和普通 prompt 是两条不同路径。扩展命令属于宿主运行时行为,普通 prompt 才属于 Agent 对话行为。
第二步是 input hook。扩展可以监听 input 事件,对用户输入进行 handled 或 transform。如果返回 handled,当前输入就被扩展消费,不再继续。若返回 transform,输入文本或图片会被改写,然后继续后续流程。这个位置很早,说明扩展可以在 skill/template 展开之前介入原始输入。
第三步是 skill 和 prompt template 展开。/skill:name 会被展开成包含 skill 文件内容的块,prompt template 也会在这里替换。展开后的内容才会成为模型看到的用户消息。
第四步是 streaming 状态判断。如果当前 Agent 正在运行,新的用户输入不会直接启动另一个 prompt。它必须显式选择 steer 或 followUp。steer 表示在当前 assistant turn 完成工具调用后尽快注入,followUp 表示等当前 Agent 自然结束后再执行。这是项目中很重要的并发控制点:同一时间只有一个 active run,但允许用户在运行中排队消息。
第五步是模型和认证校验。只有在非 streaming 且将要真实启动模型调用时,才校验当前 model 是否存在,以及 provider 是否有可用认证。这样可以避免无效请求进入 core loop。
第六步是 compaction 前置检查。如果上一条 assistant message 表明上下文过大或之前存在 aborted/error 情况,系统可能会先触发压缩,再继续当前 prompt。
第七步是构造消息数组。普通用户输入会被包装为 role: "user" 的 AgentMessage。如果扩展通过 before_agent_start 注入了 custom message,这些消息也会一起进入本轮 prompt。扩展还可以在这里修改 system prompt。最终,AgentSession 才会调用 core Agent.prompt()。
这个流程体现了 coding-agent 的职责:它不直接实现 LLM loop,而是在调用 loop 前完成输入治理、扩展治理、上下文治理和产品策略。
三、Core Agent Loop:Turn 驱动的状态机
真正的 Agent loop 在 packages/agent/src/agent-loop.ts。它的核心函数是 runLoop()。
runLoop() 的结构可以拆成两层循环。外层循环处理 follow-up 消息,内层循环处理工具调用链和 steering 消息。
一次普通 prompt 的事件顺序大致是:
agent_start
turn_start
message_start user
message_end user
message_start assistant
message_update assistant
message_end assistant
turn_end
agent_end
如果 assistant 产生 tool call,流程会变成:
assistant response
tool_execution_start
tool_execution_update
tool_execution_end
message_start toolResult
message_end toolResult
turn_end
turn_start
assistant response after tool result
...
这里的“turn”不是一条消息,而是“一次 assistant 响应加上它产生的工具执行”。如果 assistant 调用了工具,tool result 会被加入上下文,然后 loop 再开启下一次 assistant turn,让模型基于工具结果继续回答。
runLoop() 里有几个关键判断。
第一,assistant message 如果是 error 或 aborted,loop 会发出 turn_end 和 agent_end 后退出。错误恢复不在 core loop 内部处理,而是交给上层 AgentSession。
第二,如果 assistant message 包含 tool call,就执行工具,并把 tool result message 加入上下文。只要工具 batch 没有全体 terminate: true,loop 就认为还可能有更多 tool calls,于是进入下一轮。
第三,每个 turn 结束后,会调用 prepareNextTurn。这个 hook 可以替换下一轮的 context、model 或 thinking level。这是实现动态模型切换或上下文替换的底层机制。
第四,shouldStopAfterTurn 可以要求当前 turn 结束后优雅停止。它不会中断已经开始的 provider stream,也不会取消正在执行的工具。它只是在 turn 边界阻止下一次 LLM call。
第五,steering 消息在工具调用完成后被检查。也就是说,用户在工具执行期间发出的 steer 不会打断当前工具 batch,而是在当前 assistant turn 完整结束后插入下一轮模型调用。
第六,follow-up 消息只在 loop 本来要停止时检查。它比 steering 更“晚”,语义上是“当前任务完成后再处理”。
这套设计让 core loop 保持简洁:它只关心 turn、message、tool 和 queue,而不把压缩、重试、扩展命令等产品策略塞进底层状态机。
四、消息模型:AgentMessage 到 LLM Message
项目中一个非常重要的抽象是 AgentMessage。它比 LLM provider 接受的 Message 更宽。LLM 只理解 user、assistant、toolResult 等标准消息,但编码 Agent 还需要表达 bash execution、custom extension message、branch summary、compaction summary 等运行时消息。
所以 core Agent 不直接假设所有消息都能发给模型,而是提供 convertToLlm。在 packages/coding-agent/src/core/messages.ts 中,convertToLlm() 负责把产品层消息转换为模型可读消息。
例如,bashExecution 会被转换成一个 user message,内容大致是“Ran command”加上输出。被标记为 excludeFromContext 的 bash execution 会被跳过。custom message 会转换为 user message。branchSummary 和 compactionSummary 会转换成带 summary 标签的 user message。标准的 user、assistant、toolResult 则原样保留。
这个设计有两个好处。
第一,产品运行时可以保留丰富的事件和 UI 消息类型,不必为了适配模型而丢失结构信息。
第二,模型上下文的构造有一个明确边界。所有“哪些消息应该进入模型,如何进入模型”的逻辑集中在 convertToLlm,而不是散落在 UI、session 或 provider 层。
在 provider 调用前,还有一个 transformContext 阶段。它发生在 convertToLlm 之前,作用于 AgentMessage[]。这意味着扩展或上层逻辑可以先按产品语义修改上下文,再转换成 LLM 消息。比如扩展可以注入 context,压缩逻辑可以替换历史消息,或者过滤某些 custom message。
因此,完整上下文路径是:
AgentMessage[]
→ transformContext()
→ AgentMessage[]
→ convertToLlm()
→ Message[]
→ provider
这条路径是理解整个 Agent 上下文系统的关键。
五、工具编排:注册、选择、执行与结果回填
工具系统同样分层。
在 core Agent 中,工具类型是 AgentTool。它有 name、label、description、parameters、execute,以及可选的 prepareArguments 和 executionMode。core loop 只关心这些字段,尤其是如何校验参数、如何执行工具、如何发出 tool execution events、如何生成 toolResult message。
在 coding-agent 层,工具系统更复杂。它采用 definition-first registry。内置工具、扩展工具、SDK 自定义工具会先合成 ToolDefinition 注册表,再 wrap 成 core Agent 能执行的 AgentTool。
这样做的原因是,工具不只是可执行函数。它还影响系统提示词、UI 展示、扩展元数据、active tools 列表、allowlist/denylist,以及 HTML export 渲染。用 definition-first registry 可以让“工具的描述”和“工具的执行”共享同一个源。
AgentSession._refreshToolRegistry() 会收集三类工具:
- built-in tools,例如 read、bash、edit、write、grep、find、ls。
- extension registered tools。
- SDK custom tools。
然后它会应用 allowed/excluded tool 规则,生成 _toolDefinitions、_toolRegistry、tool prompt snippets、tool guidelines,并最终调用 setActiveToolsByName() 更新 agent.state.tools 和 system prompt。
工具执行阶段在 core loop 中完成。默认模式是 parallel。也就是说,如果 assistant 一次返回多个 tool call,core loop 会先按顺序做 preflight,然后并发执行允许的工具。工具完成事件 tool_execution_end 按实际完成顺序发出,但 toolResult message 会按 assistant 原始 tool call 顺序写回。这解决了一个常见问题:UI 可以及时显示先完成的工具,但模型上下文保持稳定顺序。
如果任意一个 tool 的 executionMode 是 "sequential",整个 batch 会退回顺序执行。这给 bash、edit 等对顺序敏感的工具留下了控制空间。
工具执行前后还有两个 hook:beforeToolCall 和 afterToolCall。在 coding-agent 中,它们被用于扩展系统的 tool_call 和 tool_result 事件。tool_call 可以阻止执行,tool_result 可以改写结果内容、details 或 error 状态。这样扩展系统能够参与工具安全策略和结果处理,而 core loop 不需要知道扩展存在。
六、扩展系统如何介入 Agent 编排
扩展系统是 coding-agent 编排中最强的插拔点。它不是单一的插件入口,而是贯穿 Agent 生命周期的多阶段 hook 系统。
扩展可以介入以下阶段:
input:原始用户输入进入 prompt 前。before_agent_start:Agent 启动前,可注入 custom message 或修改 system prompt。context:模型调用前,可修改 AgentMessage 上下文。before_provider_request:provider payload 发送前。after_provider_response:provider 响应返回后。tool_call:工具执行前,可 block。tool_result:工具执行后,可改写结果。message_start、message_update、message_end:消息生命周期事件。turn_start、turn_end:turn 生命周期事件。agent_start、agent_end:Agent run 生命周期事件。session_start、session_shutdown、session_before_switch、session_before_fork、session_before_compact等 session 事件。
这些 hook 分布在不同文件中,但核心调度者是 ExtensionRunner。AgentSession 负责在合适的 Agent 事件位置调用 _emitExtensionEvent(),并在特定场景下调用专用方法,比如 emitInput()、emitBeforeAgentStart()、emitContext()、emitToolCall()、emitToolResult()。
值得注意的是,扩展 context 是懒读取的。ExtensionRunner.createContext() 返回的对象通过 getter 读取当前 model、cwd、sessionManager、signal 等状态。这样 session reload 或 switch 后,旧 context 可以被 invalidate,避免扩展继续使用陈旧运行时。
扩展系统对 Agent 编排的影响很大。它既可以改用户输入,也可以改系统提示词;既可以增加工具,也可以拦截工具;既可以修改 provider payload,也可以替换 message_end 的 assistant message。因此,分析这个项目的扩展系统,本质上是在分析 Agent 编排的开放边界。
七、队列机制:Steering 与 Follow-up
Agent 运行时不允许并发执行多个 prompt。Agent.prompt() 如果在 active run 存在时被调用,会直接报错。但项目仍然支持用户在模型运行中继续输入,这靠的是两个队列:steering queue 和 follow-up queue。
steer 表示“当前 turn 完成后尽快插入”。具体来说,如果 assistant 正在执行工具,steering message 不会打断当前工具,而是在工具执行完成、toolResult 写入之后,下一次 LLM call 之前进入上下文。
followUp 表示“当前 Agent 原本要停止时再执行”。如果当前任务还会因为工具结果继续多轮,follow-up 会等这些自然流程结束后再注入。
两者的差异不是 UI 层概念,而是 core loop 的调度语义。steering 属于内层循环,follow-up 属于外层循环。steering 能够改变当前任务后续方向,follow-up 更像排队下一个任务。
队列还有两种 drain 模式:one-at-a-time 和 all。在 one-at-a-time 下,每次只取一条 queued message。模型会逐条响应。all 模式下,同一 drain 点会取出所有 queued messages,一次性加入上下文,然后模型统一响应。
AgentSession 还维护 _steeringMessages 和 _followUpMessages 供 UI 显示。当 queued user message 真正进入 message_start 时,AgentSession 会从显示队列中移除对应文本并发出 queue_update。这说明项目区分了“core Agent 队列”和“UI 可见队列”:前者驱动执行,后者驱动界面状态。
八、会话持久化与恢复
AgentSession 订阅 core Agent 的所有事件,并在 message_end 时持久化消息。普通 user、assistant、toolResult 会通过 SessionManager.appendMessage() 写入 session。custom message 会通过 appendCustomMessageEntry() 保存。bash execution、compaction summary、branch summary 等特殊消息则在各自逻辑中保存。
这个持久化发生在扩展 message_end 之后。也就是说,如果扩展在 message_end 中替换了 assistant message,最终写入 session 的是替换后的版本。这一点在测试里也有覆盖。
恢复时,createAgentSession() 会调用 sessionManager.buildSessionContext()。如果 session 有历史消息,它会尝试恢复之前使用的 model 和 thinking level。如果无法恢复原模型,则走 fallback model 逻辑,并产生 modelFallbackMessage。然后将历史 messages 写入 agent.state.messages。
session runtime 还支持 new、resume、fork、tree navigation 等操作。AgentSessionRuntime 在切换 session 时会先发 session_before_switch,允许扩展取消;然后发 session_shutdown,dispose 当前 session;最后创建新 runtime 并发 session_start。这保证了扩展在 session 生命周期中有明确边界。
这一层的设计说明:会话不是简单日志,而是一棵可分支、可恢复、可压缩、可标注的执行历史。Agent state 是当前运行态,SessionManager 是持久态,两者通过事件同步。
九、上下文管理:压缩、溢出恢复与自动重试
上下文压缩和自动重试没有放进 packages/agent,而是放在 AgentSession。这是合理的,因为它们是产品策略,不是通用 loop 语义。
每次 agent.prompt() 完成后,AgentSession._runAgentPrompt() 会进入 _handlePostAgentRun()。这个函数根据最后一条 assistant message 决定是否继续:
- 如果是可重试错误,准备 retry,然后调用
agent.continue()。 - 如果需要 compaction,执行自动压缩,然后可能调用
agent.continue()。 - 如果
agent_end扩展 handler 又排入了消息,也继续。
自动重试针对 rate limit、overloaded、网络错误、5xx、timeout 等临时错误。context overflow 不归重试处理,而归 compaction 处理。重试时会从 agent state 中移除错误 assistant message,但保留 session 中的历史记录。这是一个重要细节:模型重试上下文要干净,但用户历史仍然能看到错误发生过。
自动压缩有两种触发:overflow 和 threshold。overflow 表示 provider 明确返回上下文溢出,或 usage 超出 context window。threshold 表示上下文接近配置阈值。overflow 且 assistant 没有成功回答时,压缩后会 retry;threshold 压缩通常不自动 retry,因为成功回答已经完成,不能从 assistant 尾部直接 continue。
压缩本身也开放给扩展。session_before_compact 可以取消压缩,也可以提供自定义 compaction 结果。否则系统会调用 compact() 生成 summary,并把 compaction entry 写入 session。之后重新构建 session context,更新 agent.state.messages。
这套设计的关键是:压缩不是简单裁剪 messages,而是 session tree 上的一个持久事件。它有 summary、firstKeptEntryId、tokensBefore、details,并能被后续恢复逻辑识别。

浙公网安备 33010602011771号