会话与消息模型

Session、消息与 12 种 Part 的结构,ID 生成规则,会话状态与待办,以及它们如何经事件落进 SQLite。

作者 David更新于 第 6 篇(共 38 篇)

读会话引擎,先把数据模型装进脑子:一个 Session 包含一串 Message,每条消息由若干 Part 组成。循环、处理器、压缩、撤销,都在这三层结构上做增删改。

类型住在哪里

V1 的类型定义在 packages/schema/src/v1/session.ts,由 packages/core/src/v1/session.ts 以 SessionV1 之名重导出;packages/opencode/src/session/ 下的 session.ts、message-v2.ts 负责服务与读写。别被 message-v2 这个文件名误导:它就是现役 V1 的消息层,与V2 会话内核无关。同目录的 message.ts 是更早的旧格式,只剩 packages/web 的分享页还以类型方式导入它。

Session:会话头

Session.Info(packages/opencode/src/session/session.ts:224)的字段可以分成四组:

分组字段说明
身份id、slug、projectID、workspaceID、directory、pathpath 是相对 worktree 的子目录
谱系parentID、title、version子代理会话带 parentID;默认标题形如 New session - <ISO 时间>
运行agent、model、permission、revert当前 agent 与模型、会话级权限规则、撤销边界
统计cost、tokens、summary、timetime 含 created、updated、compacting、archived

fork(session.ts:691)复制指定消息之前的历史,给消息与 Part 换新 ID 并改写 parentID、tail_start_id 等引用,标题追加 (fork #N)。

Message:user 与 assistant

消息是按 role 判别的联合类型。User 消息(packages/schema/src/v1/session.ts:332)记录“这一轮用什么跑”:agent、model(providerID、modelID、variant)、可选的 system(追加进系统提示词)、format(text 或 json_schema 结构化输出)、不再推荐的 tools,以及记录文件差异的 summary。

Assistant 消息(同文件 :453)记录“这一轮跑出了什么”:parentID 指向触发它的 user 消息,另有模型、agent、path、cost、tokens(含 reasoning 与 cache 读写)、finish(结束原因)、error、structured 与布尔值 summary。

同名字段 summary 在两处含义完全不同:user 消息上是差异统计对象,assistant 消息上是布尔标记,表示这条消息是上下文压缩生成的摘要。

error 是按 name 判别的联合,共 8 种,如 APIError、MessageAbortedError、ContextOverflowError。其中 ContextOverflowError 在默认配置下不记为消息错误,而是被处理器转去压缩。

Part:12 种片段

消息的内容全部是 Part,packages/schema/src/v1/session.ts:357 的联合类型共 12 种,其中 10 种如下:

type关键字段来源
texttext、synthetic、ignored用户输入、模型输出、系统注入
reasoningtext、metadata(签名等)模型思考过程
tooltool、callID、state工具调用,见下文状态机
filemime、url、source用户附件与 @ 引用的文件
agentname、source输入里的 @agent
subtaskprompt、agent、command以子代理运行的斜杠命令
step-startsnapshot每个模型步开始
step-finishreason、snapshot、cost、tokens每个模型步结束
patchhash、files本步改动过的文件
compactionauto、overflow、tail_start_id压缩请求

synthetic 标记“系统代写”的文本,比如 @ 文件展开出的“Called the Read tool…”。它照样发给模型,只在判断“是否真有用户发言”(如生成标题)时被排除;ignored 才表示不发给模型。

另两种 snapshot 与 retry 仍在 schema 中,但各包 src 下找不到创建它们的代码:快照哈希如今挂在 step-start / step-finish 上,重试进度改走会话状态。

工具调用的状态机

tool Part 的 state 按 status 判别,单向推进:

图表加载中…

pending 下 input 为空、raw 为空串(增量参数并不回写);running 后有了参数与 time.start,工具可通过 metadata() 回调刷新 title 与 metadata;completed 带 output、attachments 与 time.end。time.compacted 表示输出已被修剪,metadata.interrupted 表示调用被中断收尾,见流处理器。

ID:前缀 + 时间 + 随机

ID 都是“前缀 + 下划线 + 26 个字符”:ses、msg、prt、per(权限)、que(提问)等。生成逻辑在 packages/opencode/src/id/id.ts:51:

export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
  const currentTimestamp = timestamp ?? Date.now()

  if (currentTimestamp !== lastTimestamp) {
    lastTimestamp = currentTimestamp
    counter = 0
  }
  counter++

  let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)

  now = direction === "descending" ? ~now : now

  const timeBytes = Buffer.alloc(6)
  for (let i = 0; i < 6; i++) {
    timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
  }

  return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
}

前 12 位十六进制编码“毫秒 × 4096 + 计数器”,后 14 位是 base62 随机数。ascending 让字典序等于创建顺序,Part 只需按 id 排序就能还原次序;会话 ID 用 descending(按位取反),越新字典序越小。packages/schema/src/identifier.ts 有一份同算法实现,供 schema 层使用。

按代码计算,6 字节只容纳 48 位,而当前的“毫秒 × 4096”已超过 2 的 52 次方,前缀实际是低 48 位,约每 795 天回绕一次。好在消息分页以 time_created 为主序、ID 只做决胜。

会话状态与待办

会话状态(status.ts)只有 idle、busy 和带 attempt、message、next(下次重试时间戳)的 retry 三种。它只存在实例内存里,不落库;查不到即视为 idle。

待办(todo.ts)则落库:todo 表以 (session_id, position) 为主键,update 在一个事务里先删光再按顺序插入,然后发出 todo.updated。待办工具见子代理、待办与提问。

持久化:写事件,不写表

Session.updateMessage 与 updatePart 本身不碰数据库,只发布 message.updated、message.part.updated 事件(session.ts:629)。这两类事件是 durable 的:发布时在同一个 SQLite 事务里分配序号、依次执行投影器(projector),由投影器写表。Part 的投影(packages/core/src/session/projector.ts:310,删减后):

    yield* events.project(SessionV1.Event.PartUpdated, (event) =>
      Effect.gen(function* () {
        // ...
        const row = yield* db.select().from(PartTable).where(eq(PartTable.id, id)).get().pipe(Effect.orDie)
        yield* db
          .insert(PartTable)
          .values({ id, message_id: messageID, session_id: sessionID, time_created: event.data.time, data })
          .onConflictDoUpdate({ target: PartTable.id, set: { data } })
          .run()
          .pipe(Effect.orDie)
        const previous = row && usage(row.data)
        const next = usage(event.data.part)
        if (previous) yield* applyUsage(db, row.session_id, previous, -1)
        if (next) yield* applyUsage(db, sessionID, next)
      }),
    )

表结构在 packages/core/src/session/sql.ts:session、message、part、todo 四张表服务 V1,消息与 Part 的正文整体存进 JSON 列 data。投影时还顺手记账:step-finish 的花费与用量以“先减旧值、再加新值”的方式累加到 session 行,删除时反向扣除。

流式增量走 message.part.delta,它不是 durable 事件,只推给界面、不落库;文本结束时再整段写一次。读取由 MessageV2.page 负责,以 (time_created, id) 为游标倒序分页。细节见存储:SQLite 与事件投影。

下一篇:主循环 SessionPrompt——一条 user 消息如何驱动循环一步步跑下去。

本页目录