工具调用最危险的错觉,是把 await tool.execute() 当作唯一事实。一条付款、发信或改文件操作至少有四个时刻:模型产生调用;runtime 持久化“准备调用”;外部世界接收动作;runtime 持久化结果。进程可以在任意相邻时刻之间退出,这些空隙决定了恢复时能不能自动重试。

DSH 没有承诺 exactly-once。它做的事情更诚实:用 SessionEvent 保存意图和结果,用 checkpoint policy 在副作用前强制 durability,用 approval 与 sandbox 分别管理授权和执行边界,再用 repair 把崩溃尾部分类为“未开始”或“结果未知”。

阅读契约。读完以后,你应该能回答:为什么 durable tool/call 必须早于工具体;为什么 append() 不等于落盘;ask、never 和 allowed-once 分别在哪里生效;approval 为什么不能代替 sandbox;以及为什么看到 TOOL_OUTCOME_UNKNOWN 时绝不能盲目重放副作用。

证据边界。本文固定在提交 ddefc45。第一方 JSONL persistence 和 checkpoint policy 可以组成本文的 durability 路径;自定义 backend、answerer、sandbox provider 与第三方工具仍须各自兑现接口。源码没有把它们自动变成事务系统。

一、先记录调用,再允许执行

1.1 assistant message 和 tool/call 是两份不同证据

Agent loop 先把含有 tool calls 的 assistant message 追加到 Session。随后 executeToolCalls() 为每一项追加 tool/call 事件,再进入 prepare 与 guard;真正的工具体拿到控制权前,tools/execute checkpoint 才把这段前缀变成 durable。settlement 完成后,结果再作为 tool/result 进入同一个事件源。

assistant message 保存模型提议,tool/call 则记录调度器开始处理的调用;其日志字段仍保留原始参数字符串,解析后的参数进入工具执行上下文。工具体只能在 flush 成功后运行,但后台批处理也可能更早落盘,所以 durable call 不证明工具已经开始。它只要求恢复端保守地承认:动作可能已经发生。

Agent Session 的三处独立持久化检查点:agent/pre-step、每次 llm/stream 首次消费前、最外层 tools/execute,各自调用 session/flush;嵌套工具复用外层检查点,flush 失败不派发下游

1.2 工具管线保留一个不可绕过的顺序

tools runtime 先运行 tools/pre-execute waterfall。gate 可以返回 allow、deny、ask 或 cancel;approval seam 把交互结果收敛为 allowed-once、rejected、cancelled 或 unavailable。pre-execute 之后还有 monotonic guard,确保较弱的后续决策不能把已关闭的门重新打开。只有通过后才进入 tools/execute,最后运行 post hook。

Checkpoint policy 只拦最外层调用:exec.agent 已定义且 exec.parent 未定义。嵌套 dispatch 复用外层已经建立的 checkpoint,避免每一层都重复 flush,也避免子工具绕开最外层的事实边界。

ctx.on('tools/execute', async (exec, next) => {
  if (exec.agent === undefined || exec.parent !== undefined) return next()
  await ctx.sessions.flush(exec.agent.session)
  if (exec.signal.aborted) return abortedBeforeDispatchResult()
  return next()
})

二、append 不等于 durable

2.1 Persistence backend 和 checkpoint policy 是两层能力

Session 的 append() 建立内存中的原子事件提交;persistence plugin 可以 write-behind。两者解耦让不同 backend 自由批处理,但也意味着“日志数组里已经看见”并不代表 crash 后还能读到。session-checkpoint-policy 单独定义语义 checkpoint:继续下游之前,哪些先前事件必须跨过存储边界。

2.2 三个 barrier 分别保护三种不可逆前进

  1. agent/pre-step:开始下一推理 step 之前,上一阶段事实先 durable。
  2. 关联有效 Session 的每次 llm/stream:返回的流首次被消费时,真正向 provider 发请求前才 lazy flush,避免没有网络动作时的无谓同步。
  3. 最外层 tools/execute:工具体看到控制权前,含 tool/call 的前缀必须 durable。

session/flush 在下游 adapter 或工具体之前完成;失败时 fail closed。若等待 flush 时取消,policy 返回 ABORTED_BEFORE_DISPATCH,而不是假装工具已经运行。这个顺序使“durable call 但无 result”具有稳定含义。

三、Approval 和 sandbox 解决不同问题

3.1 Approval 决定“这一次是否允许”

user-approval 提供 channel-neutral 的一次性请求 ctx.approval.request(req)。ApprovalPolicy 只有 ask 与 never:ask 才会派发 approval/request;never 在交互前直接拒绝。请求必须处于开放 turn 内;approval/asked 与 approval/decided 成对记录审计。模型不读取这两条事件,但能看到工具结果,以及追加到保留历史之后的当前策略快照。

缺少 answerer、answerer 抛错、请求被取消都 fail closed。策略也可由日志中的 approval/policy 覆盖,最后一条事件胜出。审计 append 失败会使请求拒绝,不能返回一项未记录的授权。策略切换也会为下一步追加来源明确的通知,而不是重写稳定的请求前缀。这个 seam 没有内置 answerer、没有“永久允许”,请求也不携带完整工具参数;它刻意保持窄而可审计。

3.2 Sandbox 决定“即使允许,也只能怎样执行”

审批与隔离各守边界:ask 发起 approval/request 后可得到 allowed-once 或拒绝,never 直接拒绝;受限模式通过 ctx.sandbox.confine 隔离,不可用时返回 SANDBOX_UNAVAILABLE;danger-full-access 显式绕过隔离

