跳转到内容

AgentSession 产品编排

内容状态
已校验
校验日期
2026/10/1

阶段 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
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 --> Store

它是算法:

调用模型
→ 收到 Tool Call
→ 执行工具
→ 把 Tool Result 放回上下文
→ 再调用模型
→ 没有工具调用时结束

它不长期持有产品状态,也不认识 JSONL、Skills 或 CLI。

它是通用运行时对象:

Agent = AgentState + Loop 启动权 + 队列 + Abort + 事件 listener

一次 agent.prompt() 或 agent.continue() 启动一次 Agent Run。一个 Agent 同一时刻只允许一个 active run,但同一个 Agent 可以先后执行多个 run。

它包装一个 Agent,并把一次产品级请求管理到真正稳定:

AgentSession.prompt()
→ agent.prompt() # 第一个底层 Run
→ 若需要 retry/compaction
agent.continue() # 第二个底层 Run
→ 若仍需续跑
agent.continue() # 第三个底层 Run
→ agent_settled

因此:

一个 AgentSession.prompt() 不一定等于一个 Agent Run。

它是一组与项目目录 cwd 绑定的服务:

AgentSessionServices
├─ cwd
├─ agentDir
├─ SettingsManager
├─ ResourceLoader
├─ ModelRuntime
└─ diagnostics

它还不是一次对话,不拥有消息历史,也不会运行 Agent。

它回答的问题是:

如果要在项目 A 中创建一个 AgentSession,需要使用哪一组项目环境?

例如项目 A 的 Services 必须内部一致:

cwd = /work/project-a
SettingsManager = 读取 project-a/.pi/settings.json
ResourceLoader = 加载 project-a 的 AGENTS.md、Skills、Extensions
ModelRuntime = 包含这些 Extension 注册的 Provider

AgentSessionServices 不负责:

  • 保存对话消息;
  • 执行 Agent Loop;
  • 创建新 Session、恢复 Session 或 fork;
  • 把宿主从旧 AgentSession 切换到新 AgentSession。

可以把它理解成“创建 AgentSession 所需的项目工作环境”。

这里的 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 环境 整个宿主进程
对象 典型生命周期 结束条件
agentLoop 一次底层 Run 内部 Loop 不再需要调用模型
Agent 一个 AgentSession 内 Session 被 dispose
AgentSession 一份当前 coding session new/resume/fork/quit;reload 不替换它
AgentSessionServices 一次构建出的 cwd 配置环境 Runtime 整体替换 Session 与 Services
AgentSessionRuntime 一次宿主运行期 CLI/RPC/SDK 宿主退出

假设直接在 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”

代码存在两个相关入口:

createAgentSessionServices()
→ 只创建 cwd-bound services
createAgentSessionFromServices()
→ 使用已创建的 services 创建 AgentSession

源码位置:

  • createAgentSessionServices():agent-session-services.ts
  • createAgentSessionFromServices():agent-session-services.ts
  • 最终工厂 createAgentSession():sdk.ts

拆开创建的原因是:必须先知道目标 Session 的最终 cwd,才能加载属于那个项目的设置、资源和 Extension。

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。

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

sdk.ts:

  • 解析 cwd、agentDir;
  • 创建或复用 ModelRuntime;
  • 创建或复用 SettingsManager、SessionManager;
  • 若没有传入 ResourceLoader,则创建并 reload()。

SDK 允许调用者注入这些依赖,是为了:

  • 测试时使用 Faux Provider;
  • 宿主复用统一的认证与模型注册表;
  • Runtime 切换时确保所有对象绑定到新 cwd。

第二步:恢复模型和 thinking level

Section titled “第二步:恢复模型和 thinking level”

sdk.ts:

  1. SessionManager.buildSessionContext() 重建当前分支上下文;
  2. 有历史 Session 时优先恢复当时使用的模型;
  3. 恢复失败则使用 Settings 或 Provider 默认模型;
  4. thinking level 优先使用显式参数,其次 Session 历史,再其次 Settings;
  5. 最后根据当前模型能力执行 clampThinkingLevel()。

这里体现了一个优先级:

调用者显式选择
> Session 历史
> 当前项目/全局设置
> Provider 默认值

sdk.ts:

默认 active tools = read + bash + edit + write

调用者可以:

  • 用 tools 建立 allowlist;
  • 用 excludeTools 排除工具;
  • 用 noTools 禁用默认工具。

此时只计算名字,真正的 ToolDefinition 构建和 Extension 合并发生在 AgentSession._buildRuntime()。

sdk.ts 创建 Agent,初始状态是:

