DSH / Atlas
2026-08-15implementedbug-fix

Replay state aligns with assembled content by construction

回放状态与组装内容按构造对齐

pi-ai recorded one opaque replay blob per response, projected from the provider's native message, while `BlockAssembler.blocks()` separately dropped tool calls from a `max-tokens` response because a truncated call is unsafe to execute. The durable assistant message therefore stored transformed content next to metadata describing the untransformed native block list. The next request failed during history reconstructio

English

Problem

pi-ai recorded one opaque replay blob per response, projected from the provider's native message, while BlockAssembler.blocks() separately dropped tool calls from a max-tokens response because a truncated call is unsafe to execute. The durable assistant message therefore stored transformed content next to metadata describing the untransformed native block list. The next request failed during history reconstruction with INVALID_REPLAY_STATE: block count does not match assistant content, and because the mismatch was already on disk, every later request on that session failed the same way — the session was permanently stuck. The root cause is structural: two representations of one response were snapshotted at different pipeline points, with their index alignment enforced only by a read-time hard error.

Decision

Two changes, one per side of the durable boundary.

Write side — one keep/drop decision. The finish chunk's replayState becomes a typed ReplayEnvelope: an opaque response half plus optional opaque per-block entries aligned with the emitted block sequence. BlockAssembler computes its keep/drop decision once and applies it to blocks and envelope entries together, so any transformation assembly performs — today's max-token tool-call drop or a future one — prunes the matching metadata by construction. Retained blocks keep their entries, so a truncated response keeps signatures for the reasoning and text it kept. An envelope whose entries do not match the emitted block count is discarded whole (a misemitting adapter must not publish misattributed metadata). pi-ai splits its former flat state into a version-2 response half and per-block signature entries.

Read side — durable content is authoritative. toPiAssistant treats replay state as fidelity metadata, not as a load-bearing input: any state the reading build cannot use — another adapter's kind, another version (including the flat version-1 form already on disk), malformed metadata, or a block shape that no longer matches the content — degrades that one message to the existing foreign provider-neutral conversion and reports the INVALID_REPLAY_STATE diagnostic through the plugin's onReplayDegrade hook (a logger warning). The request proceeds. This is what lets sessions poisoned before this change continue instead of erroring forever, and it bounds every future divergence source to a fidelity loss on one message.

Verification

Assembler unit tests prove pruning, misalignment discard, and pass-through for untransformed and per-block-free envelopes. pi-ai unit tests prove the version-2 envelope round-trip and that every formerly-throwing invalid-state case now degrades to foreign conversion with the diagnostic. An agent-loop regression drives a truncated text-plus-tool-call response through persistence and shows the follow-up request carrying the pruned envelope. Keyless real-composition tests boot dsh-llm-pi-ai through the Loader and prove a native continuation without tool_calls after truncation, and a successful continuation over a legacy flat-state message whose block count no longer matches. The authored keyless snapshot scenario max-tokens-continue pins the assembled application's durable log — truncated turn, pruned envelope on the stored message, continued turn — through the real ACP subprocess path.

Alternatives considered

Suppress the whole replay state when assembly drops a tool call. Works for today's one transformation, but re-derives the drop condition beside blocks() (the two drift silently), discards valid signatures for the retained blocks, and leaves read-time divergence — legacy sessions on disk foremost — a hard error.

Keep the state and relax pi-ai's block-count validation to attach what fits. Rejected: index-aligned signatures attached to a different block list would present false native history to the provider. Degrading attaches nothing.

Teach each adapter to rewrite its state after assembly. Rejected as an adapter obligation with an opaque blob; the envelope moves exactly the needed structure — and nothing else — into shared vocabulary, and the assembler's single decision does the rewrite mechanically.

Consequences

Continuing after a max-token response that included a tool call works, retains the kept blocks' native signatures, and replays as a native pi-ai message. Sessions recorded before this change replay their affected assistant messages as provider-neutral content (with a diagnostic) instead of failing the turn; on-disk replayState values changed shape under the pre-release no-compatibility stance, with the old flat form handled by the same degrade path. This supersedes the read-time hard-error rule in the provider-routed adapter decision for unusable state; validation itself is unchanged and still precedes any native reconstruction.

中文

问题

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 落盘形状在预发布无兼容承诺立场下发生变化,旧平铺形式由同一降级路径处理。对不可用状态而言,这取代了提供方路由适配器决定中读取时硬错误的规则;校验本身不变,仍先于任何原生重建执行。