跳到主要内容
路径文档

会话系统

一句话版:会话是事件溯源的:Session 是 append-only 的对话历史唯一真源,模型的 LLM 消息历史是从它派生的;持久化、投影、遥测都围绕同一串 SessionEvent 构建。不存在并行的"持久消息"类型。

这是理解'对话怎么被记录、恢复、展示'的核心。读完你能回答:一条消息到底以什么形态存进日志、surface 和原始日志是什么关系、崩溃后怎么恢复。

一、事件溯源:日志是唯一真源

核心心智:一个 Session 存的是一个不可变事件流,不是"消息列表"。

SessionEvent(append-only 事件流)
├── session-persistence 存储 / 重载 / 列出(JSONL 或 SQLite 后端)
├── session-projection 派生视图(缓存)
├── session-telemetry 遥测导出(OTel)
└── session-title 标题生成

模型看到的"LLM 消息历史" = 从这个事件流**派生**的(surface 层)

源码原话:

事件溯源模型:日志是唯一真源,因此不存在另一套并行的『持久消息』类型

这条边界是一切的基础:你想给会话"加什么信息",要么是新事件类型(append),要么是派生视图(surface/projection),而不是另开一个消息表。

二、surface:消息的派生层

原始日志里不只有"消息",还有边界、chunk、usage、错误等生命周期事件。模型需要的是一段有序消息投影,这就是 surface(一个在原始日志之上的有序投影层)。

  • surface 只投影"产消息"的事件:接受的 user 消息、assistant 消息、工具调用与结果、注入上下文等
  • 原始 chunk、生命周期边界、错误等被排除在 surface 之外(但保留在日志里)
  • surfaceOp 标记一个事件如何进入 surface;append / replace

两个读取口径,别混:

谁读用哪个
模型(请求上下文)surface()/deriveMessages()surface(看到的是替换后的面)
人类转录(debug/回放)append-origin 事件原始事件(landed 替换已遮蔽旧历史)

一条经验:想让"模型看到摘要版"就落 surface 替换;想看"到底发生过什么"就看日志原事件。

三、SessionStore:ctx.sessions

ctx.sessions 创建并持有事件溯源的 Session 实例。持久化不是它实现的:插件订阅 session/event、在 session/flush 时 flush,并可镜像 session/created/session/disposed 生命周期。

API约定
create(id?, { seed?, meta? }?)校验并 detach 持久 seed/header,填 version/id,createdAt 默认 now,发布并绑定到调用 fiber
flush(session)发布 awaited 并行持久化检查点;没发布/已 detach/stale 的对象拒绝
fork(source, boundary?, childSessionId?)解析会话,选 seed(默认当前最后事件 seq),要求前缀结束在 turn 外,创建带血缘元数据的 live 子会话
get(id)取或 undefined
list()列出

split 生命周期(仅当 teardown 需与其他资源排序时)

多数情况用 create() 就够了;但当 teardown 必须跟其它资源排序时,用三段式:

prepare(id?, opts?) // 校验并构造,不发布
enter(session) // 碰撞检查 + 发布(不 announce),返回 entry-bound 幂等 detach
// 并发同 id 可同时 prepare,但只有一个 enter 成功;陈旧 detach 无法删替换者
announce(session) // emit 唯一创建边;重复/重入 announce 拒绝

dsh-agent-loop 就用这个 split,让最后一次 loop flush 在 session detach 之前

四、Session 类

注意:Session普通类,不是 Cordis Service。live 会话经 ctx.sessions.create(),detached 回放/检查会话用 Session.create()(后者不发生命周期事件、不绑 fiber)。

关键方法

方法约定
session.append(type, data, opts?)快照并冻结持久数据与 surface 元数据,校验 marker 形状、引用的 source-event seq、complete 替换覆盖、单结果 tool/result 改写;同步提交后通知 observer(独立失败隔离)。reentrant 附加会话 append 拒绝
session.deriveMessages()增量投影每个新 surface 条目,返回冻结消息数组
session.events缓存的冻结快照(append 时失效);已接受事件保持深冻结
session.seq / session.id当前序号 / 只读标识
session.headerdetach、深冻结的创建元数据
session.surface只读 surface 视图

头信息与事件分离

内容是否可回放
SessionHeader版本、id、createdAt、可选 cwd/parentSession/seedLength/delegationDepth创建元数据,写入时 detach + 深冻结,运行时不可变
SessionEvent可回放的对话状态可回放

五、请求头重建(request/header)

