第四部分 · 会话与长任务
第 11 章:AgentSession——中央编排器与会话持久化
11,289 行的心脏,一条把消息队列升格为工作调度器的管道
巨类的合理性
core/agent-session.ts 有 11,289 行、401KB,是全仓库最大的文件,差不多是 pi 整个 agent 包的三倍。在别处这是架构坏味道的铁证;在这里它是一个显式设计决策的代价。文件头注释把理由写得很清楚:
/** * AgentSession - Core abstraction for agent lifecycle and session management. * This class is shared between all run modes (interactive, print, rpc). * ... * Modes use this class and add their own I/O layer on top. */
交互式 TUI、print、JSON、RPC、ACP、daemon worker——六种运行模式共享同一个编排语义。输入准入、转录持久化、压缩、重试、goal、refine、RLM 子代理,全部内聚在这一个类里。第 2 章说过 pi 的循环保持纯净;这一章看的是堆在循环之上的那座山。
一次 prompt 的九阶段旅程
第 2 章讲了队列上移的结论,现在走完整条管道。用户(或另一个 agent、一次心跳)提交一段文本后:
① 规范化:提交不一定是 prompt
_normalizeSubmission() 把提交分流成四种 kind:扩展斜杠命令(直接执行,不进模型);内部消化的命令(/goal、/autonomous 等状态机操作);session 级命令(/compact、/tree、/refine——封装成动作排队,在回合边界执行);以及真正的 prompt——技能引用与模板在此展开。
② 准入:动作状态机
prompt 被封装成 QueuedSessionAction 进入 ActionStore。这里有个关键的设计声明——这些动作永远不走 pi 的老路:
/** Session-owned actions. Items are never fed into Agent.steer/followUp. */
ActionStore 有两条 lane,对应两种投递策略:
export type DeliveryPolicy = "next_turn_boundary" | "when_run_idle"; export type QueuedMessageLane = "steering" | "followUp";
每个动作带一个八态生命周期(queued → selected → preparing → committing → running → completed/failed/cancelled,合法转移由转移表强制校验;committing → queued 的回滚必须携带「分派已结算且消息未入转录」的证明)。调用方拿到三段承诺的 ActionTicket:accepted(会话已接管)、delivered(消息已入转录)、completed(执行完毕)——「提交一条消息」从此是可 await、可恢复、可编辑的契约,队列里的消息甚至可以移动、替换、删除。
在 pi 里,steering 可以在 run 中途的 turn 边界插入消息。Prime 的两条 lane 却都必须等输入泵确认 agent 空闲(agent.waitForIdle())才提交——steer 只剩「优先于 followUp 被选中」的语义,mid-run 打断只能靠 abort。这不是疏忽:Prime 的每个输入都附带准备阶段(模型/凭据验证、pre-turn 压缩、refine 屏障、扩展 hook),这些都需要一个静止点。想要 pi 式的中途插话,就得放弃这套准入治理——Prime 选择了后者。
③ 泵:单实例调度循环
_pumpSessionInputs(epoch) 是唯一的调度者:等 agent 空闲 → 等事件处理链追平 → 等 refine 空闲 → 汇总忙闲(压缩、重试、bash、分支变更、dispose……全闲才可选)→ 按 lane 优先级 selectFirst() → 按 steeringMode/followUpMode("all" 时把同策略的连续动作合成一批)→ 提交。
epoch 是竞态的答案:分支导航、abort 等会让在途准备作废,泵每次暂停递增 epoch,所有 await 点醒来先查 epoch;过期的准备不当失败处理(DeferredSessionInputError),回滚重排。
④ 提交:三段式准备
_startPreparedTurnActions() 走 prepare / shouldCommit / commit:验证模型与凭据、pre-turn 压缩(时机因策略而异)、refine 屏障、扩展 before_agent_start hook(可改写 prompt 与系统提示词);commit 在围栏与 AsyncLocalStorage 上下文内进行——取 goal context 等旁注消息、组装 preparedMessages、应用准备好的系统提示词,最后 this.agent.prompt(preparedMessages) 交给下层 pi Agent。循环开始跑第 2 章讲过的那套流程;提交方还要校验主消息确实 durable 了,否则抛 "Session input dispatch settled without durable delivery"。
⑤ 事件回流:一条串行链
下层循环的每个事件经 agent.subscribe 进入 _handleAgentEvent,异步处理全部串入一条 Promise 链:
// 所有异步处理串入单链,保证顺序 this._agentEventQueue = this._agentEventQueue.then( () => this._processAgentEvent(event), () => this._processAgentEvent(event), );
链上每个 _processAgentEvent 做的事,就是「编排层治理」的全部清单:持久化消息(custom → appendCustomMessageEntry,其余 → appendMessage);goal token 记账(超预算排队一条 budget_limit 消息);auto-refine 计数与后台规划;agent_end 时的三连判定——自动重试、压缩(第 12 章)、goal 终结。扩展事件先于普通监听者发射。
泵在每次提交前 await this._agentEventQueue,使「事件处理追平」成为全系统的同步点。持久化顺序与事件顺序永远一致——这条链是整个会话一致性的脊梁。
消息类型:声明合并出的四个新角色
Prime 没有 fork pi 的消息类型系统,而是用 TypeScript 的 declaration merging 向 pi-agent-core 的接口注入四种新 role:
declare module "@earendil-works/pi-agent-core" {
interface CustomAgentMessages {
bashExecution: BashExecutionMessage;
custom: CustomMessage;
branchSummary: BranchSummaryMessage;
compactionSummary: CompactionSummaryMessage;
}
}
custom 是其中承载最多的一族——customType 区分语义:agent_message、goal_context、heartbeat_prompt、ipython_state_restored、compaction_outcome、rlm_child_failure……第 6 章的「spawn 即消息」就复用这一族。convertToLlm() 是把 AgentMessage[] 转成 provider 消息的唯一闸口:会话斜杠命令与压缩结果被过滤掉(不进 LLM),摘要类包上标记前后缀。
bash 输出进上下文时要包代码围栏。围栏用多长的反引号?答案是动态的:长度等于输出内最长连续反引号串加一。命令输出里若恰好包含 ``` ,固定围栏会被提前「闭合」——一个注入式的边角料,用一行计算堵死了。
会话存储:一棵懒出生的树
转录住在 ~/.prime/agent/sessions/<uuidv7>.jsonl(扁平目录,旧的按项目分目录结构自动迁移)。格式是 pi 会话树(第 2 章术语表里「沿用」的那项)的 v3 扩展:首行 header(含 cwd、parentSession、rlmDepth、git 状态),其后每行一个带 id/parentId 的 entry,leafId 是当前指针。entry 类型全集:
| type | 内容 | 进 LLM 上下文? |
|---|---|---|
message | 包一个 AgentMessage | 是 |
model_change / thinking_level_change / service_tier_change | 配置变更 | 否(重建状态) |
compaction | 摘要 + firstKeptEntryId + tokensBefore | 摘要替代前文 |
branch_summary | 离开分支时的摘要 | 是 |
custom | 扩展/系统状态(goal、refine 历史、rlm max depth…) | 否 |
custom_message | 注入消息(见上) | 是 |
child_usage_attributed | 子代理用量归因(第 6 章) | 否 |
label / session_info / session_state / agent_status / git_state | 书签 / 名字 / 生命周期 / 摘要 / git | 否 |
注意右列的分裂:会话文件不只是转录,它是整棵状态树。goal 状态、refine 历史、深度配置都挂在分支的 custom entry 上——这解释了第 9 章的「分支切换连带重载 goal」:切分支就是切整套状态。子代理注册表、kernel 快照则住在旁边的 session-artifacts/<sessionId>/。
存储层还有个可爱的细节:会话文件懒出生。第一条 assistant 消息之前,_persist() 根本不落盘——「打开 Agent 但一句话没聊」不会留下文件。例外通道是 flushNow(),goal 状态写入用它强制落盘保证重启幂等。大文件(>128MB)走流式加载与逐行 Buffer 解码,v1→v3 迁移在加载时自动完成。
session-lease:谁打开着这个会话
daemon 化的世界里有新的冲突源:两个 worker 打开同一个会话文件,或者用户在 --resume 一个 worker 正持有的会话。session-lease.ts 的答案是一套旁路租约:
- 默认关闭,只由 daemon supervisor 启动 worker 时通过
PRIME_AGENT_INTERNAL_SESSION_LEASES=1注入——名字里的 INTERNAL 说明这是进程间内部协议。单机交互零开销。 - 租约目录
session-leases/<sha256(会话路径)>.lock/owner.json,获取用 candidate 目录 + 原子 rename。 - PID 复用防御:owner.json 记录
processStartId——进程启动时刻(Linux 读 /proc、macOS 用 ps、Windows 用 PowerShell 查StartTime.Ticks)。判活 = pid 存活且启动时间匹配。第 3 章孤儿日志里见过同一技巧——防 PID 复用是全系统的一致性执念。 - 冲突抛
SessionAlreadyActiveError,带着对方的 activeSessionId——「会话已在另一客户端打开」是一等错误,不是数据损坏。
AgentSessionRuntime:可替换的会话槽位
宿主层的 AgentSessionRuntime 名字里有 runtime,干的却是「槽位」的活:持有当前 AgentSession + cwd 绑定服务(auth/settings/model registry/resource loader/MCP)+ 会话租约,并实现 SubagentRuntimeHost——子代理的 runtime 由它创建与托管(第 6 章的 daemon 路径就在这里落地)。
它最有纪律的部分是会话替换四件套(switchSession/newSession/fork/importFromJsonl)的统一骨架:扩展钩子可否决 → 获取替换租约 → 拆除旧会话(发 shutdown 事件、flush trace、disposeAsync() 等 kernel 最终快照落盘、销毁托管的子 runtime)→ 建并应用替换 → 重绑定宿主。每一步失败都有回滚路径(未提交的租约会归还)。TUI 里按下 /new、/fork,机器里跑的是同一台状态机。
顺带一提 side-question.ts——「不污染主上下文的旁路提问」:克隆主会话消息(structuredClone),起一个 tools: []、thinkingLevel: "off"、shouldStopAfterTurn: () => true 的一次性 Agent;多轮追问时每轮重新克隆主转录再重放旁路历史——所以旁路能看到主线程的最新进展,却一个字也写不回去。
实践应用
- 把队列升格为调度器。当输入源多元化、每种输入需要不同的准备与恢复语义时,「消息队列」就不够了:动作 + 状态机 + 回执 ticket + 恢复快照 + 队列编辑,才是完整形态。
- 副作用密集的事件流要串行化。一条 Promise 链保证「持久化顺序 = 事件顺序」,并把「处理追平」变成其他子系统的同步点。这比任何事件总线配置都简单、都强。
- 让会话文件承载状态树,而不只是消息。分支切换 = 整套状态切换;重启 = 重放。状态挂在转录上,幂等性就挂在转录上。
- 懒出生减少垃圾,强落盘保证关键状态。两个方向的例外通道(无 assistant 不落盘 / flushNow 强落盘)把「何时持久化」变成显式决策。
- 并发所有权用旁路租约,不用文件本身。租约目录可以整个删掉重建,会话文件永远不被锁机制污染。
总结
AgentSession 把 pi 的纯净循环包进一条九阶段管道:规范化、准入(动作状态机 + 双 lane)、泵调度、三段式准备、提交、事件串行链、持久化与轮间治理。消息类型用声明合并扩展,会话文件是懒出生的状态树,租约防并发打开,Runtime 提供可替换槽位与统一的会话替换骨架。它是巨类,但每一块体积都能指向一个具体的模式共享需求——六种运行模式只写一次编排逻辑。
管道里最惊险的环节是压缩:上下文逼近窗口上限时,系统必须在不丢 kernel 状态、不丢子代理、不丢 goal 的前提下缩小转录。下一章看它怎么做。