记录、回放与撤销
wire.jsonl 的版本迁移与修复、blobref 卸载、静默回放的恢复契约,以及基于分支树的撤销与压缩边界。
记录
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) 的步骤:
- 要求 loop 静止(
tryAcquireQuiescence())、没有进行中的压缩、dispatcher 已恢复完毕; wire.switchBranch({ turns, fromTurnId })从分叉点开出新分支,写入agent.switched,外加一条给旧读者看的兼容context.undo(见buildUndoSwitchRecords());dispatcher.restore()按新的可恢复链重建状态,并重置 loop 状态机;- 派发
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 与协议。