SandboxProvider 支持 read-only 与 workspace-write 的受限执行;ctx.sandbox.confine(argv, policy) 返回包装后的 argv 和执行完整度。没有可用 backend 时以 SANDBOX_UNAVAILABLE 拒绝。danger-full-access 则明确绕过隔离,由调用方执行原 argv;它不是一种被 sandbox 保护的模式。

本地 provider 在 Linux 优先 bubblewrap、再尝试 Landlock;macOS 使用 Seatbelt;Windows 使用 ACL/restricted-token 路径,Windows 与 Landlock 可能报告 partial。它限制的是同一宿主世界中的文件副作用,不是 container 或 microVM。用户点了允许,不代表命令获得无限系统权限;命令被 sandbox 包住,也不代表用户已经授权。

四、JSONL 怎样把 checkpoint 变成可恢复前缀

4.1 一帧 header,一帧 durable batch

JSONL 当前 v3 日志:旧格式先解码迁移,read 只返回逻辑事件,write 验证后发布当前代;header 与 batch 独立编码,单 writer 持有写入权

JSONL backend 当前写 session.v3.jsonl.zstd,旧 v0 文件才叫 session.jsonl.zstd。每代仍由一个独立校验的 header frame 加多个 durable batch frame 构成,也可选 raw JSONL。存储 handle 持有写入序列与 live-event buffer:内部固定的 200ms 窗口合并事件(不再是配置项),session/flush 绕过窗口并排空后端;这不是 append 到内存时就完成 fsync。

4.2 首次发布、追加失败和 torn tail 都有明确边界

create(header) 先返回写 handle,不立即建文件;首次 append 将 header 与事件写进临时文件并 fsync。显式 flush 一个空 session 也会发布只有 header 的文件。POSIX 用不覆盖目标的 hard link 发布并 fsync 父目录,Windows 走 write-through rename;后续追加各自 fsync,捕获写入或同步错误后 truncate 回原长度。

读取时保留 torn final frame 中完整解码的 records;写 handle 在下一批写入前截断 torn bytes 并把这些 records 持久化重写,resume 再追加必要的合成 closers。完整 frame 的校验、解压或结构损坏会拒绝恢复。写 handle 在进程内独占,跨实例和进程还有内核锁:POSIX 使用 session.lock 上的 flock,Windows 使用命名 semaphore;锁持有进程退出后释放,活着但卡住的进程仍阻止第二个 writer。

4.3 读取旧格式与发布新格式是两件事

迁移发布路径选择编号最高的 canonical generation。open(id, 'read') 可把支持的旧格式解码、迁移并验证成当前逻辑事件,但不发布新文件;open(id, 'write') 才编码同目录临时文件,经过 Worker Thread 验证和来源 revision 复核后,无覆盖地发布当前 generation。旧文件逐字节保留;来源发生变化时拒绝这次发布,不拿旧准备结果覆盖新历史。

因此 session.v3.jsonl.zstd 是当前写目标,旧代是迁移证据,不能理解成可随意回退的副本。迁移保留配置的压缩方式,不提供 zstd/raw 自动转换;后端也不负责删除会话文件。

五、Crash repair 必须区分未开始与结果未知

5.1 日志前缀提供恢复判断的证据

DeepSeek Harness crash outcome matrix:assistant 有调用但无 durable tool call 为 TOOL_NOT_STARTED,可按需重试;durable tool call 无 result 为 TOOL_OUTCOME_UNKNOWN,只读或幂等调用可考虑重试,否则先核验外部状态;修复不会自动重放;已有 durable result 则正常 resume
崩溃前 durable 前缀修复结果恢复建议
assistant 含 tool call;没有 tool/callTOOL_NOT_STARTED确认仍有需要后可重新 dispatch
有 tool/call;没有 tool/resultTOOL_OUTCOME_UNKNOWN只读或幂等时可重试;否则先查询外部状态或询问用户
tool/call 与 tool/result 都在正常完成前缀从下一步 resume

repair.ts 会合成必要的 tool result、step/end 与 interrupted turn/end,并保持 seq 连续。它只补齐日志,不自动重放工具,也不设置强制隔离。错误结果中的恢复建议让模型和 runtime 看见不确定性,再按工具语义决定后续动作。

5.2 callId 是幂等支点,不是 exactly-once 魔法

对有副作用的工具,DSH 建议把 exec.callId 传给支持幂等键的外部系统。这样一次未知结果可以通过查询或同键重试收敛。但如果目标系统不支持幂等、查询或事务,runtime 不能凭本地日志证明动作只发生一次。read-only 或天然幂等的调用可以更积极地恢复;付款、发信和删除必须保守。

六、这条执行路径带来的工程判断

  1. 第一方 durability 要同时装 backend 与 policy。只有 persistence 没有 checkpoint,副作用可能领先 durable 日志;只有 policy 没有兑现 flush 的 backend,也没有实际保护。
  2. Approval 不是 sandbox。前者回答“是否同意”,后者限制“能碰什么”;两道门缺一不可。
  3. Fail closed 是明确的可用性取舍。answerer、sandbox 或 flush 不可用时停止,会丢掉一次机会,却不会偷偷扩大权限或制造不可解释的动作。
  4. 未知结果按工具语义决定恢复。恢复逻辑不能用“本地没结果”推断“外部没发生”。
  5. 自定义工具要传播 callId。能把本地调用身份接到外部幂等键,才有机会把 at-least-once 风险收敛成可验证结果。

下一篇会处理另一个看似无害的 mutation:compaction 怎样替换模型可见 surface、裁剪旧 tool result,却仍让原始事件日志和 request header 足以重建一次请求。

参考源码