V2 会话内核
持久化收件箱把提示的受理与执行拆开,协调器按会话串行调度,SessionRunner 每轮只发一次 llm.stream,再加上 Context Epoch 与自动压缩。
V1 的会话主循环是 SessionPrompt → LLM.run → SessionProcessor(见主循环与流处理器)。V2 的内核在 packages/core/src/session/(约 3700 行)和门面 packages/core/src/session.ts 里,还没有取代 V1:规格 specs/v2/session.md 写明 session.next.* 事件仍是实验性的,早期实验库可以丢弃,迁移 20260622170816_reset_v2_session_state 就直接清空了 V2 的几张表。runner 开头的注释写道:
Keep this as orchestration over smaller collaborators rather than rebuilding the legacy
SessionPromptmonolith.
怎么用
V2 会话只通过新协议暴露:packages/protocol/src/groups/session.ts 在 /api/session 下定义了创建、prompt、interrupt、history(分页读持久事件,默认每页 50 条)、event(先重放、后追尾的事件流)等接口,新 CLI 的守护进程与旧 opencode 的 HTTP 服务都挂载了这组路由。门面上的 shell、skill、compact、wait 目前一律返回 OperationUnavailableError。
受理:先落盘,再谈执行
prompt 不直接跑模型,只做“受理”:
const delivery = input.delivery ?? "steer"
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
const admitted = yield* SessionInput.admit(db, events, {
// ...
}).pipe(
// ...
)
if (!SessionInput.equivalent(admitted, expected))
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
return admittedpackages/core/src/session.ts:366(删减后)。SessionInput.admit 发布持久事件 PromptAdmitted,投影器把它写成 session_input 表的一行,admitted_seq 就是该事件在会话聚合里的序号。此时输入对模型不可见;之后串行的 runner 在安全边界发布 Prompted,其投影器在同一个事件事务里写入可见的用户消息,并给收件箱行填上 promoted_seq。
幂等规则全落在消息 ID 上:精确重试(同会话、同提示、同投递方式)返回同一张回执并再唤醒一次;同一个 ID 撞上别的会话、提示或投递方式,或已是可见消息,都报 PromptConflictError;resume: false 只受理不执行。好处是调用方拿到回执即算提交成功,模型失败或进程重启都不会丢输入,排队中的输入也能被客户端观察到。
执行:同会话串行,跨会话并发
执行只认会话 ID。本地实现 session/execution/local.ts 先查 SessionStore,再用 locations.get(session.location) 进入会话所在的 Location(见上一篇),调用那里的 SessionRunner.run。调度交给 run-coordinator.ts,它为每个会话维护一个条目:
| 操作 | 会话空闲时 | 已在执行时 |
|---|---|---|
run(显式续跑) | 强制起一次 drain | 加入并等待当前执行 |
wake(有新输入) | 起一次普通 drain | 只记一个 pendingWake,多次合并 |
interrupt | 无操作 | 标记停止、清掉 pendingWake、中断纤程;收件箱行保留 |
drain 结束时若有 pendingWake,协调器立刻接力再跑一轮。active() 就是这张表的键集合,只代表本进程的前台 drain,重启后即为空。
SessionRunner:每轮一次 llm.stream
外层循环如下:
let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
let shouldRun = input.force || hasSteer || hasQueue
while (shouldRun) {
let needsContinuation = true
let step = 1
while (needsContinuation) {
const result = yield* runTurn(input.sessionID, promotion, step)
needsContinuation = result.needsContinuation
step = result.step + 1
promotion = "steer"
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
}
shouldRun = yield* SessionInput.hasPending(db, input.sessionID, "queue")
promotion = shouldRun ? "queue" : undefined
}packages/core/src/session/runner/llm.ts:398。steer(默认)在下一个安全的 provider 轮次边界提升,哪怕当前 drain 还要续跑;queue 只在会话本来要闲下来时提升,一次一条,提升后重新评估是否续跑;提升了新输入,步数重置为 1。每一轮 runTurnAttempt 依次:
- 位置栅栏:会话已被移到别的 Location 就直接中断。
- 准备上下文:选定 agent,初始化或对账 Context Epoch,按序提升输入。
- 组装请求:system 是 agent 提示加 epoch 基线,历史经
to-llm-message.ts转成@opencode-ai/llm消息;到达 agent 的steps上限时追加max-steps.ts的提示并设toolChoice: "none"。 - 压缩检查,然后只调用一次
llm.stream(request)。 - 急切执行工具:每个完整的本地工具调用先持久化,再立即在
FiberSet里启动;流结束后等全部结算,发布带文件差异的Step.Ended,下一轮重新加载历史。
publish-llm-event.ts 把 LLMEvent 流翻译成持久的 session.next.* 事件,发布经信号量串行化。两条安全规则:开始 drain 前,failInterruptedTools 把上个进程遗留的 pending、running 工具统一标记为 Tool execution interrupted,副作用绝不静默重放;用户拒绝权限或问题时循环直接停下,与 V1 一致。runner/model.ts 目前只支持 @ai-sdk/openai、@ai-sdk/anthropic 与配置了 URL 的 @ai-sdk/openai-compatible 三类端点,分别走 packages/llm 的 OpenAI Responses、Anthropic Messages 与 OpenAI 兼容 Chat 协议。
Context Epoch 与自动压缩
V2 把模型看到的特权系统上下文持久化在 session_context_epoch 表:不可变的基线文本(照顾 provider 前缀缓存)、模型不可见的结构化快照、基线对应的事件序号。上下文由若干带 key 的 Source 组成(packages/core/src/system-context/):内置的 core/environment、core/date,core/instructions(全局与向上查找到的 AGENTS.md),再加 skill 指引与引用指引。
每轮对账时,来源没变则跳过;变了就发布一条按时间顺序插入历史的 ContextUpdated 系统消息,并在同一事务里推进快照;来源暂时观测不到就保留旧值;首次初始化遇到不可用的来源报 InitializationBlocked,提示保持待处理。规格还要求会话换 Location 时清空 epoch,SessionContextEpoch.reset 已写好,但 v1.18.30 里还没有调用方。
compaction.ts 在组装好请求后估算体积,超过“上下文窗口减去输出上限与 compaction.buffer(默认 20000)中的较大者”就先压缩:较早的对话按固定的五段模板摘要,保留最近约 8000 token 的原文。只有持久事件 Compaction.Ended 投影出模型可见的压缩消息,失败时旧边界照常生效;压缩完成后下一轮直接渲染新基线。provider 在尚无助手输出时报上下文溢出,会额外做一次溢出压缩并重建同一轮,再溢出即按普通失败结束。对照见上下文压缩。
读模型与 V1 桥接
projector.ts 用 events.project 为每种事件注册投影:V1 事件写 session、message、part,V2 事件写 session_message;message-updater.ts 用 immer 把增量事件合并进当前助手消息;history.ts 按最近的压缩点与 baseline_seq 选出给 runner 的消息行。
两代共用 session 表,V2 的 create 发布的仍是 SessionV1.Event.Created(V1 契约留在 packages/core/src/v1/)。规格提到 V1→V2 影子桥会为已可见的 V1 提示补发 Prompted,但 v1.18.30 的 packages/opencode/src 里找不到发布方,只有 SessionInput.projectPrompted 为“没有收件箱行的 Prompted”保留了补插分支。
规格的 V1 运行时上下文对齐清单共 21 项:complete 3 项、partial 8 项、missing 10 项,配置与远程指令、provider 专属基础提示、插件变换、结构化输出等仍未实现;崩溃后续跑恢复、provider 超时与重试、集群所有权也被明确延后。
下一篇:V2 工具与权限——单一执行体的工具契约与默认 ask 的权限模型。