跳到主要内容
路径文档

会话系统

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

审计基线 0.1.5-alpha.1 @ 5dda764ed3:包名、ctx key、事件名、配置键与磁盘布局均与官方源码逐点核对。

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

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

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

SessionEvent(append-only 事件流)
├── session-persistence 存储 / 重载 / 列出(JSONL 后端)
├── session-format-* 历史格式迁移(v0→v1→v2→v3)
├── session-projection 派生视图(缓存)
├── session-telemetry 遥测导出(OTel)
└── session-title 标题生成

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

源码原话:

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

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

二、surface:消息的派生层​

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

  • surface 只投影"产消息"的事件:system/message、user/message、assistant/message、tool/result 四种(这就是 SurfaceEventType)
  • assistant/attempt、生命周期边界、错误等被排除在 surface 之外(但保留在日志里)
  • surfaceOp 标记一个事件如何进入 surface,只允许出现在上面四种事件上:'append',或 { op:'replace', startSeq, endSeq }(闭区间、两端都必须是当前 surface 节点,且 sourceEventSeqs 必须覆盖全部被遮蔽节点)

两个读取口径,别混:

谁读用哪个
模型(请求上下文)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.snapshotEvents(fromSeq?, toSeqExclusive?)物化半开区间的冻结快照;整段当前快照会被缓存到下次 append
session.eventAt(seq)按 seq 读取单个已接受、深冻结的事件
session.seq / session.id当前日志长度(下一条事件的 seq)/ 只读标识
session.headerdetach、深冻结的创建元数据
session.surface只读 surface 视图
session.inheritedEventCount / session.ownEvents() / session.isOwnSeq(seq)fork 继承前缀长度 / 子会话自有事件 / 是否自有位置
session.firstLiveSeq本进程首次 append 的 seq(构造 seed 长度)

头信息与事件分离​

内容是否可回放
SessionHeader版本、id、createdAt、可选 cwd/parentSession、isSeeded、可选 origin/delegationDepth/agentPreset创建元数据,写入时 detach + 深冻结,运行时不可变;精确的继承前缀长度是 Session 状态(inheritedEventCount),不是 header 字段
SessionEvent可回放的对话状态可回放

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

request/header 记录一次请求的完整规范快照(非历史请求信封),reason 是四值:initial / resume / change / series(series = 信封未变但显式开启新的消息序列,或紧跟在 surface 替换之后)。

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

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

六、事件信封字段(按事件类型可选)​

字段含义
seq单调递增的持久化排序键
timeUnix epoch 毫秒
sourceEventSeqs?: SessionSeq[]引用的源事件 seq(如压缩替换背后的被遮蔽条目)。只存在于 surface 事件上;assistant/message 禁止该字段(它自带 provider 流)
surfaceOp?: SurfaceOp事件如何进入 surface;非 surface 事件(边界/attempt/错误)不带
ignorable?: true读到不认识的类型可以安全跳过;缺失 = 必选,未知类型会拒绝会话重建

事件词汇与扩展​

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

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

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

kind时机
completed正常完成
aborted活 turn 被打断,reason: AgentCancelCause(保留类型化取消原因)。旧格式导入成 { kind:'aborted', reason:{ kind:'legacy' } }
blocked被策略/守卫阻断
errorturn 失败,{ kind:'error', error }
max-tokens至少一个 step 撞到输出 token 上限
interrupted仅崩溃恢复合成(找不到别的证据)

崩溃恢复:冷 load 用 interruptedTurnClosers() 合成收尾事件——未配对的 tool call 先补 tool/result 错误(TOOL_NOT_STARTED/TOOL_OUTCOME_UNKNOWN),再补 step/end,最后补 turn/end { kind:'interrupted' };所以恢复出的会话不会有"悬空 turn"。

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

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

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

九、持久化后端:JSONL​

session-persistence-jsonl 是唯一的官方 SessionPersistence provider,配置面只有两项:

配置默认作用
root无(必填无默认)会话日志根目录,通常 $DSH_HOME/sessions
compressionzstdzstd(校验帧压缩)或 none(纯文本 JSONL)

写合并窗口不再是配置:它是 seam 在每个 write handle 内的内部调度策略。

磁盘布局(当前逻辑格式 V3 → 文件名带 v3):

~/.dsh/sessions/--<归一化cwd>--/<encoded-id>/session.v3.jsonl.zstd # 当前世代
~/.dsh/sessions/_no-cwd/<encoded-id>/session.v3.jsonl.zstd # 无 cwd 的会话
  • 每个会话目录保留不可变的历史世代:session.jsonl[.zstd] = released v0,session.v1.jsonl[.zstd] = released v1,session.v2.jsonl[.zstd] = released v2,当前是 session.v3.jsonl[.zstd];运行时选数字最高的世代
  • 默认 zstd 压缩;要直接 head/jq 就配置 compression: 'none' 或先 zstdcat
  • 两级目录:--<cwd>--(工作区,缺失时 _no-cwd)+ <encoded-id>(会话 id 注入式转义成单个安全路径段)
  • 每个会话同时只能有一个写者:in-process 认领 + session.lock 上的非阻塞 flock(2)(Windows 用命名内核信号量);首个 append 用 link() 无覆盖发布,POSIX 需要预编译的 node-addon-system

