会话系统
一句话版:会话是事件溯源的:
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.header | detach、深冻结的创建元数据 |
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 替换之后)。
- 可选
adapterDefaultsmap:标记 effectivereasoningEffort/maxTokens是精确模型解析物化的值,让下一次请求提案能区分它们与显式会话设置:这正是 Agent 主循环 里"adapter-default 标记"的持久化落点 foldRequestHeader()选最近快照;历史格式里的 legacy delta 事件与已移除的fallbackreason 由迁移包session-format-v0-to-v1在读取时拒绝
user/message 存完整 UserMessage(在 inbox 路由 / step 进入前就已创建身份);它的 content 原样渲染,source 是区分"人工 prompt / 合成注入 / 进入的 goal round"的唯一通道。
六、事件信封字段(按事件类型可选)
| 字段 | 含义 |
|---|---|
seq | 单调递增的持久化排序键 |
time | Unix 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 | 被策略/守卫阻断 |
error | turn 失败,{ 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 |
compression | zstd | zstd(校验帧压缩)或 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-catalog | build-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