systemPrompt = ""(initialState 的便捷输入)
messages = existingSession.messages
model = 恢复或选出的模型
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()。

AgentSession 构造函数位于 agent-session.ts,完成下列关键接线:

1. agent.subscribe(_handleAgentEvent)
2. _installAgentToolHooks()
3. _installAgentNextTurnRefresh()
4. _installAgentRequestProjection()(虚拟模型在这里路由)
5. _installAgentBoundaryHooks()
6. _installHiddenDeclarationsProjection()
7. _installAgentForcedPromptProjection()
8. _buildRuntime();未指定 initialActiveToolNames 时从 transcript 恢复工具
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);

它把底层 Agent 的事件接到产品层,以完成:

  • Extension 事件转发;
  • UI/RPC/SDK 事件转发;
  • Session 持久化;
  • retry/compaction 所需状态记录。

Agent 的 listener 是 awaited listener,因此 _handleAgentEvent() 完成前,底层 Loop 不会越过这个事件屏障。

_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
→ 下一轮模型调用

它收到:

  • 产生调用的 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。

它收到:

  • 实际执行的 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。

以下缩写省略宿主既有 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 是联动的”

位置:agent-session.ts。

它执行:

读取图片与 Shell 设置
→ createAllToolDefinitions(cwd)
→ 读取 ResourceLoader 中的 Extensions
→ new ExtensionRunner(...)
→ 绑定 Extension API
→ _refreshToolRegistry(...)

这里的 runtime 指 AgentSession 内部的 Extension/Tool runtime,不是一次 Agent Run。

位置:agent-session.ts。

它把三类工具合并:

内置工具
+ Extension 注册工具
+ SDK custom tools

三类工具的“能力接口”基本相同,主要区别是注册来源和生命周期。

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;
  • 适合安装和共享的工作流功能。

嵌入 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。

源码先把内置定义放入 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 版本。

合并规则:

  1. 先应用 allowlist 和 exclude list;
  2. 内置定义先进入 registry;
  3. Extension 覆盖同名内置工具,SDK custom tool 再覆盖同名 Extension 工具;
  4. 提取每个工具的 promptSnippet 和 promptGuidelines;
  5. 用 ExtensionRunner 包装实际执行;
  6. 计算 active tool names;
  7. 调用 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。

位置: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 会逐段拆解其内容。

主入口: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 --> Run

agent-session.ts:

输入以 / 开头时,先检查 Extension command。

若命令被处理:

  • 不构造 user message;
  • 不调用 Provider;
  • 命令自己决定是否通过 Extension API 发送额外消息。

因此 /foo 不能一开始就无条件作为 Prompt Template 展开。

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" };

不修改输入,继续原流程:

return { action: "continue" };

handler 也可以直接返回 undefined。当所有 handlers 都没有修改输入时,ExtensionRunner.emitInput() 最终会归一化为:

{ action: "continue" }

AgentSession.prompt() 没有单独写 continue 分支,是因为它不需要修改任何状态,代码自然向下执行。

修改文本或图片,但仍交给当前 Agent:

return {
action: "transform",
text: `${event.text}\n\n要求:先添加回归测试,再修改实现。`,
images: event.images,
};

适合:

  • 给任务补充团队规则;
  • 实现 @review 等自然语言别名;
  • 注入外部 Issue 信息;
  • 脱敏或规范化输入;
  • 修改随输入提交的图片。

多个 Extension 的 transform 会按加载顺序串联:后一个 handler 看到前一个 handler 已经转换后的文本和图片。

Extension 已经完成或完全接管输入,不再构造 UserMessage,也不调用当前 Agent:

if (event.text === "打开任务面板") {
ctx.ui.setWidget(...);
return { action: "handled" };
}

适合:

  • 打开 Extension UI;
  • 执行不需要模型的确定性操作;
  • 把请求转交给另一个 Agent 或外部服务;
  • 阻止不允许发送的输入。

handled 会短路后续 handlers 和整个 Prompt 流程。如果 Extension 返回 handled 却没有自己产生任何结果,用户输入就会被“吃掉”。

选择规则:

不干预 → continue 或 undefined
加工输入后仍让 Pi Agent 做 → transform
Extension 自己完成/接管 → handled

它与另外两个 hook 的边界是:

input hook
决定用户输入是否以及如何进入 Agent
before_agent_start
在 Agent 启动前追加 custom messages 或临时覆盖 System Prompt
beforeToolCall / afterToolCall
处理模型已经生成的工具调用及其结果

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,可以各跑自己的 Run

agent-session.ts:

  1. 把此前延迟的 BashExecutionMessage 写回;
  2. 检查是否已选择模型;
  3. 检查 Provider 认证;
  4. 检查上一次被 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_settled

