Repository navigation
Compression breaks Responses assistant history ordering, causing repeated invalid_assistant_history 400s #564
Description
现象
通过 bili 使用 Responses-compatible upstream 时,压缩成功后,内部 re-request 或后续历史重放可能返回 400 invalid_assistant_history。实际遇到过两种错误:
input Item 9 reasoning cannot follow another reasoning Item; supported assistant Item order is reasoning, message content, then function calls
input Item 56 reasoning cannot follow assistant message content
当重试仍使用同一份损坏的 folded history 时会重复失败;并非所有压缩都会触发,也不意味着会话永久不可恢复。另一次改变折叠结果的压缩后,后续请求曾恢复成功。
版本与范围
- 对照主分支
020d455(v0.1.86),使用acp-kernel@0.0.53。 - 真实错误来自本地集成分支,并非声称在未经修改的发布包上完成了全部复现。
- 问题涉及 kernel fold 后的 assistant 分组,以及
src/server.ts的stripKernelSummaries()/refreshFolded()调用顺序。 - 与 ACP loop re-request drops
reasoning_content, DeepSeek returns 400 invalid_request_error #539 不同:这里已有的 Responses reasoning items 仍然存在,损坏的是它们与 assistant 文本、工具调用之间的顺序,不是新生成的reasoning_content字段被遗漏。
路径一:折叠工具交互后,原本独立的 assistant 轮次合并
示意输入,所有标识和内容均为最小测试数据:
user
reasoning A
assistant text A
function_call bash_a
function_call_output bash_a
reasoning B
assistant text B
function_call bash_b
function_call_output bash_b
当 reasoning / assistant text 被保护,而范围中的 bash 调用可以压缩时,工具调用被移除、孤立工具结果被清理,原本隔开的 assistant 轮次可能直接相邻:
reasoning A -> assistant text A -> reasoning B -> assistant text B
另一个变体是 reasoning 所属轮次的正文及工具调用全部消失,只留下 reasoning;折叠后可以出现相邻的 reasoning items。原始合法历史因此变成上游拒绝的输入。
路径二:摘要去重再次删除轮次分隔
即使在 core.processTurn() 返回时已经维持了轮次分隔,后续去重仍可能破坏它。
实际定位到的折叠尾部结构:
reasoning A
assistant text A
acp_summary_<block>
reasoning B
assistant text B
refreshFolded() 随后追加当前 compress loop 的记录。当 active block 的 compressCallId 对应调用已存在时,stripKernelSummaries() 删除重复的 acp_summary_<block>。这是避免重复摘要所需要的处理,但这个摘要恰好也是两轮 assistant 之间唯一剩余的分隔。
去重后发往上游的序列变成:
reasoning A
assistant text A
reasoning B <- reasoning follows assistant content
assistant text B
...
compress call / result
因此只在 kernel fold 后修复还不够;需要检查摘要去重之后的最终输入。位于尾部的 compress call/result 不能隔开更早的两组 assistant items。
最小服务端复现步骤
- 使用上述两轮 Responses 输入,reasoning items 带固定的测试
encrypted_content。 - 启动真实 bili server 和本地 mock upstream;设置
preserveRecentMessages = 20,测试中将minCompressRange/minSummaryLength设为 0。 - mock upstream 的第一轮流式返回
compress调用,范围从第一条 bash call 到第二条 bash call,摘要为Inspected files A and B.。 - 捕获 bili 实际发出的内部第二轮 HTTP 请求,而不是只检查 kernel 返回值。
- 检查最终 Responses input 中 reasoning、assistant content、function calls 的顺序。
本地回归在补上摘要去重后的修复前,确定性失败于:
AssertionError [ERR_ASSERTION]: reasoning rs_b follows assistant content
本地修复方向及验证
本地使用原始历史记录 assistant 分组,而不是仅凭折叠结果猜测:
- 只有当某 reasoning 原有的正文 / 工具调用全部被折叠移除时,才移除这个失去关联内容的 reasoning;原本 reasoning-only 的消息不因此删除。
- 被压缩掉的非 assistant 消息原本隔开的两组 assistant 内容,折叠后相邻时,插入说明中间交互已被压缩的 user-role 消息,保留轮次区别。
- 在
stripKernelSummaries()完成后再次恢复这些分隔,包括普通 prepare 路径和内部 re-request。保留 reasoning items 的原始内容及encrypted_content,不合并或改写签名内容,也不修改原始历史与压缩块。
这是本地可工作的处理方式,最终实现可放在合适的 kernel / host 层;关键是摘要去重之后仍然保持原始 assistant 轮次关系。
本地新增回归覆盖:
- kernel fold 及持久化重放中的 detached reasoning / assistant 分组。
- 真实 server 执行 compress 后的内部 re-request。
- 不携带 compress call 的普通后续请求,保留摘要载体。
- 携带 compress call 的 plugin 请求,去重摘要后仍保持有效顺序。
- 工具调用与结果成对移除,compress call/result 保留,原始输入和保留的签名 reasoning 不变。
本地执行:
node --import tsx --test tests/compression-reasoning-pairs.test.ts tests/compression-summary-boundaries.test.ts结果:12 passed,0 failed。这两个测试文件目前属于本地修复,尚未提交到本仓库。上述服务端回归使用 mock provider;不将其等同于修复后的真实上游端到端验收。
期望行为
压缩和摘要去重不能把原本合法、独立的 assistant 轮次变成同一轮非法历史。应针对最终发往上游的请求增加回归,而不只验证去重之前的 kernel fold 输出。
Activity
ranxianglei commentedon Sep 5, 2026
[bot] 🏷 接手 #564。先给结论,随后会附 PR。
查重:与 #539 不重复(已逐条比对)。#539 是 re-request 时
reasoning_content字段被丢弃(PR#540 已修,协议感知的签名守卫);#564 是既有的 Responsesreasoningitem 被保留但顺序错乱——两者根因不同、修复路径不同。无其他重复项。层次判断(根因 vs 现象):你定位到的两条路径我都已在代码里核实,且它们收敛到同一个不变量被破坏:
coreToResponses(bundled acp-kernel wire)按 CoreMessage 顺序逐项映射,reasoning item 原样回写rawResponsesItem(保留encrypted_content),所以最终 input 的 item 顺序 == CoreMessage 顺序。reasoning* → message* → function_call*,且一个 run 内不能出现第二个 reasoning。fold /stripKernelSummaries()把原本隔开的两个 assistant 轮次合并进同一 run 时,就产生reasoning follows assistant content(路径二)或reasoning follows reasoning(路径一变体)。coreToOpenai会把连续 assistant 消息合并成一条(reasoning_content是字段不是独立 item),Anthropic 强制 user/assistant 交替,所以只有 Responses 会踩到。方案评估:你给的本地方向(用原始历史做 assistant 分组真值、detached reasoning 才删、否则插 user-role 分隔、且在
stripKernelSummaries()之后对 prepare 和 re-request 两条路径都恢复分隔、保留签名 reasoning 不动)我认为是打到核心的——它保证的是"摘要去重之后发往上游的最终输入"仍然保持原始轮次关系,正是 issue 期望行为要求的验收点,而不是只验去重前的 fold 输出。我会在 host 层实现(避免动 acp-kernel 依赖/发布链路),并补上针对最终请求的回归。正在做:用真实
processTurn + stripKernelSummaries + runCompressLoop + buildRequest管线复现(先红后绿),然后提 PR。先占个坑,稍后更新链接。ranxianglei commentedon Sep 5, 2026
已定位并修复。PR: #565
审核结论(先于实现)
不是 ACP loop re-request drops
reasoning_content, DeepSeek returns 400 invalid_request_error #539 的重复。 ACP loop re-request dropsreasoning_content, DeepSeek returns 400 invalid_request_error #539 是 re-request 时reasoning_content字段被丢弃;Compression breaks Responses assistant history ordering, causing repeated invalid_assistant_history 400s #564 是已存在的 Responsesreasoningitem 被保留、但与 assistant 文本/工具调用的顺序被破坏。根因不同。层次判断(症状 vs 病因)。 你给的两条路径都对,且都收敛到同一个最终状态:两个原本独立的 assistant 轮次在发往上游前被合并进同一个 run。上游报
reasoning cannot follow ...是这个合并的表面现象;病因是「折叠移除轮次间分隔」+「stripKernelSummaries()随后删掉最后那个acp_summary分隔符」两步叠加。所以只修 kernel fold 不够——必须在摘要去重之后、构造最终 input 之前再校验顺序。你的方向判断正确。为何方案成立。 关键事实:Responses 的最终 item 顺序 == CoreMessage 顺序(
coreToResponses按序逐项映射),而 OpenAI chat 会把连续 assistant 消息合并成单个对象,所以这是 Responses 专属。因此修复放在 host 层(src/server.ts),不碰 kernel,避免跨仓库发版依赖(AGENTS.md §5)。实现
新增
repairResponsesAssistantOrdering(folded, original),在全部三个 Responses wire 构造点、且在摘要去重之后调用:prepareResponses(初始请求 / plugin-mode 回放)prepareResponsesCompact(回给客户端的 compact 结果)refreshFolded(压缩后的内部 re-request)按原始客户端历史重建轮次边界:
签名 reasoning 原样保留(
encrypted_content不动),不改写原始历史与压缩块。回归测试
tests/issue564-responses-ordering.test.ts:runCompressLoop+ responses adapter 走完一次 live compress,断言的是实际发出的 re-request body(fetch probe 捕获),而非去重前的 fold 输出——正是你要的「针对最终发往上游的请求」。acp_summary载体不被误加分隔。本地复现:修复前该 e2e 确定性失败于
item N reasoning follows another reasoning Item,与你遇到的rs_b follows assistant content同型;修复后通过。Pre-flight
npm run typecheck✓npm test1051 pass / 1 fail —— 唯一失败是launcher.test.ts的resolveClientCommand: codex/claude resolve to themselves,预先存在且环境相关(本环境装有/usr/bin/codex,解析返回全路径而测试期望裸名);已在 pristine master 复现同样失败,与本改动无关。npm run build✓未做
真实 codex 过 bili 的 E2E(
tests/e2e/e2e-codex.test.ts)需要本地 Responses-compatible upstream + codex binary,沙箱网络策略禁止 loopback upstream,无法在此执行;新回归直接锁定最终 wire 顺序作为替代验收。请 review:#565
Merge pull request #565 from ranxianglei/2026-09-06_responses-assista…
fix: keep Responses assistant history valid after compression (ranxia…
fix: fold round-2 renders the steady path's per-wire shape (ranxiangl…
fix: exempt Responses turn separators from the outbound-invariant jud…