# Agent Note: 回放状态与组装内容按构造对齐

Status: implemented

[English](2026-08-15-max-token-replay-state-alignment.md) | 中文

## 问题

pi-ai 为每个响应记录一个从提供方原生消息投影而来的不透明回放数据，而 `BlockAssembler.blocks()` 会另行从 `max-tokens` 响应中丢弃工具调用，因为被截断的调用不能安全执行。持久化的 assistant 消息因此把变换后的内容与描述未变换原生块清单的元数据存在一起。下一个请求在历史重建阶段以 `INVALID_REPLAY_STATE: block count does not match assistant content` 失败；由于不一致已经落盘，该会话之后的每个请求都以同样方式失败——会话被永久卡死。根因是结构性的：同一响应的两种表示在流水线的不同位置各自拍摄快照，其索引对齐只靠读取时的硬错误来维持。

## 决定

两处改动，各覆盖持久化边界的一侧。

**写侧——一次保留/丢弃决定。** finish 分片的 `replayState` 变为有类型的 `ReplayEnvelope`：一个不透明的 `response` 半区，加上与发射块序列对齐的可选不透明逐块条目。`BlockAssembler` 只计算一次保留/丢弃决定，并把它同时应用于块和逐块条目，因此组装执行的任何变换——max-token 工具调用丢弃或未来的其他变换——都按构造裁剪掉对应元数据。保留的块保留其条目，所以被截断的响应仍为其保留的推理（reasoning）与文本保有签名。条目数与发射块数不一致的数据整体丢弃（发射不当的适配器不得发布归属错误的元数据）。pi-ai 把原先的平铺状态拆为版本 2 的 response 半区和逐块签名条目。

**读侧——持久化内容是权威记录。** `toPiAssistant` 把回放状态当作保真度元数据，而非承重输入：读取方无法使用的任何状态——其他适配器的 kind、其他版本（包括已落盘的平铺版本 1 形式）、格式错误的元数据、或与内容不再匹配的块结构——都把这一条消息降级为既有的外来提供方无关转换，并通过插件的 `onReplayDegrade` 钩子（logger 警告）上报 `INVALID_REPLAY_STATE` 诊断。请求继续执行。正是这一点让本次改动之前已被毒化的会话得以继续而不是永远报错，也把未来一切分叉源约束为单条消息的保真度损失。

## 验证

组装器单元测试证明裁剪、错位丢弃、以及未变换与无逐块条目数据的透传。pi-ai 单元测试证明版本 2 数据的往返，以及先前每个抛错的无效状态用例现在都降级为外来转换并带出诊断。agent loop 回归用例驱动一个被截断的文本加工具调用响应穿过持久化，并证明后续请求携带裁剪后的数据。无密钥真实组合测试通过 loader 启动 `dsh-llm-pi-ai`，证明截断后不带 `tool_calls` 的原生续聊，以及在块数不再匹配的旧平铺状态消息之上成功续聊。手工编写的无密钥快照场景 `max-tokens-continue` 通过真实 ACP 子进程路径钉住组装应用的持久化日志——截断轮次、存储消息上裁剪后的数据、以及继续的轮次。

## 已考虑的替代方案

**组装丢弃工具调用时抑制整个回放状态。** 对唯一已交付的变换有效，但在 `blocks()` 旁边重新推导丢弃条件（两处会无声漂移），丢掉保留块的有效签名，并让读取时的分叉——首当其冲是已落盘的旧会话——仍然是硬错误。

**保留状态并放宽 pi-ai 的块数校验、能贴多少贴多少。** 否决：索引对齐的签名贴到不同的块清单上，会向提供方呈现虚假的原生历史。降级则什么都不贴。

**让每个适配器在组装后改写自己的状态。** 否决：这把义务压给持有不透明数据的适配器；信封只把恰好需要的结构——不多一分——纳入共享词汇，组装器的单一决定即可机械完成改写。

## 影响

包含工具调用的 max-token 响应之后的续聊可以工作，保留块保有原生签名，并作为原生 pi-ai 消息回放。本次改动之前记录的会话，其受影响的 assistant 消息作为提供方无关内容回放（带诊断）而不是让轮次失败；`replayState` 落盘形状在预发布无兼容承诺立场下发生变化，旧平铺形式由同一降级路径处理。对不可用状态而言，这取代了[提供方路由适配器决定](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md)中读取时硬错误的规则；校验本身不变，仍先于任何原生重建执行。