prompt() 在新请求开始前再次 flush,是防御性保证:构造新的 UserMessage 前,确保此前任何遗留 Bash 结果已经进入 Context 和 Session。写回后,下一次模型调用才能看到用户执行过的命令及其输出。

发送前 compaction 不调用 agent.continue(),因为新的用户消息马上会通过本次 agent.prompt(messages) 启动 Run。

agent-session.ts:

before_agent_start(详见 7.7)
→ 按 hook 选定模型的图片限制归一化图片
messages =
[SystemMessage patch(有变化时)]
+ [UserMessage(text + normalized images)]
+ pendingNextTurnMessages
+ handler 返回的 CustomMessages

pendingNextTurnMessages 是 Extension 提前排入“下一次用户请求”的上下文补充,不等于 steering/follow-up:

类型 何时消费
steering 当前 Run 的 Turn 边界
follow-up 当前任务自然结束后
nextTurn custom message 下一次显式 AgentSession.prompt()

agent-session.ts:

Extension 可以:

  • 向本次输入追加 custom messages;
  • 仅为本次产品级请求覆盖 System Prompt。

handler 可直接修改 event.systemPromptOptions,普通选项变化经 SystemMessage 持久化;返回 systemPrompt 字符串则写入 _runSystemPromptOptions.forceSystemPrompt,仅在请求投影中强制替换正文。整个编排结束时清除 _runSystemPromptOptions,不覆盖已保存的系统状态。

回调现在只通知接受后的分发结果,拒绝时不调用,直接抛错:

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 会出现重复用户输入。

每次底层 agent_end 之后:

取出最后一条 AssistantMessage
→ 是可重试错误?
准备 retry,返回 true
→ retry 已耗尽?
发出最终失败事件
→ 需要 compaction?
执行 compaction;需要续跑则返回 true
→ agent_end Extension 又排入消息?
hasQueuedMessages() 为真
→ 否则返回 false

返回 true 就会调用一次 agent.continue()。

九、agent_end 不等于整个请求完成

Section titled “九、agent_end 不等于整个请求完成”

这是本阶段最重要的事件边界。

含义:

一次底层 Agent Run 已结束。

此时可能仍要:

  • 等待指数退避后 retry;
  • compact 后重试 overflow 请求;
  • 处理 agent_end Extension 新加入的队列消息。

AgentSession 转发 agent_end 时会增加 willRetry:

agent_end { ..., willRetry: true | false }

判断逻辑在 agent-session.ts。

含义:

AgentSession 已完成所有自动续跑逻辑,现在才真正稳定空闲。

_emitAgentSettled() 位于 agent-session.ts:

_isAgentRunActive = false
→ await Extension agent_settled
→ emit AgentSession agent_settled
→ resolve waitForIdle()

因此 UI 是否重新视为“任务彻底完成”,应该看 agent_settled,而不是第一个 agent_end。

还要区分:

agent.state.isStreaming
= 当前底层 Agent Run 是否正在 Loop 中
agentSession.isStreaming
= 整个产品级 Prompt Operation 是否尚未 settled

在 retry backoff 或 compaction 期间,底层 Run 已结束,但 AgentSession 仍然忙。

事件桥接涉及两个连续的处理层:

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

一次运行中存在三份用途不同的状态:

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 外部 listeners
4. message_end 时写入 SessionManager
5. 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 + UserMessage

Agent 内部 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.message
Loop 中的最终 message

都指向同一个消息对象。

如果只写:

event.message = replacement;

只会改变局部事件引用,AgentState 仍保存旧对象。

_replaceMessageInPlace() 因此删除旧对象字段,再把 replacement 的字段复制进同一个对象:

保持对象引用不变
+ 对象内容变成 replacement

这样以下位置同时看到新内容:

Loop 后续检查的 AssistantMessage
AgentState.messages
后续 turn_end / agent_end
AgentSession listeners
SessionManager 持久化

这就是为什么代码不使用普通数组替换。

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 一次性写入 SessionManager

message_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 历史中。

假设模型返回:

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 顺序稳定为:

UserMessage
AssistantMessage(toolCall)
ToolResultMessage
下一条 AssistantMessage

Tool 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 后保存

错误历史可能变成:

UserMessage
ToolResultMessage
AssistantMessage(toolCall)

这不仅影响展示,也会破坏 Provider 对 Tool Call/Tool Result 的协议要求,导致恢复后的 Session 无法继续调用模型。注意,问题不在于 Extension 是否使用了异步代码,而在于宿主是否等待这段异步处理完成后再推进下一步。

仓库中的回归测试:

