记录、回放与撤销

wire.jsonl 的版本迁移与修复、blobref 卸载、静默回放的恢复契约,以及基于分支树的撤销与压缩边界。

作者 David更新于 第 28 篇(共 33 篇)

记录

v1 的 packages/agent-core/src/agent/records/ 已随 v1 一起删除,记录层现在是 agent-core-v2 的 src/wire/:

packages/agent-core-v2/src/wire/
├── record.ts       WireRecord、AGENT_WIRE_RECORD_KEY = 'wire.jsonl'
├── journal.ts      IAgentJournal:追加、读取、分支切换
├── wire.ts         IWireService
├── wireService.ts  实现:迁移、修复、可恢复链
├── migration/      协议迁移 v1.1 … v1.5
├── repair.ts       损坏日志的截断修复
└── tree/           分支树(tree.ts / fork.ts)

每个 agent 把发生的事写成 WireRecord 流,落在 wire.jsonl——transcript 冷重建的唯一真相源。v2 里「会落盘的事」就是声明了 durable = true 的 Event2 类(比如 plan.revision),存储走 IAppendLogStore(persistence/backends/node-fs/appendLogStore.ts)。

Wire 协议版本迁移

export const WIRE_PROTOCOL_VERSION = '1.5';

记录格式会演进,而用户的旧会话必须还能打开。wire/migration/migration.ts 保留了与 v1 同名的一组函数:

  • isNewerWireVersion():记录来自更新的版本(降级打开)时,不迁移、不改写,原样读取。
  • resolveWireMigrations():算出从旧版到 1.5 要走哪几步(migrateV1_0ToV1_1 … migrateV1_4ToV1_5),缺一步就抛 WIRE_MIGRATION_MISSING。
  • migrateWireRecord():逐条套用。

没有 metadata 头的老日志一律按 1.4 处理,补上 migrateV1_4ToV1_5。

修复

新增的 wire/repair.ts 面对另一种现实:磁盘写满、进程被杀,日志尾部坏了。repairWireJournal() 先把原文件备份成 <key>.bak(wireJournalBackupKey()),再用 appendLog.rewrite() 只保留有效前缀,并记一条 corrupted wire journal truncated to its valid prefix 警告。v0.39.0 的 CHANGELOG 里有一条相关修复(#3281):会话日志被截断或损坏(比如磁盘写满)后无法恢复的问题已修复。

blobref:大对象卸载

agent/blob/agentBlobService.ts 定义 IAgentBlobService:offloadParts() / loadParts() / isBlobRef()。实现里,内容片段中 data URI 形式的媒体,base64 载荷达到 4096 个字符(DEFAULT_THRESHOLD)就写进 IBlobStore(agent 作用域下的 blobs),URL 换成 blobref:<mime>;<hash>;读回时查不到就换成 [media missing]。

理由不变:记录流要能被快速顺序扫描,混着几 MB 的 base64 图片会让重建慢到不可用。plan 文档走的也是同一个 blob 存储。

恢复的契约:从注释变成机制

v1 在 records/index.ts 里用注释声明「restore 只许重建内存状态,不许发 UI 事件」。v2 是零注释区,这条约定改由代码强制执行。

IEventDispatcher.restore()(state/eventDispatcherService.ts)的流程:restorePhase 置为 'restoring' → 先沿可恢复链(wire.readRestorable())回放可撤销的状态,再按完整日志回放其余状态 → rehydrateStates() → 置为 'ready',并只在第一次恢复后跑一遍 onDidRestore 钩子。回放时每条记录都以 silent 模式执行(executeEvent(event, true, undoable))。executeEvent() 提交完状态后遇到 silent 就直接返回,走不到后面的 wire.appendRecord()(durable 事件落盘)与 eventBus.publish()(observable 事件广播);fold 上下文的 emit 在 silent 下也直接返回(第 137 行):

  emit(event: Event2<any>): void {
    if (this.silent) return;
    this.owner.enqueue(event);
  }

回放既不会重复落盘,也派生不出新事件——恢复时想发 UI 事件,物理上做不到。配套约束还有:AgentModel 只允许在构造时用 on() 注册 applier,状态只能在 applier 里替换;持久化参与者必须在 restore 之前挂上;认不出的记录类型跳过并上报 WIRE_UNKNOWN_RECORD。

回放

v1 的 ReplayBuilder 类没有了,取而代之的是一个纯函数(agent/replayBuilder/fold.ts 第 40 行):

export function foldWireRecords(records: readonly WireRecord[]): WireReplayFold {
  const fold = new WireReplayFoldState();
  for (const record of migrateJournalRecords(records)) {
    fold.apply(record);
  }
  fold.finish();
  return { replay: fold.replay, toolStore: fold.toolStore };
}

先迁移、再折叠,产出 replay(消息、压缩、goal / plan / 配置 / 权限变化、审批结果)与 toolStore。node-sdk 的 v2 客户端恢复会话时就用它从 wire.jsonl 重建回放。一个细节:折叠到最后仍没有结果的工具调用,会被补上一条错误结果,内容以「Tool execution was interrupted before its result was recorded.」开头,提醒模型别假设它成功了。

撤销:从截断到分支

v2 的撤销不删记录,而是切分支。wire/tree/ 把日志看成一棵树(主干 MAIN_BRANCH = 'main')。IAgentConversationUndoService.undo(turns) 的步骤:

  1. 要求 loop 静止(tryAcquireQuiescence())、没有进行中的压缩、dispatcher 已恢复完毕;
  2. wire.switchBranch({ turns, fromTurnId }) 从分叉点开出新分支,写入 agent.switched,外加一条给旧读者看的兼容 context.undo(见 buildUndoSwitchRecords());
  3. dispatcher.restore() 按新的可恢复链重建状态,并重置 loop 状态机;
  4. 派发 context.undone 事件。

撤销边界还是那两条。fork.ts 的 computeForkLine() 先排除最后一条 context.clear 之前的全部记录,往回数回合时遇到压缩摘要就抛错(第 86 行):

    if (message.origin?.kind === 'compaction_summary') {
      throw new ForkLineError('compaction_boundary', turns - remaining);
    }

失败原因只有 'compaction_boundary' | 'insufficient' 两种。压缩是有损的,撤销到压缩之前在物理上就做不到;availability() 返回的 stoppedAtCompaction 让界面能提前告诉用户。TUI 的 /undo 注册在 tui/commands/registry.ts。

可视化调试

apps/vis(含 server 与 web)是会话与回放的可视化调试工具,由 kimi vis 启动,可带会话 id 直接打开。apps/kimi-inspect 则打 kap-server 的 /api/v1/debug 反射 RPC 面,现在还多了按 message protocol v3 渲染的会话视图,以及基于 IDebugLedgerService / IDebugGraphService / IDebugCascadeService 的 DI 视图。

两个调试器合计约 3 万行 TypeScript(v0.29.1 时本文统计是 1.7 万行)。agent 的行为是概率性的,没有好的回放与检视工具,排查「它为什么这么干」几乎不可能。

第 5 部分结束。下一篇进入 kap-server 与协议。

本页目录