AgentSession 产品编排
阶段 2、3 已经回答了:
Agent 如何调用模型?模型要求执行工具后,工具如何执行并返回结果?本阶段回答更外层的问题:
一个通用 Agent Loop,怎样被组装成可实际使用的 coding agent?读完本文后,需要能够:
- 区分
Agent、AgentSession、AgentSessionServices和AgentSessionRuntime; - 追踪一次
AgentSession.prompt()从用户文本到最终agent_settled; - 解释工具、System Prompt、Extensions 和持久化如何接入底层 Agent;
- 解释为什么一个用户请求可能包含多个底层 Agent Run;
- 解释 retry 和 compaction 为什么位于 Agent Loop 外层;
- 解释切换 Session 时为什么要替换整个 cwd-bound runtime。
一、先给出结论:AgentSession 是产品编排层
Section titled “一、先给出结论:AgentSession 是产品编排层”Agent 只知道:
- 当前模型和 thinking level;
- 当前 System Prompt、消息和工具;
- 如何执行 Agent Loop;
- steering、follow-up、abort 和事件。
AgentSession 在 Agent 外再加上 coding-agent 的产品规则:
- 从用户输入中识别命令、Skill 和 Prompt Template;
- 检查模型和认证;
- 装配 coding tools、Extensions 和 System Prompt;
- 把 Agent 事件保存到 JSONL Session;
- 在一次底层 Agent Run 结束后决定是否 retry、compact 或 continue;
- 对 UI、Print、JSON、RPC 和 SDK 暴露统一事件。
可以把两者理解为:
Agent = 会思考、调用工具并产生事件的执行引擎
AgentSession = 管理这台引擎如何在 coding 产品中启动、续跑、保存和恢复的控制器这不是继承关系,而是组合关系:
AgentSession ├─ owns Agent ├─ owns SessionManager ├─ uses SettingsManager ├─ uses ResourceLoader ├─ uses ModelRuntime └─ owns ExtensionRunner关键入口:
AgentSession:packages/coding-agent/src/core/agent-session.ts- 构造函数:
packages/coding-agent/src/core/agent-session.ts - SDK 工厂:
packages/coding-agent/src/core/sdk.ts
二、五个容易混淆的层级
Section titled “二、五个容易混淆的层级”flowchart TB
Host["Interactive / Print / RPC / SDK"]
Runtime["AgentSessionRuntime<br/>可替换当前 Session"]
Session["AgentSession<br/>产品编排"]
Agent["Agent<br/>状态与一次次 Run"]
Loop["agentLoop<br/>模型:工具循环算法"]
Provider["ModelRuntime / Provider"]
Store["SessionManager / JSONL"]
Host --> Runtime
Runtime --> Session
Session --> Agent
Agent --> Loop
Loop --> Provider
Session --> Store1. agentLoop
Section titled “1. agentLoop”它是算法:
调用模型 → 收到 Tool Call → 执行工具 → 把 Tool Result 放回上下文 → 再调用模型 → 没有工具调用时结束它不长期持有产品状态,也不认识 JSONL、Skills 或 CLI。
2. Agent
Section titled “2. Agent”它是通用运行时对象:
Agent = AgentState + Loop 启动权 + 队列 + Abort + 事件 listener一次 agent.prompt() 或 agent.continue() 启动一次 Agent Run。一个 Agent 同一时刻只允许一个 active run,但同一个 Agent 可以先后执行多个 run。
3. AgentSession
Section titled “3. AgentSession”它包装一个 Agent,并把一次产品级请求管理到真正稳定:
AgentSession.prompt() → agent.prompt() # 第一个底层 Run → 若需要 retry/compaction agent.continue() # 第二个底层 Run → 若仍需续跑 agent.continue() # 第三个底层 Run → agent_settled因此:
一个
AgentSession.prompt()不一定等于一个 Agent Run。
4. AgentSessionServices
Section titled “4. AgentSessionServices”它是一组与项目目录 cwd 绑定的服务:
AgentSessionServices ├─ cwd ├─ agentDir ├─ SettingsManager ├─ ResourceLoader ├─ ModelRuntime └─ diagnostics它还不是一次对话,不拥有消息历史,也不会运行 Agent。
它回答的问题是:
如果要在项目 A 中创建一个 AgentSession,需要使用哪一组项目环境?
例如项目 A 的 Services 必须内部一致:
cwd = /work/project-aSettingsManager = 读取 project-a/.pi/settings.jsonResourceLoader = 加载 project-a 的 AGENTS.md、Skills、ExtensionsModelRuntime = 包含这些 Extension 注册的 ProviderAgentSessionServices 不负责:
- 保存对话消息;
- 执行 Agent Loop;
- 创建新 Session、恢复 Session 或 fork;
- 把宿主从旧 AgentSession 切换到新 AgentSession。
可以把它理解成“创建 AgentSession 所需的项目工作环境”。
5. AgentSessionRuntime
Section titled “5. AgentSessionRuntime”这里的 Runtime 也不是 Agent Run。它是宿主持有的“当前会话槽位”:
AgentSessionRuntime ├─ current services ├─ current AgentSession └─ createRuntime factory执行 /new、/resume、/fork 时,它会销毁旧 AgentSession,创建并替换为新 AgentSession,再通知 UI 重新绑定。
它回答的问题是:
宿主当前使用哪个 AgentSession?需要切换时,如何把整套旧环境安全替换掉?
例如当前位于项目 A:
AgentSessionRuntime ├─ services(A) └─ session(A1)用户恢复项目 B 的 Session 后:
旧 Extension session_shutdown → 清理旧 Extension UI → dispose session(A1) → 创建 services(B) → 创建 session(B1) → 替换 Runtime 的 current services/session → 通知宿主重新绑定 session(B1)最终成为:
AgentSessionRuntime ├─ services(B) └─ session(B1)最简区别是:
Services:创建 AgentSession 需要什么?Runtime:当前是哪个 AgentSession,怎么整体替换?| 问题 | AgentSessionServices |
AgentSessionRuntime |
|---|---|---|
| 核心关注点 | 一个 cwd 下的依赖一致性 | 当前 Session 的生命周期与替换 |
| 是否持有 AgentSession | 否 | 是 |
| 是否加载 Settings/Resources | 是 | 通过 factory 创建新的 Services |
| 是否处理 new/resume/fork | 否 | 是 |
| 是否通知 UI 重新绑定 | 否 | 是 |
| 是否执行 Agent Loop | 否 | 否 |
| 生命周期 | 一个 cwd 环境 | 整个宿主进程 |
生命周期对比
Section titled “生命周期对比”| 对象 | 典型生命周期 | 结束条件 |
|---|---|---|
agentLoop |
一次底层 Run 内部 | Loop 不再需要调用模型 |
Agent |
一个 AgentSession 内 | Session 被 dispose |
AgentSession |
一份当前 coding session | new/resume/fork/quit;reload 不替换它 |
AgentSessionServices |
一次构建出的 cwd 配置环境 | Runtime 整体替换 Session 与 Services |
AgentSessionRuntime |
一次宿主运行期 | CLI/RPC/SDK 宿主退出 |
三、为什么不能只使用 Agent
Section titled “三、为什么不能只使用 Agent”假设直接在 CLI 中调用:
await agent.prompt("修复这个 bug");仍有大量问题没有答案:
- 应该加载哪个项目的
AGENTS.md? - 默认启用哪些工具?
- Extension 注册的工具如何合并?
/foo是扩展命令、Skill、Prompt Template,还是普通消息?- 模型凭据是否可用?
- 每条消息何时写入 Session?
- Provider 临时失败后是否重试?
- 上下文溢出后如何压缩并重跑?
/resume到另一个项目后,旧资源何时失效?- UI 应该在
agent_end还是 retry 完成后恢复输入?
这些都不是通用 Agent Loop 必须回答的问题,而是 coding-agent 产品策略。AgentSession 正是这层策略的集中位置。
四、创建流程:先准备环境,再创建 AgentSession
Section titled “四、创建流程:先准备环境,再创建 AgentSession”4.1 两条创建入口
Section titled “4.1 两条创建入口”代码存在两个相关入口:
createAgentSessionServices() → 只创建 cwd-bound services
createAgentSessionFromServices() → 使用已创建的 services 创建 AgentSession源码位置:
createAgentSessionServices():agent-session-services.tscreateAgentSessionFromServices():agent-session-services.ts- 最终工厂
createAgentSession():sdk.ts
拆开创建的原因是:必须先知道目标 Session 的最终 cwd,才能加载属于那个项目的设置、资源和 Extension。
4.2 Services 创建链
Section titled “4.2 Services 创建链”createAgentSessionServices() 的逻辑可以压缩成:
解析 cwd 和 agentDir → 创建或复用 ModelRuntime → 创建 SettingsManager(cwd, agentDir) → 创建 DefaultResourceLoader(cwd, agentDir, settings) → resourceLoader.reload() → 把 Extension 注册的 Provider 应用到 ModelRuntime → 本地刷新模型目录 → 校验 Extension CLI flags → 返回 services + diagnostics输入:
- 目标项目 cwd;
- 全局 agentDir;
- 可选的预建 ModelRuntime、SettingsManager;
- ResourceLoader 配置和 Extension flags。
输出:
- 一组内部 cwd 一致的 Services。
副作用:
- 读取项目和全局配置;
- 加载 Extensions、Skills、Prompts、Themes、AGENTS 文件;
- 注册 Extension Provider。
它明确“不创建 AgentSession”,源码注释位于 agent-session-services.ts。
4.3 createAgentSession() 的完整装配
Section titled “4.3 createAgentSession() 的完整装配”sdk.ts:createAgentSession() 是对象装配中心:
flowchart TB
Input["cwd + services + SessionManager + options"]
Restore["读取已有 Session Context"]
Model["恢复/选择 Model 与 thinking level"]
Tools["计算初始工具名单"]
Agent["new Agent(...)"]
History["补齐缺失设置或写入初始配置"]
Session["new AgentSession(...)"]
Input --> Restore --> Model --> Tools --> Agent --> History --> Session第一步:确定基础依赖
Section titled “第一步:确定基础依赖”sdk.ts:
- 解析 cwd、agentDir;
- 创建或复用
ModelRuntime; - 创建或复用
SettingsManager、SessionManager; - 若没有传入 ResourceLoader,则创建并
reload()。
SDK 允许调用者注入这些依赖,是为了:
- 测试时使用 Faux Provider;
- 宿主复用统一的认证与模型注册表;
- Runtime 切换时确保所有对象绑定到新 cwd。
第二步:恢复模型和 thinking level
Section titled “第二步:恢复模型和 thinking level”sdk.ts:
SessionManager.buildSessionContext()重建当前分支上下文;- 有历史 Session 时优先恢复当时使用的模型;
- 恢复失败则使用 Settings 或 Provider 默认模型;
- thinking level 优先使用显式参数,其次 Session 历史,再其次 Settings;
- 最后根据当前模型能力执行
clampThinkingLevel()。
这里体现了一个优先级:
调用者显式选择 > Session 历史 > 当前项目/全局设置 > Provider 默认值第三步:计算初始工具
Section titled “第三步:计算初始工具”sdk.ts:
默认 active tools = read + bash + edit + write调用者可以:
- 用
tools建立 allowlist; - 用
excludeTools排除工具; - 用
noTools禁用默认工具。
此时只计算名字,真正的 ToolDefinition 构建和 Extension 合并发生在 AgentSession._buildRuntime()。
第四步:创建裸 Agent
Section titled “第四步:创建裸 Agent”sdk.ts 创建 Agent,初始状态是:
systemPrompt = ""(initialState 的便捷输入)messages = existingSession.messagesmodel = 恢复或选出的模型thinking = 恢复并校准后的级别tools = []tools 初始为空,后续由 AgentSession 装配;messages 已包含恢复的历史,因此只读 systemPrompt getter 可立即重放出已有系统状态,不能把空的初始化参数理解为恢复后 Prompt 必为空。
同时注入几个关键适配器:
| Agent 插槽 | coding-agent 注入的实现 |
|---|---|
convertToLlm |
把 coding-agent 自定义消息转为标准 LLM Message,并动态过滤图片 |
streamFn |
调用 ModelRuntime.streamSimple(),应用超时、Provider retry 和 headers |
onPayload |
触发 before_provider_request Extension hook |
onResponse |
触发 after_provider_response Extension hook |
transformContext |
触发 Extension context hook |
| queue modes | 从 Settings 读取 steering/follow-up 模式 |
convertToLlm 的基础转换在 messages.ts。阶段 5 会深入;本阶段只需知道:
AgentState 可以保存 coding-agent 自定义消息,但发送给模型前必须转成 Provider 能理解的标准消息。
第五步:补齐设置并创建 AgentSession
Section titled “第五步:补齐设置并创建 AgentSession”历史消息已经通过 initialState.messages 传给 Agent。后续代码为旧 Session 补缺失的 model/thinking metadata;新 Session 则记录初始配置。最后将 Agent、Managers、ResourceLoader、CacheWarmer 和工具配置交给 new AgentSession()。
五、构造函数做了什么
Section titled “五、构造函数做了什么”AgentSession 构造函数位于 agent-session.ts,完成下列关键接线:
1. agent.subscribe(_handleAgentEvent)2. _installAgentToolHooks()3. _installAgentNextTurnRefresh()4. _installAgentRequestProjection()(虚拟模型在这里路由)5. _installAgentBoundaryHooks()6. _installHiddenDeclarationsProjection()7. _installAgentForcedPromptProjection()8. _buildRuntime();未指定 initialActiveToolNames 时从 transcript 恢复工具5.1 订阅 Agent 事件
Section titled “5.1 订阅 Agent 事件”this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);它把底层 Agent 的事件接到产品层,以完成:
- Extension 事件转发;
- UI/RPC/SDK 事件转发;
- Session 持久化;
- retry/compaction 所需状态记录。
Agent 的 listener 是 awaited listener,因此 _handleAgentEvent() 完成前,底层 Loop 不会越过这个事件屏障。
5.2 安装工具 hooks
Section titled “5.2 安装工具 hooks”_installAgentToolHooks() 位于 agent-session.ts:
Agent.beforeToolCall → Extension tool_call → 可以阻止执行
Agent.afterToolCall → Extension tool_result → 可以替换 content/details/isError/usage/structuredContent它们位于工具流水线的两个检查点:
参数准备与 schema 校验 → beforeToolCall → tool.execute() → afterToolCall → 构造 ToolResultMessage → 下一轮模型调用beforeToolCall 能做什么
Section titled “beforeToolCall 能做什么”它收到:
- 产生调用的 AssistantMessage;
- Tool Call 名称和 ID;
- 已经过 schema 校验的参数;
- 当前 Agent Context。
它最重要的能力是阻止执行:
if (toolCall.name === "bash" && args.command.includes("rm -rf")) { return { block: true, reason: "禁止执行危险删除命令", };}阻止后:
工具 execute 不运行 → Loop 创建错误 ToolResult → 错误原因返回给模型 → 模型可以调整方案它也可以通过原地修改 Extension tool_call 事件的 input 改写参数,例如:
- 将本地 Bash 改为容器内执行;
- 规范文件路径;
- 给命令追加执行前缀;
- 把操作限制到允许的目录。
需要注意,schema 校验发生在 hook 之前,修改后的参数不会再走一遍同样的校验,因此 Extension 必须保证改写结果仍合法。
常见用途:
权限控制安全策略危险操作确认审计记录本地/容器/远程执行重定向如果 beforeToolCall 阻止或抛错,工具不会执行,也不会进入 afterToolCall。
afterToolCall 能做什么
Section titled “afterToolCall 能做什么”它收到:
- 实际执行的 Tool Call 和参数;
- 工具原始
result; - 当前
isError; - 当前 Agent Context。
它可以替换:
| 字段 | 用途 |
|---|---|
content |
替换最终发给模型的文本或图片 |
details |
替换供 UI/宿主使用的结构化详情 |
isError |
调整最终是否被视为错误 |
usage |
调整工具自身的用量信息 |
典型用途:
原始输出脱敏大输出截断和增加完整文件路径给结果补充审计元数据将某种可接受的非零退出码归一化为成功把业务校验失败的“成功返回”标记为错误如果 tool.execute() 抛错,Agent Loop 会先把异常转成错误 ToolResult,随后仍会调用 afterToolCall,所以 Extension 可以统一清洗错误信息。
但 afterToolCall 发生在工具执行之后,只能修改结果的表达,不能撤销已经发生的文件修改或 Shell 副作用。
最简记忆:
beforeToolCall:这个工具允不允许这样执行?afterToolCall:执行完成后,应该把什么结果交给模型?hooks 每个 Agent 只安装一次,但回调执行时读取当前 _extensionRunner。所以 reload 更换 ExtensionRunner 后,不需要重新安装 Agent hooks。
5.3 三个新边界如何协作
Section titled “5.3 三个新边界如何协作”以下缩写省略宿主既有 hooks 的组合;相关安装方法都在 agent-session.ts:
_installAgentBoundaryHooks → agent.finishTurn → Extension turn_end 草稿预览与提交 → 决定是否显式 continue(Core turn_end 事件之前)
_installAgentNextTurnRefresh → prepareNextTurnWithContext → 规范投影 → threshold compaction → Prompt sections patch → 返回 Context、增量 messages、model、thinking
_installAgentRequestProjection → prepareRequest → 每次请求前从 SessionManager.buildSessionProjection() 取消息 → 复制实际工具集合并组合此前的 prepareRequest
_installAgentForcedPromptProjection → transformContext → Extension context/context_with_system 之后临时应用强制 Prompt首轮不执行 prepareNextTurn,但会执行 prepareRequest。系统指令与工具声明现在放入 messages;tools 保留执行函数。变化通过消息事件进入 Session,不能直接赋值只读的 agent.state.systemPrompt。
六、工具注册与 System Prompt 是联动的
Section titled “六、工具注册与 System Prompt 是联动的”6.1 _buildRuntime()
Section titled “6.1 _buildRuntime()”位置:agent-session.ts。
它执行:
读取图片与 Shell 设置 → createAllToolDefinitions(cwd) → 读取 ResourceLoader 中的 Extensions → new ExtensionRunner(...) → 绑定 Extension API → _refreshToolRegistry(...)这里的 runtime 指 AgentSession 内部的 Extension/Tool runtime,不是一次 Agent Run。
6.2 _refreshToolRegistry()
Section titled “6.2 _refreshToolRegistry()”位置:agent-session.ts。
它把三类工具合并:
内置工具 + Extension 注册工具 + SDK custom tools三类工具的“能力接口”基本相同,主要区别是注册来源和生命周期。
Extension 注册工具
Section titled “Extension 注册工具”Extension 通过 pi.registerTool() 注册:
export default function (pi: ExtensionAPI) { pi.registerTool({ name: "query_ticket", label: "Query Ticket", description: "查询工单", parameters: Type.Object({ id: Type.String() }), execute: async (_id, params) => ({ content: [{ type: "text", text: `工单:${params.id}` }], details: {}, }), });}它的特点是:
- 由 ResourceLoader 从 Extension 文件或 Pi Package 中发现;
- 由 Extension Loader 加载 factory 并收集注册项,ExtensionRunner 负责分发和运行时动作;
- 可以和 hooks、commands、UI、Provider 注册一起组成一个插件;
/reload时可以重新加载;- 来源信息指向具体 Extension;
- 适合安装和共享的工作流功能。
SDK custom tools
Section titled “SDK custom tools”嵌入 Pi 的宿主程序通过 createAgentSession() 直接传入:
const { session } = await createAgentSession({ customTools: [ { name: "query_order", label: "Query Order", description: "查询宿主业务系统中的订单", parameters: Type.Object({ orderId: Type.String() }), execute: async (_id, params) => ({ content: [{ type: "text", text: await orderService.query(params.orderId) }], details: {}, }), }, ],});它的特点是:
- 由宿主在创建 AgentSession 时直接注入;
- 不需要单独的 Extension 文件;
- 可以直接闭包引用宿主已有的 Service、数据库连接或测试状态;
- 来源标记为
<sdk:工具名>,scope 为 temporary; - 适合嵌入式应用和测试,而不是一个独立安装的插件。
两者最终都会被包装成 AgentTool,进入相同 Tool Loop,也都会经过 beforeToolCall 和 afterToolCall。
同名覆盖顺序
Section titled “同名覆盖顺序”源码先把内置定义放入 Map,再按以下顺序写入:
allCustomTools = [ ...registeredExtensionTools, ...sdkCustomTools,];Map.set(name, tool) 遇到同名键时后写覆盖先写,因此准确优先级是:
SDK custom tool > Extension tool > 内置 tool例如三者都叫 read:
内置 read → 读取本地文件Extension read → 从沙箱读取SDK read → 从宿主虚拟文件系统读取模型最终调用的是 SDK read。Definition Registry 和实际执行用的 Tool Registry 都采用这个覆盖顺序,不只是 UI 元数据被覆盖。
allowlist/exclude list 按工具名过滤:
excludeTools: ["read"]会排除最终的 read,不能只排除内置版本而保留同名 SDK 版本。
合并规则:
- 先应用 allowlist 和 exclude list;
- 内置定义先进入 registry;
- Extension 覆盖同名内置工具,SDK custom tool 再覆盖同名 Extension 工具;
- 提取每个工具的
promptSnippet和promptGuidelines; - 用 ExtensionRunner 包装实际执行;
- 计算 active tool names;
- 调用
setActiveToolsByName()。
6.3 为什么切换工具还要重建 Prompt
Section titled “6.3 为什么切换工具还要重建 Prompt”setActiveToolsByName() 位于 agent-session.ts:
名字 → AgentTool[] → agent.state.tools → _rebuildSystemPrompt(validToolNames)工具有两个面对模型的部分:
结构化 Tool Schema 告诉模型:工具名、描述、参数结构
System Prompt 中的工具说明 告诉模型:何时使用、遵守什么工程规则若只更新 schema、不更新 Prompt,模型可能继续遵循已经禁用工具的说明;反过来也会出现 Prompt 提到某工具但请求中没有其 schema。
6.4 _rebuildSystemPrompt()
Section titled “6.4 _rebuildSystemPrompt()”位置:agent-session.ts。
输入:
- 当前 active tool names;
- 每个工具的 Prompt snippet/guidelines;
- ResourceLoader 加载的 System Prompt;
- append System Prompt;
- Skills;
- AGENTS/context files;
- cwd。
输出:
_baseSystemPromptOptions,规范化后的结构化选项。
buildSystemPromptSections() 生成可独立替换的 sections,_preparePromptAndToolLoadout() 对历史重放状态计算差量;buildSystemPrompt() 可将选项渲染为字符串。阶段 5 会逐段拆解其内容。
七、prompt():产品层的请求入口
Section titled “七、prompt():产品层的请求入口”主入口:agent-session.ts。
先看全流程:
flowchart TB
Input["prompt(text, options)"]
Command{"Extension command?"}
Hook["Extension input hook"]
Expand["展开 Skill / Prompt Template"]
Streaming{"Session 正在运行?"}
Queue["加入 steering / follow-up"]
Validate["flush bash + 检查 Model/Auth"]
Compact["发送前检查旧上下文是否需压缩"]
Messages["按最终模型归一化图片,构造 User + custom + system"]
Before["before_agent_start<br/>注入消息/覆盖 System Prompt"]
Run["_runAgentPrompt(messages)"]
Input --> Command
Command -->|是| HookDone["立即执行命令并返回"]
Command -->|否| Hook
Hook --> Expand --> Streaming
Streaming -->|是| Queue
Streaming -->|否| Validate --> Compact --> Before --> Messages --> Run7.1 Extension command 优先
Section titled “7.1 Extension command 优先”agent-session.ts:
输入以 / 开头时,先检查 Extension command。
若命令被处理:
- 不构造 user message;
- 不调用 Provider;
- 命令自己决定是否通过 Extension API 发送额外消息。
因此 /foo 不能一开始就无条件作为 Prompt Template 展开。
7.2 input hook 在文本展开前执行
Section titled “7.2 input hook 在文本展开前执行”agent-session.ts:
input hook 是 Extension 对用户输入的前置拦截器:
用户输入 → Extension command 检查 → input hook → Skill / Prompt Template 展开 → 构造 UserMessage → Agent Loop它用于在输入成为模型消息之前进行检查、改写或接管。
它位于 Skill/Template 展开之前,所以扩展看到的是用户原始输入。
源码中的 InputEventResult 有三种 action:
type InputEventResult = | { action: "continue" } | { action: "transform"; text: string; images?: ImageContent[] } | { action: "handled" };continue
Section titled “continue”不修改输入,继续原流程:
return { action: "continue" };handler 也可以直接返回 undefined。当所有 handlers 都没有修改输入时,ExtensionRunner.emitInput() 最终会归一化为:
{ action: "continue" }AgentSession.prompt() 没有单独写 continue 分支,是因为它不需要修改任何状态,代码自然向下执行。
transform
Section titled “transform”修改文本或图片,但仍交给当前 Agent:
return { action: "transform", text: `${event.text}\n\n要求:先添加回归测试,再修改实现。`, images: event.images,};适合:
- 给任务补充团队规则;
- 实现
@review等自然语言别名; - 注入外部 Issue 信息;
- 脱敏或规范化输入;
- 修改随输入提交的图片。
多个 Extension 的 transform 会按加载顺序串联:后一个 handler 看到前一个 handler 已经转换后的文本和图片。
handled
Section titled “handled”Extension 已经完成或完全接管输入,不再构造 UserMessage,也不调用当前 Agent:
if (event.text === "打开任务面板") { ctx.ui.setWidget(...); return { action: "handled" };}适合:
- 打开 Extension UI;
- 执行不需要模型的确定性操作;
- 把请求转交给另一个 Agent 或外部服务;
- 阻止不允许发送的输入。
handled 会短路后续 handlers 和整个 Prompt 流程。如果 Extension 返回 handled 却没有自己产生任何结果,用户输入就会被“吃掉”。
选择规则:
不干预 → continue 或 undefined加工输入后仍让 Pi Agent 做 → transformExtension 自己完成/接管 → handled它与另外两个 hook 的边界是:
input hook 决定用户输入是否以及如何进入 Agent
before_agent_start 在 Agent 启动前追加 custom messages 或临时覆盖 System Prompt
beforeToolCall / afterToolCall 处理模型已经生成的工具调用及其结果7.3 展开 Skill 和 Prompt Template
Section titled “7.3 展开 Skill 和 Prompt Template”agent-session.ts:
原始或转换后的文本 → _expandSkillCommand() → expandPromptTemplate()Skill 并不是在这里运行一段独立 Agent Loop,而是把命令展开为提供给当前 Agent 的文本上下文。阶段 7 会深入加载机制。
7.4 Session 忙时不启动第二个并发 Run
Section titled “7.4 Session 忙时不启动第二个并发 Run”agent-session.ts:
若 this.isStreaming 为真,必须指定:
streamingBehavior = steer | followUp消息会进入已有 Run 的队列,而不是再次调用 agent.prompt()。
这与阶段 2 的 activeRun 对应:
同一个 Agent: 不能同时有两个 Run
两个独立 AgentSession: 各自拥有 Agent,可以各跑自己的 Run7.5 发送前检查
Section titled “7.5 发送前检查”agent-session.ts:
- 把此前延迟的 BashExecutionMessage 写回;
- 检查是否已选择模型;
- 检查 Provider 认证;
- 检查上一次被 abort 或遗留的 assistant message 是否已经需要 compaction。
这里“把此前延迟的 BashExecutionMessage 写回”需要展开理解。
用户直接执行:
!git status走的是 AgentSession.executeBash(),不是模型调用 bash Tool。命令完成后会产生:
{ role: "bashExecution", command: "git status", output: "...", exitCode: 0, timestamp: ...}正常情况下,它会:
push 到 agent.state.messages + SessionManager.appendMessage()但如果 AgentSession 正处于产品级运行中,立即插入可能把协议消息变成:
AssistantMessage(toolCall: edit)BashExecutionMessage(!git status)ToolResult(edit)破坏 Provider 要求的 Tool Call/Tool Result 对应顺序。因此此时只把消息暂存在:
_pendingBashMessages延迟的不是 Bash 执行,也不是终端输出:
命令执行 → 立即输出展示 → 立即加入 AgentState → 延迟到安全时机写入 Session → 延迟到安全时机主要安全时机是 _runAgentPrompt() 的 finally:
所有底层 Run 和自动续跑结束 → _flushPendingBashMessages() → agent_settledprompt() 在新请求开始前再次 flush,是防御性保证:构造新的 UserMessage 前,确保此前任何遗留 Bash 结果已经进入 Context 和 Session。写回后,下一次模型调用才能看到用户执行过的命令及其输出。
发送前 compaction 不调用 agent.continue(),因为新的用户消息马上会通过本次 agent.prompt(messages) 启动 Run。
7.6 hook 之后构造本次初始消息
Section titled “7.6 hook 之后构造本次初始消息”agent-session.ts:
before_agent_start(详见 7.7) → 按 hook 选定模型的图片限制归一化图片messages = [SystemMessage patch(有变化时)] + [UserMessage(text + normalized images)] + pendingNextTurnMessages + handler 返回的 CustomMessagespendingNextTurnMessages 是 Extension 提前排入“下一次用户请求”的上下文补充,不等于 steering/follow-up:
| 类型 | 何时消费 |
|---|---|
| steering | 当前 Run 的 Turn 边界 |
| follow-up | 当前任务自然结束后 |
| nextTurn custom message | 下一次显式 AgentSession.prompt() |
7.7 before_agent_start
Section titled “7.7 before_agent_start”agent-session.ts:
Extension 可以:
- 向本次输入追加 custom messages;
- 仅为本次产品级请求覆盖 System Prompt。
handler 可直接修改 event.systemPromptOptions,普通选项变化经 SystemMessage 持久化;返回 systemPrompt 字符串则写入 _runSystemPromptOptions.forceSystemPrompt,仅在请求投影中强制替换正文。整个编排结束时清除 _runSystemPromptOptions,不覆盖已保存的系统状态。
7.8 preflightResult 不再是 boolean
Section titled “7.8 preflightResult 不再是 boolean”回调现在只通知接受后的分发结果,拒绝时不调用,直接抛错:
type PromptDisposition = "handled" | "queued" | "started";// command / input 完全消费 → handled// 忙时成功排队 → queued// 前置处理完成、进入 _runAgentPrompt 前 → started这让 RPC 等宿主区分“被扩展处理”“入队”和“开始运行”,但三者都不是模型任务成功的证明。若在 agent_settled handler 中再次 prompt,当前实现先加入 deferred actions,等 settled 事件分发完再执行。
八、一次 AgentSession.prompt() 可能包含多个 Agent Run
Section titled “八、一次 AgentSession.prompt() 可能包含多个 Agent Run”核心流程缩写如下,省略取消重试的收尾细节:
this._isAgentRunActive = true;try { await this.agent.prompt(messages); while (!this._agentRunAbortRequested) { if (await this._handlePostAgentRun()) { if (this._agentRunAbortRequested) break; await this.agent.continue(); continue; } if (this._agentRunAbortRequested || !(await this._runBeforeSettleBoundary())) break; if (this._agentRunAbortRequested) break; await this.agent.continue(); }} finally { this._runSystemPromptOptions = undefined; this._flushPendingBashMessages(); this._flushPendingCustomMessages(); await this._emitAgentSettled();}agent_before_settle 是新增的 Extension 边界:普通 retry/compaction/队列续跑均处理后,它还可以提交上下文草稿并申请继续。只有能形成可继续的模型上下文才接受继续决定;最后才发出 agent_settled。
更准确的命名心智模型是:
一次产品级 Prompt Operation ├─ Run 1:正常 Agent Loop ├─ Run 2:可选的 retry/overflow recovery/延迟队列续跑 ├─ Run 3:仍有需要时继续 └─ Settled:产品层确认不会再自动续跑为什么要用 agent.continue(),而不是再次 agent.prompt()
Section titled “为什么要用 agent.continue(),而不是再次 agent.prompt()”prompt() 表示加入新的初始用户输入。
continue() 表示:
基于当前 AgentState 中已有消息或队列继续运行,不重复加入原用户消息。
重试时尤其重要。若再次 prompt(originalUserMessage),Session 会出现重复用户输入。
_handlePostAgentRun() 的决策顺序
Section titled “_handlePostAgentRun() 的决策顺序”每次底层 agent_end 之后:
取出最后一条 AssistantMessage → 是可重试错误? 准备 retry,返回 true → retry 已耗尽? 发出最终失败事件 → 需要 compaction? 执行 compaction;需要续跑则返回 true → agent_end Extension 又排入消息? hasQueuedMessages() 为真 → 否则返回 false返回 true 就会调用一次 agent.continue()。
九、agent_end 不等于整个请求完成
Section titled “九、agent_end 不等于整个请求完成”这是本阶段最重要的事件边界。
9.1 agent_end
Section titled “9.1 agent_end”含义:
一次底层 Agent Run 已结束。
此时可能仍要:
- 等待指数退避后 retry;
- compact 后重试 overflow 请求;
- 处理
agent_endExtension 新加入的队列消息。
AgentSession 转发 agent_end 时会增加 willRetry:
agent_end { ..., willRetry: true | false }判断逻辑在 agent-session.ts。
9.2 agent_settled
Section titled “9.2 agent_settled”含义:
AgentSession 已完成所有自动续跑逻辑,现在才真正稳定空闲。
_emitAgentSettled() 位于 agent-session.ts:
_isAgentRunActive = false → await Extension agent_settled → emit AgentSession agent_settled → resolve waitForIdle()因此 UI 是否重新视为“任务彻底完成”,应该看 agent_settled,而不是第一个 agent_end。
9.3 两种 isStreaming
Section titled “9.3 两种 isStreaming”还要区分:
agent.state.isStreaming = 当前底层 Agent Run 是否正在 Loop 中
agentSession.isStreaming = 整个产品级 Prompt Operation 是否尚未 settled在 retry backoff 或 compaction 期间,底层 Run 已结束,但 AgentSession 仍然忙。
十、事件桥接与持久化
Section titled “十、事件桥接与持久化”事件桥接涉及两个连续的处理层:
Agent Loop → Agent.processEvents() 先更新 AgentState 再 await Agent listeners → AgentSession._handleAgentEvent() await Extension hooks 通知 UI/RPC/SDK listeners 写入 SessionManager关键位置:
- Loop 将事件交给 Agent:
packages/agent/src/agent.ts - Agent 归约状态并等待 listeners:
packages/agent/src/agent.ts - AgentSession 订阅 Agent:
agent-session.ts - AgentSession 处理事件:
agent-session.ts - Extension 事件映射:
agent-session.ts
10.1 为什么需要“桥接”
Section titled “10.1 为什么需要“桥接””一次运行中存在三份用途不同的状态:
Loop currentContext = 当前模型:工具循环正在使用的工作上下文
AgentState = Agent 对外暴露的内存状态,UI 和宿主可以读取
SessionManager = JSONL Session Tree 中可恢复、可分支的持久化历史模型流式生成内容时,三者不能在每个 token 到达时都写入最终历史:
currentContext需要不断更新正在生成的 AssistantMessage;AgentState.streamingMessage需要让 UI 显示流式内容;- SessionManager 应只保存最终消息,不能每个 token 写一条 JSONL entry。
所以事件管线承担的是:
把 Loop 中不断变化的临时状态,按生命周期节点同步到 AgentState、Extensions、宿主和持久化历史。
其中最重要的提交点是 message_end。
10.2 第一层:Agent 先更新内存状态
Section titled “10.2 第一层:Agent 先更新内存状态”Loop 调用的 emit(event) 实际指向:
(event) => this.processEvents(event)Agent.processEvents() 先根据事件更新 AgentState:
| 事件 | AgentState 变化 |
|---|---|
message_start |
设置 streamingMessage |
message_update |
更新 streamingMessage |
message_end |
清空 streamingMessage,把最终消息 push 到 messages |
tool_execution_start |
把 Tool Call ID 加入 pendingToolCalls |
tool_execution_end |
从 pendingToolCalls 删除 ID |
turn_end |
记录最终错误信息 |
agent_end |
清空残留的 streamingMessage |
然后才按订阅顺序逐个等待 Agent listeners:
for (const listener of this.listeners) { await listener(event, signal);}这意味着 AgentSession 收到 message_end 时:
AgentState.messages 中已经有这条最终消息AgentState.streamingMessage 已经清空为什么先更新 AgentState?
因为 listener 是事件发生后的观察者。Extension 或 AgentSession 处理 message_end 时,如果读取 agent.state.messages,应该看到与事件一致的最新内存状态,而不是旧状态。
10.3 第二层:AgentSession 再完成产品处理
Section titled “10.3 第二层:AgentSession 再完成产品处理”AgentSession 构造时执行:
this.agent.subscribe(this._handleAgentEvent);因此 _handleAgentEvent() 是一个 Agent listener,并且会被底层 Agent await。
它内部的准确顺序是:
1. 必要时更新产品级 queue 展示状态2. await Extension 对应事件3. 同步通知 AgentSession 外部 listeners4. message_end 时写入 SessionManager5. AssistantMessage 时更新 retry/compaction 辅助状态6. handler 返回7. Agent.processEvents() 返回8. Agent Loop 才能发出下一个事件或执行工具下面逐步解释为什么这样排。
10.4 第一步:先更新 steering/follow-up 展示队列
Section titled “10.4 第一步:先更新 steering/follow-up 展示队列”这个分支只针对:
message_start + UserMessageAgent 内部 steering/follow-up 队列在 Loop 取出消息时已经消费,但 AgentSession 还维护了两份用于 UI 展示的文本数组:
_steeringMessages_followUpMessages当排队的 UserMessage 真正开始处理时,AgentSession 先从展示数组中移除它,再发出 queue_update。
这样随后收到 message_start 的 UI 会看到:
这条消息已经进入本轮处理而不是仍显示在“等待队列”中这是产品展示状态,不是 Agent Core 队列本身。
10.5 第二步:为什么 Extension 必须最先处理最终消息
Section titled “10.5 第二步:为什么 Extension 必须最先处理最终消息”AgentSession 接着执行:
await this._emitExtensionEvent(event);对于 message_end,Extension 可以返回一条替换消息:
原始 AssistantMessage → Extension message_end → 可选 replacement常见用途包括:
- 对最终输出脱敏;
- 给 custom message 补充显示信息;
- 规范不完整的消息内容;
- 修改最终交给宿主和 Session 的消息。
如果先持久化、后执行 Extension:
JSONL 保存原消息AgentState/界面看到修改后消息恢复 Session 后就会与当时运行时看到的内容不一致。
所以 Extension 必须位于外部通知和持久化之前。
10.6 为什么替换消息必须“原地修改”
Section titled “10.6 为什么替换消息必须“原地修改””这里有一个不明显的引用关系。
在 AgentSession 收到 message_end 前,Agent.processEvents() 已经执行:
this._state.messages.push(event.message);所以此时:
AgentState.messages 最后一项event.messageLoop 中的最终 message都指向同一个消息对象。
如果只写:
event.message = replacement;只会改变局部事件引用,AgentState 仍保存旧对象。
_replaceMessageInPlace() 因此删除旧对象字段,再把 replacement 的字段复制进同一个对象:
保持对象引用不变 + 对象内容变成 replacement这样以下位置同时看到新内容:
Loop 后续检查的 AssistantMessageAgentState.messages后续 turn_end / agent_endAgentSession listenersSessionManager 持久化这就是为什么代码不使用普通数组替换。
10.7 第三步:通知 AgentSession 外部 listeners
Section titled “10.7 第三步:通知 AgentSession 外部 listeners”Extension 完成后:
this._emit(event);把事件交给 Interactive、Print、JSON、RPC 或 SDK listener。
这里要注意:
Agent.subscribe(listener) 支持 Promise Agent 会 await
AgentSession.subscribe(listener) 是同步 void listener AgentSession 不会 await listener 返回的异步任务因此关键的 Extension 处理和持久化不能交给外部 listener 完成。
当前实现中,外部 listener 通知发生在 SessionManager.appendMessage() 之前,而且 _emit() 只是同步广播,不会等待 listener 里自己启动的异步任务。
这意味着:
收到 AgentSession
message_end时,可以读取事件中的最终消息,但不能假设这条消息已经写入 SessionManager。
如果逻辑必须依赖已持久化的当前消息,应放到整个 awaited 事件边界之后,而不是在外部 message_end listener 中查询 JSONL。
10.8 第四步:为什么只在 message_end 持久化
Section titled “10.8 第四步:为什么只在 message_end 持久化”流式 AssistantMessage 会经历:
message_start → message_update → message_update → ... → message_end前三类事件服务于实时展示,只有 message_end 表示消息已经定型。
若每次 message_update 都写 Session:
- 同一 AssistantMessage 会产生大量重复 JSONL entries;
- 中途 abort 时难以判断哪一条是最终状态;
- 恢复 Session 时还要重新拼接 token delta;
- Extension 最终替换消息后,之前写入的部分内容已经无法保持一致。
所以持久化规则是:
流式状态保存在 AgentState.streamingMessage最终状态在 message_end 一次性写入 SessionManagermessage_end 时:
custom→appendCustomMessageEntry();system/user/assistant/toolResult→appendMessage();bashExecution、compactionSummary、branchSummary→ 各自专用流程保存。
10.9 第五步:记录 retry/compaction 所需状态
Section titled “10.9 第五步:记录 retry/compaction 所需状态”如果最终消息是 AssistantMessage,AgentSession 还会:
_lastAssistantMessage = event.message它不是重复保存消息,而是给 agent_end 后的 _handlePostAgentRun() 留一个判断入口:
最后响应是否是临时错误?是否发生 Context overflow?usage 是否达到 compaction threshold?成功 AssistantMessage 还会:
- 清除 overflow recovery 标记;
- 重置 retry attempt;
- 发出
auto_retry_end(success: true)。
这些动作放在持久化之后,保证即使随后进入 retry 或 compaction,刚完成的 AssistantMessage 已经留在完整 Session 历史中。
10.10 一条带 Tool Call 的完整时序
Section titled “10.10 一条带 Tool Call 的完整时序”假设模型返回:
AssistantMessage └─ toolCall: read("src/app.ts")准确时序是:
sequenceDiagram
participant L as Agent Loop
participant A as Agent.processEvents
participant E as ExtensionRunner
participant U as UI/RPC/SDK
participant S as SessionManager
participant T as Tool Pipeline
L->>A: message_end(Assistant toolCall)
A->>A: push 到 AgentState.messages
A->>E: await message_end
E-->>A: 可选 replacement
A->>U: 同步通知最终 AssistantMessage
A->>S: appendMessage(AssistantMessage)
S-->>A: append 完成
A-->>L: message_end 处理完成
L->>T: 准备并执行 Tool Call
T->>A: tool_execution_start/update/end
T->>A: message_end(ToolResult)
A->>A: push ToolResult 到 AgentState
A->>E: await ToolResult message_end
A->>U: 同步通知 ToolResult
A->>S: appendMessage(ToolResult)
A-->>L: ToolResult 处理完成
L->>L: 带 ToolResult 开始下一轮模型调用最终 JSONL 顺序稳定为:
UserMessageAssistantMessage(toolCall)ToolResultMessage下一条 AssistantMessageTool Call 的 beforeToolCall 发生在 AssistantMessage 的整个 message_end 事件处理完成之后。因此 tool_call Extension 查询 SessionManager 时,产生该调用的 AssistantMessage 已经持久化。
并行工具也不会打破这个约束:工具可以并发执行,但 ToolResultMessage 仍按 Assistant Tool Call 源顺序发出并逐条经过 awaited message_end。
10.11 如果没有 await,会出现什么问题
Section titled “10.11 如果没有 await,会出现什么问题”这里的“awaited Agent listener”指的是:Agent 处理完事件、更新自己的 AgentState 后,会等待订阅者返回的 Promise;AgentSession._handleAgentEvent() 在这个 Promise 中先完成 Extension 事件处理,再执行 SessionManager.appendMessage()。appendMessage() 本身是同步调用,但它必须处在这条被等待的事件链中,后续 Tool Call 才能等到它完成后再开始。
持久化的关键不是“另起一个异步保存任务”,而是保持事件顺序:先把 AssistantMessage 写入 Session,再允许由它产生的 Tool Call 继续执行。
假设 Assistant message_end Extension 需要异步处理 20ms:
正确:Assistant message_end → 等待 Extension 20ms → 保存 Assistant → 执行工具 → 保存 ToolResult
错误的 fire-and-forget:Assistant message_end → Extension 尚未完成 → 工具立即执行 → ToolResult 先保存 → Assistant 后保存错误历史可能变成:
UserMessageToolResultMessageAssistantMessage(toolCall)这不仅影响展示,也会破坏 Provider 对 Tool Call/Tool Result 的协议要求,导致恢复后的 Session 无法继续调用模型。注意,问题不在于 Extension 是否使用了异步代码,而在于宿主是否等待这段异步处理完成后再推进下一步。
仓库中的回归测试:
packages/coding-agent/test/suite/regressions/1717-2113-agent-session-event-settlement.test.ts专门验证:
- 即使 Assistant
message_endhandler 主动await,持久化顺序仍是 assistant 在 toolResult 之前; tool_callhandler 执行时,Session 中已经存在产生该调用的 AssistantMessage。
10.12 这套顺序的核心不变量
Section titled “10.12 这套顺序的核心不变量”可以把整个事件桥接浓缩成四条不变量:
1. listener 观察事件时,AgentState 已经反映该事件2. Extension 修改最终消息后,宿主和 Session 才能看到它3. Tool Call 开始前,产生它的 AssistantMessage 已完成持久化4. 下一轮模型调用前,所有 ToolResult 已完成持久化持久化不直接属于通用 Agent,是因为:
- 有的 Agent 宿主不需要持久化;
- coding-agent 使用 Session Tree,而不是简单数组;
- compaction、branch、model change 都有产品级 entry;
- 同一个 Agent Core 可以搭配 JSONL、Memory 或其他存储。
十一、Retry:底层 Run 失败,产品请求继续
Section titled “十一、Retry:底层 Run 失败,产品请求继续”入口:
_isRetryableError():agent-session.ts_prepareRetry():agent-session.ts
11.1 哪些错误进入 retry
Section titled “11.1 哪些错误进入 retry”典型是:
- rate limit;
- Provider overloaded;
- 网络或服务器临时错误。
Context overflow 不进入普通 retry,因为重复同一上下文只会再次失败;它交给 compaction recovery。
11.2 _prepareRetry() 做什么
Section titled “11.2 _prepareRetry() 做什么”检查 retry setting → attempt + 1 → 超过 maxRetries 则停止 → delay = retryDelayMs(settings, attempt),遵守 maxAgentDelayMs 上限 → emit auto_retry_start → _omitRecoveryAttempt(message) 追加 context_edit(targetId, null) → _refreshFinalizedContext() 重建投影,Session 保留失败事实和排除决定 → 可取消地等待 backoff → 返回 true返回 true 后 _runAgentPrompt() 调用 agent.continue()。
11.3 为什么投影排除,原始 Session 记录却保留
Section titled “11.3 为什么投影排除,原始 Session 记录却保留”两者用途不同:
AgentState = 下一次发给模型的工作上下文 不应把 Provider 错误响应再喂回模型
Session JSONL = 完整运行历史与诊断记录 应保留失败事实排除决定也通过 context_edit 保存在 Session,重启与压缩重建后仍能应用。原记录用于审计,工作投影用于下一次请求。
11.4 取消 retry
Section titled “11.4 取消 retry”backoff 使用独立 AbortController。
取消后:
- 发出
auto_retry_end(success: false); - 不再
continue(); - 最终仍进入
_emitAgentSettled()。
十二、Compaction:改变上下文后再决定是否续跑
Section titled “十二、Compaction:改变上下文后再决定是否续跑”入口:
_checkCompaction():agent-session.ts_runAutoCompaction():agent-session.ts
阶段 6 会深入摘要结构。本阶段掌握编排位置即可。
12.1 两种触发
Section titled “12.1 两种触发”Overflow Provider 已返回上下文溢出,属于恢复路径
Threshold Context 接近上限,属于预防路径12.2 Overflow
Section titled “12.2 Overflow”若模型返回 overflow error:
失败 AssistantMessage 已持久化 → 追加 context_edit 排除失败 Assistant 及本次 ToolResults → 从规范投影准备 compaction → 用压缩后的上下文 agent.continue()同一恢复只尝试一次,避免:
overflow → compact → overflow → compact → 无限循环若模型已经成功回答,只是 usage 超过配置窗口:
- 可以压缩,为下次 Prompt 做准备;
- 但不能自动 retry,因为本次答案已经完成。
12.3 Threshold
Section titled “12.3 Threshold”达到阈值时执行压缩,通常不重跑刚完成的成功回答。
若压缩后存在 queued messages,则仍可能继续。
12.4 为什么 compaction 不在 Agent Loop 内
Section titled “12.4 为什么 compaction 不在 Agent Loop 内”因为它依赖:
- 用户 Settings;
- Session Tree 和 compaction entry;
- 摘要模型与认证;
- overflow 恢复次数;
- UI compaction events。
这些都是产品级策略,而不是所有 Agent 都必须采用的通用机制。
十三、Steering、Follow-up 和 agent_end 后入队
Section titled “十三、Steering、Follow-up 和 agent_end 后入队”底层 Agent Loop 会在 Run 结束前正常排空当时已有的 steering/follow-up。
但 Extension 的 agent_end handler 自身也可能再加入消息。此时:
- 底层 Loop 已经决定结束;
agent_end事件进入 AgentSession;- Extension handler 加入新消息;
_handlePostAgentRun()检查agent.hasQueuedMessages();- 返回
true; - AgentSession 调用
agent.continue()。
这说明 AgentSession 的外层循环不是重复 Agent Loop,而是处理:
只有在一次 Run 完结之后才能知道的产品级续跑条件。
十四、为什么 Shell 命令还有一条独立路径
Section titled “十四、为什么 Shell 命令还有一条独立路径”AgentSession.executeBash() 位于 agent-session.ts。
它处理用户直接输入的 !command 类命令,不等于模型调用 bash Tool:
| 路径 | 发起者 | 是否经过 Tool Call |
|---|---|---|
| bash Tool | 模型 | 是 |
AgentSession.executeBash() |
用户或 Extension | 否 |
执行结果形成 BashExecutionMessage,可进入 Agent Context 和 Session。
为什么 streaming 时延迟写入
Section titled “为什么 streaming 时延迟写入”recordBashResult() 的关键分支位于 agent-session.ts:
若 AgentSession 正在运行 → 放入 _pendingBashMessages
若已空闲 → 立即写 AgentState 和 Session原因是模型 Tool Call 与 ToolResult 必须保持协议顺序。若在中间插入一个独立 BashExecutionMessage,某些 Provider 会认为 tool_use 没有紧邻对应 tool_result。
最终 _runAgentPrompt() 的 finally 会 flush 这些消息,即使运行失败或被取消也不会遗失。这里延迟的只是加入 AgentState 和 Session,Bash 命令及其流式输出仍然立即执行和展示。
十五、Session 切换为什么由 AgentSessionRuntime 管理
Section titled “十五、Session 切换为什么由 AgentSessionRuntime 管理”AgentSessionRuntime 位于 agent-session-runtime.ts。
它负责:
switchSession();newSession();fork();importFromJsonl();dispose()。
15.1 标准替换流程
Section titled “15.1 标准替换流程”以 switchSession() 为例,位置 agent-session-runtime.ts:
flowchart TB
Before["session_before_switch<br/>Extension 可取消"]
Open["打开目标 SessionManager<br/>校验 cwd"]
Shutdown["旧 Extension session_shutdown"]
UI["同步清理旧 Extension UI"]
Dispose["dispose 旧 AgentSession"]
Create["按目标 cwd createRuntime"]
Apply["替换 services + session"]
Rebind["宿主 UI 重新绑定"]
With["withSession 使用新鲜 context"]
Before --> Open --> Shutdown --> UI --> Dispose --> Create --> Apply --> Rebind --> With通用 teardown 位于 agent-session-runtime.ts:
await session_shutdown → beforeSessionInvalidate() → session.dispose()15.2 为什么不能只换 SessionManager
Section titled “15.2 为什么不能只换 SessionManager”一个 Session 可能属于另一个 cwd,而以下资源都绑定 cwd:
.pi/settings.json;.pi/extensions;.pi/skills;.pi/prompts;- 项目
AGENTS.md; - Project Trust;
- 默认工具的工作目录。
只替换 SessionManager 会产生混合状态:
历史属于项目 B工具仍在项目 A 执行Prompt 仍加载项目 A 的 AGENTS.mdExtension 仍来自项目 A所以必须通过保存的 factory 重建完整 Services 和 AgentSession。
15.3 为什么旧 Extension context 会变 stale
Section titled “15.3 为什么旧 Extension context 会变 stale”Session 替换或 reload 后:
- 旧 AgentSession 已 dispose;
- 旧 ExtensionRunner 已失效;
- 旧 context 里捕获的
pi、session 控制器和 UI binding 都指向旧对象。
因此替换后的操作要放入 withSession(ctx),使用 Runtime 提供的新 context。
15.4 Runtime 自己为什么长期存在
Section titled “15.4 Runtime 自己为什么长期存在”宿主 UI/RPC 需要一个稳定引用。
如果宿主直接持有 AgentSession,那么每次 /resume 后所有持有者都必须自己寻找新对象。AgentSessionRuntime 提供稳定外壳,内部替换当前 Session,再通过 rebindSession 集中通知宿主。
十六、dispose() 和 reload() 的边界
Section titled “十六、dispose() 和 reload() 的边界”dispose()
Section titled “dispose()”agent-session.ts:
- abort 当前运行;
- 取消 Agent 订阅;
- 清空 AgentSession 的外部事件 listeners;
- 使旧 Extension context 失效。
它用于 Session 生命周期结束。
reload()
Section titled “reload()”agent-session.ts:
旧 Extension session_shutdown(reload) → Settings reload → 同步队列模式 → reset Provider → ResourceLoader reload → 重建 ExtensionRunner 与 Tool Registry → session_start(reload) → 让 Extension 扩展资源reload 保留:
- 当前 SessionManager;
- 当前 Agent 消息历史;
- 当前 active tool names(在新 registry 中仍有效的部分)。
它替换:
- Settings 快照;
- Resources;
- ExtensionRunner;
- Tool definitions;
- System Prompt。
十七、完整时序:一次普通请求
Section titled “十七、完整时序:一次普通请求”sequenceDiagram
participant U as User/Mode
participant S as AgentSession
participant E as ExtensionRunner
participant A as Agent
participant L as AgentLoop
participant SM as SessionManager
U->>S: prompt(text)
S->>E: input
S->>S: expand skill/template
S->>S: validate model/auth
S->>E: before_agent_start
S->>A: prompt(messages)
A->>L: start run
L-->>A: message/turn/tool events
A->>S: await _handleAgentEvent(event)
S->>E: await extension event
S-->>U: session event
S->>SM: persist message_end
L-->>A: agent_end
A->>S: agent_end
S->>S: _handlePostAgentRun()
S->>E: agent_settled
S-->>U: agent_settled普通成功路径只有一个底层 Run。
十八、完整时序:临时错误后重试
Section titled “十八、完整时序:临时错误后重试”sequenceDiagram
participant S as AgentSession
participant A as Agent
participant SM as SessionManager
S->>A: prompt(user message)
A-->>S: error AssistantMessage
S->>SM: 保存失败记录
A-->>S: agent_end
S->>S: _prepareRetry()
S->>SM: appendContextEdit(失败 entry ID, null)
S->>S: _refreshFinalizedContext()
S->>S: 等待 backoff
S->>A: continue()
A-->>S: 成功 AssistantMessage
S->>SM: 保存成功记录
A-->>S: agent_end
S-->>S: agent_settled对调用者来说:
await agentSession.prompt(...)会等待重试产生的整个新 Tool Loop 完成,而不是在第一次 agent_end 时提前返回。
十九、失败、取消和恢复路径
Section titled “十九、失败、取消和恢复路径”例如:
- 没有模型;
- 没有认证;
- busy 时未指定
streamingBehavior。
结果:
- 不调用
preflightResult; - 抛出错误;
- 不启动 Agent Run;
- 不产生
agent_settled,因为产品级运行根本没有开始。
Agent Run 中失败
Section titled “Agent Run 中失败”Provider error 会成为 AssistantMessage,并经过正常事件和持久化:
- 可重试 → retry;
- overflow → compaction recovery;
- 不可恢复 → settled。
Agent 的 active run 被中止后,仍通过 _runAgentPrompt() 的 finally:
- 清除临时 System Prompt;
- flush Bash messages;
- 发出
agent_settled。
retry、compaction 和用户 Bash 还各有自己的 AbortController,因为它们可能发生在底层 Agent Run 之外。
Session 替换失败
Section titled “Session 替换失败”切换前允许 Extension 取消;取消时旧 Session 保持有效。
目标 cwd 不存在时会在销毁旧 Session 前校验,避免先破坏当前可用环境。
二十、代码中的状态分别归谁
Section titled “二十、代码中的状态分别归谁”| 状态 | 所有者 | 原因 |
|---|---|---|
| messages、model、thinking、tools | Agent |
直接驱动每次模型调用 |
| active run、steering/follow-up 内部队列 | Agent |
属于通用 Loop 控制 |
| retry attempt/backoff | AgentSession |
产品恢复策略 |
| compaction/branch summary | AgentSession |
依赖 Session Tree 和设置 |
| active tool registry | AgentSession |
由内置、Extension、SDK 合并 |
| base/override System Prompt | AgentSession |
由项目资源和本次 Extension 决定 |
| JSONL entries/branch leaf | SessionManager |
持久化与树结构 |
| Settings/Resources/Models | AgentSessionServices |
与 cwd 环境绑定 |
| current replaceable session | AgentSessionRuntime |
服务于宿主切换 |
二十一、关键设计取舍
Section titled “二十一、关键设计取舍”1. 为什么 retry 不塞进 Agent
Section titled “1. 为什么 retry 不塞进 Agent”Agent Core 不应规定:
- 哪些 Provider 错误值得重试;
- 重试次数和退避时间;
- 是否把失败写入历史;
- UI 显示哪些 retry 事件。
Agent 只提供再次 continue() 的机制,AgentSession 决定策略。
2. 为什么持久化通过事件完成
Section titled “2. 为什么持久化通过事件完成”如果 Agent Loop 直接依赖 SessionManager:
- Core 会绑定 JSONL 产品格式;
- 无持久化宿主也必须承担依赖;
- UI、RPC 与测试难以复用同一事件流;
- Extension 最终修改消息与持久化顺序更难统一。
当前做法让 Agent 只发出确定的生命周期事件,AgentSession 在 awaited 边界上保存。
3. 为什么 System Prompt 每 Turn 刷新
Section titled “3. 为什么 System Prompt 每 Turn 刷新”运行中的配置不是完全静态:
- active tools 可变;
- Extension 可覆盖 Prompt;
- model/thinking 可切换。
在 Turn 边界拍摄新快照,可以让变化安全地作用于下一次 Provider 请求,而不修改已经发出的请求。
4. 为什么有 Services 和 Runtime 两层
Section titled “4. 为什么有 Services 和 Runtime 两层”Services 解决“一组依赖是否属于同一个 cwd”
Runtime 解决“宿主如何安全替换整组依赖和当前 Session”它们解决的是不同问题,不能合并成一个模糊的全局单例。
二十二、测试如何证明这些行为
Section titled “二十二、测试如何证明这些行为”agent-session-prompt.test.ts
Section titled “agent-session-prompt.test.ts”位置:packages/coding-agent/test/suite/agent-session-prompt.test.ts
覆盖:
- 空闲时 Prompt 被持久化;
- 带工具调用时
prompt()等待最终回答; - 多工具结果完整进入流程;
- 图片、Skill、Prompt Template 展开;
- Extension command 不调用 Provider;
- input hook 的 handled/transform;
- busy 时必须指定 queue behavior;
- 缺模型或认证时前置失败。
阅读重点:
测试不是只断言“收到了一段文字”,而是断言
AgentSession.prompt()包含完整 Tool Loop 和产品层预处理。
1717-2113-agent-session-event-settlement.test.ts
Section titled “1717-2113-agent-session-event-settlement.test.ts”位置:packages/coding-agent/test/suite/regressions/1717-2113-agent-session-event-settlement.test.ts
它验证:
- 异步
message_endExtension 不会让 assistant/toolResult 持久化乱序; - Tool Call hook 运行时,产生该调用的 AssistantMessage 已完成前序处理。
这证明 Agent listener 的 await 是真实的顺序屏障,不只是类型上允许 Promise。
agent-session-retry.test.ts
Section titled “agent-session-retry.test.ts”位置:packages/coding-agent/test/agent-session-retry.test.ts
覆盖:
- 临时错误后成功;
- 达到最大重试次数;
- backoff;
- 网络错误;
- retry 后模型再次产生 Tool Call 时,原
prompt()仍等待整个 Tool Loop。
agent-session-runtime-events.test.ts
Section titled “agent-session-runtime-events.test.ts”位置:packages/coding-agent/test/agent-session-runtime-events.test.ts
覆盖:
- new/resume/fork 的生命周期事件;
- Extension 取消切换;
session_shutdown → UI cleanup → rebind的顺序;- 替换后旧 Extension context 变 stale。
当前 SDK 还有独立的缓存预热
Section titled “当前 SDK 还有独立的缓存预热”createAgentSession() 创建 CacheWarmer 并注入 AgentSession。session 请求经过 streamFn 时才登记预热基线,摘要请求使用自己的路由 ID,不替换该基线:
if (options?.sessionId === sessionManager.getSessionId()) { cacheWarmer.start({ model, context, options: requestOptions }, cacheContextIsCurrent(model));}return modelRuntime.streamSimple(model, context, requestOptions);是否预热受设置、Provider 能力和 cache_warming_decision 控制,不能据此断言每次对话都会多发请求。相关 usage 可由 appendUsage() 保存为独立统计记录,不参与 LLM Context;模型请求数、Agent Turn 数与 JSONL 消息条数因此不是同一个计数。
二十三、建议的源码回查顺序
Section titled “二十三、建议的源码回查顺序”本文已经给出完整流程。需要验证实现时,不要从 agent-session.ts 第一行顺读,按符号跳转:
第一遍:建立对象关系
Section titled “第一遍:建立对象关系”agent-session-services.ts:createAgentSessionServicessdk.ts:createAgentSessionagent-session.ts:AgentSession.constructoragent-session-runtime.ts:AgentSessionRuntime
第二遍:追一次 Prompt
Section titled “第二遍:追一次 Prompt”AgentSession.promptAgentSession._runAgentPromptAgentSession._handlePostAgentRun- 回顾
packages/agent/src/agent.ts:prompt/continue - 回顾
packages/agent/src/agent-loop.ts:runLoop
第三遍:追事件与持久化
Section titled “第三遍:追事件与持久化”AgentSession._handleAgentEventAgentSession._emitExtensionEventAgentSession._replaceMessageInPlace- 搜索
SessionManager.appendMessage - 阅读 settlement regression test
第四遍:追恢复路径
Section titled “第四遍:追恢复路径”AgentSession._isRetryableErrorAgentSession._prepareRetryAgentSession._checkCompactionAgentSession._runAutoCompactionAgentSession._emitAgentSettled
第五遍:追 Session 替换
Section titled “第五遍:追 Session 替换”AgentSessionRuntime.teardownCurrentAgentSessionRuntime.switchSessionAgentSessionRuntime.newSessionAgentSessionRuntime.forkAgentSessionRuntime.finishSessionReplacement
二十四、容易混淆的问题
Section titled “二十四、容易混淆的问题”AgentSession 是不是 Session 文件的面向对象包装?
Section titled “AgentSession 是不是 Session 文件的面向对象包装?”不是。
SessionManager 才直接管理 Session Tree 和 JSONL。AgentSession 同时编排 Agent、工具、Prompt、Extensions、恢复和持久化。
一次 Prompt 是一个 Run 吗?
Section titled “一次 Prompt 是一个 Run 吗?”产品视角的一次 Prompt Operation,可能包含多个底层 Agent Run。
正常情况:1 个 Runretry:至少 2 个 Runoverflow compact-and-retry:至少 2 个 Runagent_end hook 入队:追加 Run两个 Session 能同时运行吗?
Section titled “两个 Session 能同时运行吗?”两个独立 AgentSession 各自拥有 Agent,因此可以各自运行。
但一个 AgentSessionRuntime 同时只持有一个“当前 AgentSession”;它负责切换,不负责把多个 Session 当作并行任务调度器。
agent_end 后为什么还能 continue?
Section titled “agent_end 后为什么还能 continue?”agent_end 只宣布底层 Loop 这一次停下了。AgentSession 在外层检查产品策略,并用新 Run 继续已有上下文。
AgentSession.isStreaming 这个名字是否只表示正在收 token?
Section titled “AgentSession.isStreaming 这个名字是否只表示正在收 token?”不是。它实际表达整个产品级操作是否未 settled,也覆盖 Run 之间的 retry/compaction 阶段。
系统状态、构造选项与本次覆盖有何不同?
Section titled “系统状态、构造选项与本次覆盖有何不同?”AgentState.systemPrompt是 transcript 重放得到的只读视图。_baseSystemPromptOptions是资源和工具的基础构造选项。_runSystemPromptOptions是本次产品请求选项,forceSystemPrompt 在请求投影中临时覆盖。
结束时清除 run options;普通系统状态保留在 Session 的 SystemMessage 中。强制覆盖期间 session.systemPrompt 与 AgentState 的重放值不一定相同。
二十五、面试表达
Section titled “二十五、面试表达”30 秒版本
Section titled “30 秒版本”Pi 的 Agent 是通用模型:工具运行时,AgentSession 是 coding-agent 产品编排层。它负责输入预处理、工具和 System Prompt 装配、Extension hooks、Session 持久化以及 retry 和 compaction。一次 AgentSession.prompt() 可能驱动多个底层 Agent Run,只有 agent_settled 才表示所有自动续跑完成。再外层的 AgentSessionRuntime 负责 new、resume 和 fork 时安全替换整套 cwd-bound services 与 Session。
2 分钟版本
Section titled “2 分钟版本”createAgentSession() 先从 Session 恢复模型、thinking 和消息,再通过 initialState.messages 恢复通用 Agent,tools 由产品层装配。随后 AgentSession 构建 coding tools、ExtensionRunner 和 System Prompt,并通过 awaited Agent listener 把底层事件映射成 Extension 与产品事件,同时在 message_end 持久化消息。
用户调用 AgentSession.prompt() 时,输入先经过 Extension command、input hook、Skill 和 Prompt Template 展开;空闲时再检查模型、认证和 compaction,构造用户与 custom messages,最后调用 agent.prompt()。底层 agent_end 后,AgentSession 会依次判断 retry、overflow compaction 和新队列消息,再经过 agent_before_settle 边界,必要时调用 agent.continue() 启动新的 Run。所以 agent_end 是一次 Run 的结束,agent_settled 才是产品级请求彻底完成。
Services 把 Settings、Models 和 Resources 绑定到同一 cwd;Runtime 则保存一个稳定宿主引用,在 new/resume/fork 时先 shutdown 和 dispose 旧 Session,再为目标 cwd 创建整套新对象并让 UI rebind。这避免了历史、工具目录、AGENTS.md 和 Extensions 来自不同项目的混合状态。
二十六、理解检查
Section titled “二十六、理解检查”尝试不看本文回答:
Agent与AgentSession的核心边界是什么?- 为什么 Agent 初始
systemPrompt和tools可以为空? createAgentSessionServices()为什么不顺便创建 AgentSession?- Services 与 Runtime 分别解决依赖一致性和会话替换中的什么问题?
beforeToolCall和afterToolCall分别能改变什么,不能改变什么?- Extension tool 和 SDK custom tool 的来源、生命周期与覆盖顺序是什么?
- input hook 的
continue、transform、handled分别代表什么? prompt()为什么先处理 Extension command,再展开 Skill?- busy 时为什么要 steer/followUp,而不能再次
agent.prompt()? - 一个用户 Prompt 在什么情况下会产生多个 Agent Run?
agent_end与agent_settled有什么区别?- 为什么 retry 要保留失败原记录,并用 context_edit 持久化排除?
- 为什么 Context overflow 不进入普通 retry?
- 为什么消息持久化发生在 awaited Agent listener 中?
- active tools 改变时为什么必须同时重建 System Prompt?
- Bash 已经执行完成,为什么它的 BashExecutionMessage 仍可能延迟写入 Context?
/resume到另一个 cwd 时,为什么不能只替换 SessionManager?AgentSessionRuntime中的 Runtime 和 Agent Run 有什么区别?- reload 与 replace Session 分别保留和替换哪些状态?
二十七、本阶段验收
Section titled “二十七、本阶段验收”- 能画出
Runtime → AgentSession → Agent → agentLoop层级图 - 能复述
createAgentSession()的装配过程 - 能完整追踪
AgentSession.prompt()的前置处理 - 能解释一个 Prompt 对应多个 Run 的情况
- 能解释事件转发与消息持久化的准确顺序
- 能解释工具 hooks、工具来源及同名覆盖顺序
- 能解释 input hook 的三种 action 和短路行为
- 能解释 pending BashExecutionMessage 的产生与 flush 时机
- 能解释 retry、compaction 和 settled
- 能区分 Services 的 cwd 一致性与 Runtime 的 Session 替换职责
- 能解释 new/resume/fork 的替换时序
- 能完成 30 秒与 2 分钟面试表达
新增:虚拟模型、嵌套工具与请求装配
Section titled “新增:虚拟模型、嵌套工具与请求装配”用户选择的模型现在可能是 api = pi-virtual 的路由入口。Agent 状态和分支 model_change 保留这个选择,
每次请求由 prepareRequest 解析为实际模型;正常 AssistantMessage 记录实际 provider/model 和 thinkingLevel。
// _installAgentRequestProjection 缩写,省略旧 hook 合并与投影重建。const route = await modelRuntime.resolveModel(selectedModel, convertToLlm(context.messages), { reason: failed ? "retry" : userTurn ? "user" : "continuation", thinkingLevel, failed, state, signal,});// 若 route.state 是新的值,追加 pi.virtual-model-state custom entry。if (exceedsThreshold(route.model, projection)) { await runAutoCompaction(); context = rebuildContext(); // 使用已确定的 route,不再重新路由本次请求。}return { context, model: route.model, thinkingLevel: route.thinkingLevel };以上缩写中的阈值与重建函数对应 _exceedsCompactionThreshold、_runAutoCompaction 和 prepare 局部函数。
物理模型选择仍可在 prepareNextTurn 做阈值检查;虚拟选择必须等实际路由确定后按真实窗口检查。
自动 retry/overflow 排除失败消息后,以 _failedResponse 将该尝试交给路由器,不把它重新发给模型。
摘要请求先以 direct 理由解析模型,再确定输入输出预算。
工具层还新增 _applyToolLoadout()、隐藏声明投影和 _executeNestedToolCall():注册、激活、可被工具
调用不再是同一个集合。嵌套调用经过相同 before/after hooks,事件带 parentToolCallId;有限调用摘要与
usage 汇入外层 ToolResult,避免 UI 执行事件被误记为独立模型历史。
defaultTools 支持增减语法;reload 在使用默认设置的会话中会激活新增项,同时保留已经激活但从设置
删除的工具。内置扩展也经 ResourceLoader 最终阶段按 builtin 路径加载,不能把它们视为任意 SDK
宿主都会默认装配的全局单例。
下一阶段连接
Section titled “下一阶段连接”本阶段把 Prompt 和 Context 当作 AgentSession 的输入装配结果,但还没有回答它们的内容细节。
阶段 5 将沿着这条链深入:
ResourceLoader → AGENTS.md / Skills / Prompt Templates → buildSystemPrompt() → AgentMessage[] → transformContext → convertToLlm() → Provider Context重点是区分:
- System Prompt;
- 用户输入展开;
- AgentState messages;
- Session 历史;
- 真正发送给 Provider 的 Context。