十、持久化格式与迁移​

会话日志有逻辑格式版本(当前 SESSION_FORMAT_VERSION = 3)与按版本命名的物理世代。历史日志由一组纯库(非 cordis 插件)迁移到当前格式:

包角色
session-format相邻格式规划、无损 JSON 校验、header-only 迁移与物理编解码调度的纯库
session-format-v0-to-v1冻结的 released-v0 解码器 + 到 v1 的恒等迁移(拒绝 legacy delta 事件与已移除的 fallback reason)
session-format-v1-to-v2冻结的 released-v1 解码器 + 基数变化迁移:把顶层 assistant/chunk 嵌进 assistant/message,并为失败/重试/取消的尝试补 assistant/attempt
session-format-v2-to-v3新增:把系统提示提升为 system/message、重映射本地事件引用、翻译 PTC 与 preset 名、规范化事件信封
session-format-catalogbuild-static 目录:在模块初始化时校验 v0→v3 无缺口链,并暴露物理分发与当前编码器

迁移触发点在打开历史世代时:open(id,'read') 只在内存里解码迁移、不发布后继文件;open(id,'write') 会校验后把当前世代 session.v3.jsonl[.zstd] 发布到同目录,源文件保持逐字节不变。没有 CLI/配置开关;无法忠实解释的日志以 SessionFormatUnsupportedError 拒绝且源文件不动。迁移步骤与回退见 升级与迁移。

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

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

在三个边界做 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,per-record 布局;shipped 组合的 json 后端把每条记录写在 <root>/session_projcache/sessions/<id>.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 生命周期(存 formatVersion/createdAt/cwd/isSeeded/inheritedEventCount),每次读校验;删了重建的 id 或换掉的 store 会丢弃无关 record,不会种 phantom 值
  • log 在前、cache 跟随:live checkpoint 先把 buffered events durable flush 再落 cache row;crash 只能让 cache 落后于 log,不会超前

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

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

writeEveryEvents / writeIntervalMs 都必填无默认。读阶梯:零 I/O 的 cachedSnapshot(meta, inheritedEventCount, keys?)(只读 identity 匹配、version 匹配的 stored record)与 coldSnapshot(meta, inheritedEventCount, events)(调用方按 seq 传入完整日志;内部 cache → ctx.sessionProjections.restoreFloor → restore → fail-soft 写回)。

挂载状态:默认挂载(Web 组合里 writeEveryEvents: 200、writeIntervalMs: 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 只有两个值;FULL 会被拒绝,不是别名:

mode行为
FEEDBACK_ONLY(默认)文字反馈、评分创建/编辑、备注编辑与撤回都会通过该 canonical feedback 事件放行未交付前缀(含上下文);之后的 record 等下一个 feedback,没有就留本地
DISABLED不构造任何 pipeline,无 record 出进程;live feedback 本地告警,冷变更静默

它按原样组合 OTel JS SDK(LoggerProvider → BatchLogRecordProcessor → OTLP/HTTP log exporter),把每个 record map 到 logger.emit(),instrumentation scope 是 @deepseek-ai/dsh-session-telemetry-otel。Resource identity 含 service.name/service.version(来自 dsh-llm 的 APP_IDENTITY)加匿名 user.id($DSH_HOME/.anonymous-user-id,随机 UUID)。

上传授权 fail-closed:exporter.url 缺失/非法、processor.maxExportBatchSize 非正整数、shutdownTimeoutMillis 非法都在插件加载时失败;直接 ctx.sessionTelemetry.emit() 在任何 mode 下都是 no-op,无法绕过 feedback 授权;只有新的自有 feedback/record/feedback/message-put/feedback/message-delete 事件触发 live 捕获(继承父会话的 feedback 不授权子会话导出)。seam 通过 ctx.sessionTelemetry.sharing(SessionTelemetrySharingStatus,本后端只会是 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 默认 FEEDBACK_ONLY;endpoint 由 exporter.url 提供,出厂缺省 https://harness-telemetry.deepseeksvc.com/v1/logs,可用 DSH_TELEMETRY_OTLP_URL 覆盖,DSH_TELEMETRY_MODE 改 mode,DSH_TELEMETRY_DISABLED 任意非空值关闭)。

十五、损坏与格式策略​

场景策略
撕裂尾部碎片丢弃
已提交损坏 / 格式错误SessionPersistenceCorruptionError 拒绝
已发布历史格式(v0/v1/v2)由格式目录迁移到当前 v3
比当前更新的格式版本SessionFormatUnsupportedError 拒绝,提示升级 harness
未知事件类型(非 ignorable)SessionFormatUnsupportedError 拒绝
未知事件类型(ignorable)跳过

十六、验证​

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

# 会话日志是压缩 JSONL(zstd),先解压再读,一行一事件(当前世代是 session.v3.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

下一步​