request/header 记录一次请求的完整规范快照(非历史请求信封),reason 是三值:initial / resume / change

  • 可选 adapterDefaults map:标记 effective reasoningEffort/maxTokens精确模型解析物化的值,让下一次请求提案能区分它们与显式会话设置:这正是 Agent 主循环 里"adapter-default 标记"的持久化落点
  • foldRequestHeader() 选最近快照;legacy delta 事件和已移除的 fallback reason 被拒绝

user/message 存完整 UserMessage(在 inbox 路由 / step 进入前就已创建身份);它的 content 原样渲染,source 是区分"人工 prompt / 合成注入 / 进入的 goal round"的唯一通道。

六、事件信封字段(每个 SessionEvent 都可能有)

字段含义
sourceEventSeqs?: number[]引用的源事件 seq(比如 assistant/message 背后的 chunk seq;压缩替换背后的被遮蔽条目)。assistant/message[] = 已知空流;其他 surface 事件出现时须非空
surfaceOp?: SurfaceOp事件如何进入 surface;非 surface 事件(边界/chunk/usage/错误)不带
ignorable?: true读到不认识的类型可以安全跳过;缺失 = 必选,未知类型会拒绝会话重建

事件词汇与扩展

  • 完整目录见 known-event-types.ts 与生成的 persistence catalog
  • SessionEventMap 可声明合并:插件用 declare module 加自己的类型(compaction/*、hook 桥的 hook/* 等),合并成员进同一 catalog
  • 一个插件要自己的持久事实,session.appendawait ctx.sessions.flush(session),不要伪造执行 turn

七、崩溃恢复与 turn 结束原因

turn/start 只带轮次号;之后进入的 user/message 批次记输入,llm/retry 记请求恢复。turn/endTurnEndReasonMapkind-标签联合:

kind时机
aborted活 turn 被打断,reason: AgentCancelCause(保留类型化取消原因)。旧格式导入成 { kind:'aborted', reason:{ kind:'legacy' } }
errorturn 失败,{ kind:'error', error }
interrupted仅崩溃恢复合成(找不到别的证据)

崩溃恢复:冷 load 用合成事件关闭被中断的轮次:所以恢复出的会话不会有"悬空 turn"。

八、事件溯源的校验:snapshot 与不可变

持久化的值必须"可被一次接受",而不是"check 一下再读一遍":

  • isJsonValue(value):布尔谓词
  • snapshotJsonValue(value):一趟迭代校验并复制;拒绝环、不支持标量、exotic prototype;接受有限 JSON 数(但 -0 会被重写为 0);不设调用栈深度上限
  • snapshotSessionEvent(event) / adoptSessionEvent(event):克隆 borrowed / 原地持有独占所有权的修改(request-header)

九、持久化后端:JSONL

session-persistence-jsonl 的引擎配置决定存储形状:

配置默认作用
root:(必填无默认)会话日志根目录,通常 $DSH_HOME/sessions
packChunkstrue打包 chunk 行,约瘦身 60%
compressionzstdzstd(默认压缩)或 none(纯文本 JSONL)
preparedSessionCacheSize冷读 LRU 缓存大小
writeBatchMaxDelayMs200写合并窗口(write coordinator)

磁盘布局:

~/.dsh/sessions/--<归一化cwd>--/<encoded-id>/session.jsonl.zstd
  • 默认 zstd 压缩;要直接 head/jq 就配置 compression: 'none' 或先 zstdcat
  • 两级目录:--<cwd>--(工作区)+ <encoded-id>(会话)
  • packChunks 让行更少(打包 chunk),两种后端可切换

十、持久化后端:SQLite(opt-in)

session-persistence-sqlite 是第二个 SessionPersistence provider,满足与 JSONL 相同的契约(append-only、seq 连续、惰性物化、load 时关闭被中断 turn),只是落在 node:sqlite 的行上而非文件字节上。

挂载状态:opt-in——默认组合挂 JSONL(见上节),SQLite 后端需显式配置才装载。

存储模型:每个 SessionEvent 1:1 映射到 events 表一行 (session_id, seq, type, time, data, source_event_seqs, surface_op);data 是事件 JSON 文本,所以行就是事件的逐字形态(含 assistant/chunkseq 连续)。source_event_seqssurface_op 两列可空,存 surface 元数据;SessionHeader、物化 incarnation id、per-log revision 在 sessions 行里。库默认 wal 日志模式,PRAGMA application_id 标识库、PRAGMA user_version 存 layout 版本;初始化在一个事务里建全部表并盖两个 pragma。POSIX 上缺省目录 0700、库文件 0600(先建库再交给 SQLite 打开),新 sidecar 继承 owner-only 权限。

配置默认作用
path无(必填)SQLite 库文件路径,或 :memory: 进程内库
journalModewaljournal_mode pragma
preparedSessionCacheSize5冷读缓存
writeBatchMaxDelayMs200写合并窗口

关键语义:append 是一个事务(BEGIN/COMMIT),mid-batch 失败整体回滚;create 惰性物化(首个 append 才写 sessions 行,create 后从未 append 的会话不在 list() 里);load 按共享崩溃恢复契约关闭被中断 turn;locate(meta) 返回 undefined(所有会话共用一个库,没有独立 transcript 路径)。

已知限制:DatabaseSync 是同步的(每次 append 事务阻塞事件循环)、写竞争无等待/重试、只开 pristine 新库或当前 layout 版本、不删除会话。

十一、持久化时机:checkpoint-policy

session-checkpoint-policy 是零配置的语义持久化策略,决定"什么时候必须落盘"。它消费 ctx.sessions/ctx.llm/ctx.tools,并在 ctx.sessionPersistence 存在时生效(与任一持久化后端搭配)。

在三个边界做 checkpoint:

边界保证
model adapter 收到请求之前该请求对应的 buffered 事件已 durable
顶层 tool body 可能产生外部副作用之前记录的调用已 durable 才进 body
每个 agent/pre-step前一个 response 与有序 tool 结果在下一个请求前 durable

持久化与 checkpoint 调度是刻意拆开的两个插件:持久化后端跑有界后台批量 append、把每次 session/flush 变成即时 quiescence 屏障;本策略只选 request / tool-dispatch / next-step 三个屏障。只挂后端不挂本策略也合法,但 crash 可能丢掉 batching 窗口内或未完成的写。checkpoint 拒绝是 fail-closed:model 与 tool 边界拒绝则不跑 adapter / tool body;step 边界拒绝让 turn 失败。并发 tool checkpoint 共享 session store 的串行 drain,不会重复 seq。

挂载状态:默认挂载(与 JSONL 后端一起)。

十二、投影持久化缓存:projection-cache

session-projection-cache 提供 ctx.sessionProjectionCache:每个已注册投影单元状态的 durable checkpoint,每会话一条,落在 domain data form(session_projcache domain;json 后端落在配置 storage root 下的 workspace.json 旁)。

一条存储行 (key → {ver, seq, val})fold 捷径,不是权威:可能 stale(seq 精确说明 stale 到哪),但绝不错。由此而来的承诺:

  • 每个后台写 fail-soft:失败记 warning、保持 stale,下次写或冷读自愈;crash 只多付一段 tail replay,不会给错值
  • ver 与 live 单元 stateVersion 不匹配 → 读时丢弃、不迁移,key 从日志重新 fold
  • whole-record write:每次写替换会话完整 checkpoint,经 lossless-JSON 边界快照;不满足 plain-JSON 的单元状态 fail loud
  • record 绑定 log 生命周期(存 header 的 createdAt/cwd),每次读校验;删了重建的 id 或换掉的 store 会丢弃无关 record,不会种 phantom 值
  • log 在前、cache 跟随:live checkpoint 先把 buffered events durable flush 再落 cache row;crash 只能让 cache 落后于 log,不会超前

写策略是两个强制点 + 两个配置节流:

触发性质
turn/end强制(冷读想要 turn-final 值)
会话 disposal(detach)强制(live→cold 时刻)
writeEveryEvents 个 committed events配置节流(计数)
首个 dirty event 起 writeIntervalMs配置节流(间隔)

writeEveryEvents / writeIntervalMs 都必填无默认。读阶梯:零 I/O 的 cachedSnapshot(meta)(只读 identity 匹配、version 匹配的 stored record)与 coldSnapshot(id)(cache → restoreFloor → persistence readFromrestore → fail-soft 写回)。

挂载状态:默认挂载(Web 组合里 writeEveryEvents: 200writeIntervalMs: 5000;不挂时投影系统只跑 live-only)。

十三、会话标题:三件套

会话标题由 ctx.sessionTitle seam 提供,模型支撑的标题 provider 共享同一套实现策略(库 session-title-llm),真正可选的是两个 provider 插件:

形态cadence挂载
session-title-llm库(非 cordis 插件)共享实现策略,provider 调 registerSessionTitleLlmProvider() 注册
session-title-first-prompt-llm插件first-prompt:总结第一条合格用户 prompt,仅 fresh 非 fork 会话首次创建 fallback 时自动跑一次;自动失败保留 fallback,只能 ctx.sessionTitle.refresh() 重试默认挂载
session-title-all-prompts-llm插件all-prompts:每个新 human prompt 后起新 revision(含 seeded history 与子会话 prompt);新 revision 中止并取代旧工作opt-in

共享配置(除 route override 外全必填,无库级默认):

约定
targetWords非 CJK 标题目标词数
targetCjkCharacters中/日/韩标题目标字符数
maxInputBytes最终 JSON-framed 用户提示的 UTF-8 字节上限
maxOutputTokens辅助生成 token 上限
timeoutMs端到端 deadline
provider,model可选显式 route;两者都填或都不填

route 与失败契约:不填 provider/model 对就用当前会话已记录 request/header 的精确 route;在尚无 route 时显式 refresh 需 override。JSON-framed 用户提示(含 seq 字段、包装、转义)按 maxInputBytes 先校验后 dispatch,超则拒绝而非截断;malformed/空输出、tool call、非 stop finish reason 都拒绝。dispatch 前会直接经 Session append 一条 log-only 的 session/title-llm-request 事件(含 provider id、精确 source seqs、route、system prompt、message list、output-token cap),持久化 eager 观察。

标题请求与主对话完全分离:主 agent 请求 零额外 token;标题 purpose 映射为 thinking-disabled(DeepSeek adapter),主对话保留其 configured thinking 模式。

十四、遥测:session-telemetry-otel

session-telemetry-otel 是 telemetry seam 的唯一装载入口(OpenTelemetry 后端),mode 决定它跟随会话事件、只在记录反馈时回放、还是保持本地:

mode行为
FULL默认。每个投影 record(含 lifecycle ops)立即交给 OTel SDK
FEEDBACK_ONLY每个 feedback/record 回放、投影、redact canonical log 后缀;之后 record 等下一个 feedback,没有就留本地
DISABLED不构造任何 pipeline,无 record 出进程;feedback 留在本地会话日志

它按原样组合 OTel JS SDK(LoggerProviderBatchLogRecordProcessor → OTLP/HTTP log exporter),把每个 record map 到 logger.emit(),两个 instrumentation scope(ledger / ops)。Resource identity 含 service.name/service.version(来自 dsh-llmAPP_IDENTITY)加匿名 user.id($DSH_HOME/.anonymous-user-id,随机 UUID)。

上传授权 fail-closed:未知 mode 在读 transport config 前失败;只有 FULL 接受直接 ctx.sessionTelemetry.emit();FEEDBACK_ONLY 只把已存在 session.events[event.seq] 的精确 feedback/record 当 consent;DISABLED 即便有 exporter 配置也不构造 SDK pipeline。seam 通过 TelemetrySharingStatus.sharing(full/feedback-only/disabled)披露 mode。

出了本机的是什么:上传模式下 record 携带完整 event.data(telemetry seam 的 raw 副本)——用户/助手消息全文、工具参数与结果(命令输出、文件内容)、system prompt、tool schema、todo、compaction summary、hook stderrSummary、feedback 文本、会话 cwd。seam 无 redaction 规则;跨信任边界部署需自挂 session-telemetry/record redaction 规则。详见 数据与隐私

挂载状态:默认挂载(mode 默认 FULL;endpoint 缺省 https://harness-telemetry.deepseeksvc.com/v1/logs,可用 DSH_TELEMETRY_OTLP_URL 覆盖,DSH_TELEMETRY_DISABLED 任意非空值关闭)。

十五、损坏与格式策略

场景策略
撕裂尾部碎片丢弃
已提交损坏 / 格式错误SessionPersistenceCorruptionError 拒绝
未知事件类型(非 ignorable)SessionFormatUnsupportedError 拒绝
未知事件类型(ignorable)跳过

十六、验证

# 看 session 相关插件装载(挂载状态:持久化/checkpoint/投影缓存/标题/遥测)
dsh web --dump-config | grep -iE "persistence|checkpoint|projection-cache|session-title|telemetry-otel"

# 会话日志是压缩 JSONL(zstd),先解压再读,一行一事件
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | head -3

# 类型分布(看一个会话存了哪些事件)
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | jq -r .type | sort | uniq -c

# 看请求头重建来源
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep "request/header" | head -1

# 看 turn 结束原因
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep "turn/end" | tail

下一步