packages/coding-agent/test/suite/regressions/
1717-2113-agent-session-event-settlement.test.ts

专门验证:

  1. 即使 Assistant message_end handler 主动 await,持久化顺序仍是 assistant 在 toolResult 之前;
  2. tool_call handler 执行时,Session 中已经存在产生该调用的 AssistantMessage。

可以把整个事件桥接浓缩成四条不变量:

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

典型是:

  • rate limit;
  • Provider overloaded;
  • 网络或服务器临时错误。

Context overflow 不进入普通 retry,因为重复同一上下文只会再次失败;它交给 compaction recovery。

检查 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,重启与压缩重建后仍能应用。原记录用于审计,工作投影用于下一次请求。

backoff 使用独立 AbortController。

取消后:

  • 发出 auto_retry_end(success: false);
  • 不再 continue();
  • 最终仍进入 _emitAgentSettled()。

十二、Compaction:改变上下文后再决定是否续跑

Section titled “十二、Compaction:改变上下文后再决定是否续跑”

入口:

  • _checkCompaction():agent-session.ts
  • _runAutoCompaction():agent-session.ts

阶段 6 会深入摘要结构。本阶段掌握编排位置即可。

Overflow
Provider 已返回上下文溢出,属于恢复路径
Threshold
Context 接近上限,属于预防路径

若模型返回 overflow error:

失败 AssistantMessage 已持久化
→ 追加 context_edit 排除失败 Assistant 及本次 ToolResults
→ 从规范投影准备 compaction
→ 用压缩后的上下文 agent.continue()

同一恢复只尝试一次,避免:

overflow → compact → overflow → compact → 无限循环

若模型已经成功回答,只是 usage 超过配置窗口:

  • 可以压缩,为下次 Prompt 做准备;
  • 但不能自动 retry,因为本次答案已经完成。

达到阈值时执行压缩,通常不重跑刚完成的成功回答。

若压缩后存在 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 自身也可能再加入消息。此时:

  1. 底层 Loop 已经决定结束;
  2. agent_end 事件进入 AgentSession;
  3. Extension handler 加入新消息;
  4. _handlePostAgentRun() 检查 agent.hasQueuedMessages();
  5. 返回 true;
  6. 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。

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()。

以 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()

一个 Session 可能属于另一个 cwd,而以下资源都绑定 cwd:

  • .pi/settings.json;
  • .pi/extensions;
  • .pi/skills;
  • .pi/prompts;
  • 项目 AGENTS.md;
  • Project Trust;
  • 默认工具的工作目录。

只替换 SessionManager 会产生混合状态:

历史属于项目 B
工具仍在项目 A 执行
Prompt 仍加载项目 A 的 AGENTS.md
Extension 仍来自项目 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。

宿主 UI/RPC 需要一个稳定引用。

如果宿主直接持有 AgentSession,那么每次 /resume 后所有持有者都必须自己寻找新对象。AgentSessionRuntime 提供稳定外壳,内部替换当前 Session,再通过 rebindSession 集中通知宿主。

agent-session.ts:

  • abort 当前运行;
  • 取消 Agent 订阅;
  • 清空 AgentSession 的外部事件 listeners;
  • 使旧 Extension context 失效。

它用于 Session 生命周期结束。

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 时提前返回。

例如:

  • 没有模型;
  • 没有认证;
  • busy 时未指定 streamingBehavior。

结果:

  • 不调用 preflightResult;
  • 抛出错误;
  • 不启动 Agent Run;
  • 不产生 agent_settled,因为产品级运行根本没有开始。

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 之外。

切换前允许 Extension 取消;取消时旧 Session 保持有效。

目标 cwd 不存在时会在销毁旧 Session 前校验,避免先破坏当前可用环境。

状态 所有者 原因
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 服务于宿主切换

Agent Core 不应规定:

  • 哪些 Provider 错误值得重试;
  • 重试次数和退避时间;
  • 是否把失败写入历史;
  • UI 显示哪些 retry 事件。

Agent 只提供再次 continue() 的机制,AgentSession 决定策略。

如果 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 “二十二、测试如何证明这些行为”

位置: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_end Extension 不会让 assistant/toolResult 持久化乱序;
  • Tool Call hook 运行时,产生该调用的 AssistantMessage 已完成前序处理。

这证明 Agent listener 的 await 是真实的顺序屏障,不只是类型上允许 Promise。

位置:packages/coding-agent/test/agent-session-retry.test.ts

覆盖:

  • 临时错误后成功;
  • 达到最大重试次数;
  • backoff;
  • 网络错误;
  • retry 后模型再次产生 Tool Call 时,原 prompt() 仍等待整个 Tool Loop。

位置:packages/coding-agent/test/agent-session-runtime-events.test.ts

覆盖:

  • new/resume/fork 的生命周期事件;
  • Extension 取消切换;
  • session_shutdown → UI cleanup → rebind 的顺序;
  • 替换后旧 Extension context 变 stale。

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 消息条数因此不是同一个计数。

本文已经给出完整流程。需要验证实现时,不要从 agent-session.ts 第一行顺读,按符号跳转:

  1. agent-session-services.ts:createAgentSessionServices
  2. sdk.ts:createAgentSession
  3. agent-session.ts:AgentSession.constructor
  4. agent-session-runtime.ts:AgentSessionRuntime
  1. AgentSession.prompt
  2. AgentSession._runAgentPrompt
  3. AgentSession._handlePostAgentRun
  4. 回顾 packages/agent/src/agent.ts:prompt/continue
  5. 回顾 packages/agent/src/agent-loop.ts:runLoop
  1. AgentSession._handleAgentEvent
  2. AgentSession._emitExtensionEvent
  3. AgentSession._replaceMessageInPlace
  4. 搜索 SessionManager.appendMessage
  5. 阅读 settlement regression test
  1. AgentSession._isRetryableError
  2. AgentSession._prepareRetry
  3. AgentSession._checkCompaction
  4. AgentSession._runAutoCompaction
  5. AgentSession._emitAgentSettled
  1. AgentSessionRuntime.teardownCurrent
  2. AgentSessionRuntime.switchSession
  3. AgentSessionRuntime.newSession
  4. AgentSessionRuntime.fork
  5. AgentSessionRuntime.finishSessionReplacement

AgentSession 是不是 Session 文件的面向对象包装?

Section titled “AgentSession 是不是 Session 文件的面向对象包装?”

不是。

SessionManager 才直接管理 Session Tree 和 JSONL。AgentSession 同时编排 Agent、工具、Prompt、Extensions、恢复和持久化。

产品视角的一次 Prompt Operation,可能包含多个底层 Agent Run。

正常情况:1 个 Run
retry:至少 2 个 Run
overflow compact-and-retry:至少 2 个 Run
agent_end hook 入队:追加 Run

两个独立 AgentSession 各自拥有 Agent,因此可以各自运行。

但一个 AgentSessionRuntime 同时只持有一个“当前 AgentSession”;它负责切换,不负责把多个 Session 当作并行任务调度器。

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 的重放值不一定相同。

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。

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 来自不同项目的混合状态。

尝试不看本文回答:

  1. Agent 与 AgentSession 的核心边界是什么?
  2. 为什么 Agent 初始 systemPrompt 和 tools 可以为空?
  3. createAgentSessionServices() 为什么不顺便创建 AgentSession?
  4. Services 与 Runtime 分别解决依赖一致性和会话替换中的什么问题?
  5. beforeToolCall 和 afterToolCall 分别能改变什么,不能改变什么?
  6. Extension tool 和 SDK custom tool 的来源、生命周期与覆盖顺序是什么?
  7. input hook 的 continue、transform、handled 分别代表什么?
  8. prompt() 为什么先处理 Extension command,再展开 Skill?
  9. busy 时为什么要 steer/followUp,而不能再次 agent.prompt()?
  10. 一个用户 Prompt 在什么情况下会产生多个 Agent Run?
  11. agent_end 与 agent_settled 有什么区别?
  12. 为什么 retry 要保留失败原记录,并用 context_edit 持久化排除?
  13. 为什么 Context overflow 不进入普通 retry?
  14. 为什么消息持久化发生在 awaited Agent listener 中?
  15. active tools 改变时为什么必须同时重建 System Prompt?
  16. Bash 已经执行完成,为什么它的 BashExecutionMessage 仍可能延迟写入 Context?
  17. /resume 到另一个 cwd 时,为什么不能只替换 SessionManager?
  18. AgentSessionRuntime 中的 Runtime 和 Agent Run 有什么区别?
  19. reload 与 replace Session 分别保留和替换哪些状态?
  • 能画出 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 宿主都会默认装配的全局单例。

本阶段把 Prompt 和 Context 当作 AgentSession 的输入装配结果,但还没有回答它们的内容细节。

阶段 5 将沿着这条链深入:

ResourceLoader
→ AGENTS.md / Skills / Prompt Templates
→ buildSystemPrompt()
→ AgentMessage[]
→ transformContext
→ convertToLlm()
→ Provider Context

重点是区分:

  • System Prompt;
  • 用户输入展开;
  • AgentState messages;
  • Session 历史;
  • 真正发送给 Provider 的 Context。