Skip to content

Compression breaks Responses assistant history ordering, causing repeated invalid_assistant_history 400s #564

Closed

Description

@plyflai

现象

通过 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。

最小服务端复现步骤

  1. 使用上述两轮 Responses 输入,reasoning items 带固定的测试 encrypted_content。
  2. 启动真实 bili server 和本地 mock upstream;设置 preserveRecentMessages = 20,测试中将 minCompressRange / minSummaryLength 设为 0。
  3. mock upstream 的第一轮流式返回 compress 调用,范围从第一条 bash call 到第二条 bash call,摘要为 Inspected files A and B.。
  4. 捕获 bili 实际发出的内部第二轮 HTTP 请求,而不是只检查 kernel 返回值。
  5. 检查最终 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

  1. ranxianglei commented on Sep 5, 2026

    @ranxianglei
    Owner

    🤖 ework agent · qwen3.8-27b

    [bot] 🏷 接手 #564。先给结论,随后会附 PR。

    查重:与 #539 不重复(已逐条比对)。#539 是 re-request 时 reasoning_content 字段被丢弃(PR#540 已修,协议感知的签名守卫);#564 是既有的 Responses reasoning item 被保留但顺序错乱——两者根因不同、修复路径不同。无其他重复项。

    层次判断(根因 vs 现象):你定位到的两条路径我都已在代码里核实,且它们收敛到同一个不变量被破坏:

    • kernel 的 coreToResponses(bundled acp-kernel wire)按 CoreMessage 顺序逐项映射,reasoning item 原样回写 rawResponsesItem(保留 encrypted_content),所以最终 input 的 item 顺序 == CoreMessage 顺序。
    • OpenAI Responses 对单个 assistant run 的硬约束是 reasoning* → message* → function_call*,且一个 run 内不能出现第二个 reasoning。fold / stripKernelSummaries() 把原本隔开的两个 assistant 轮次合并进同一 run 时,就产生 reasoning follows assistant content(路径二)或 reasoning follows reasoning(路径一变体)。
    • 这是 Responses 专属:OpenAI chat 的 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。先占个坑,稍后更新链接。

  2. ranxianglei commented on Sep 5, 2026

    @ranxianglei
    Owner

    🤖 ework agent · qwen3.8-27b

    已定位并修复。PR: #565

    审核结论(先于实现)

    1. 不是 ACP loop re-request drops reasoning_content, DeepSeek returns 400 invalid_request_error #539 的重复。 ACP loop re-request drops reasoning_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 是已存在的 Responses reasoning item 被保留、但与 assistant 文本/工具调用的顺序被破坏。根因不同。

    2. 层次判断(症状 vs 病因)。 你给的两条路径都对,且都收敛到同一个最终状态:两个原本独立的 assistant 轮次在发往上游前被合并进同一个 run。上游报 reasoning cannot follow ... 是这个合并的表面现象;病因是「折叠移除轮次间分隔」+「stripKernelSummaries() 随后删掉最后那个 acp_summary 分隔符」两步叠加。所以只修 kernel fold 不够——必须在摘要去重之后、构造最终 input 之前再校验顺序。你的方向判断正确。

    3. 为何方案成立。 关键事实: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 所属轮次的正文/工具调用被整体折叠掉时,才删除这个失去关联内容的 reasoning;原本 reasoning-only 的轮次不因此删除;
    • 否则在合并的两组之间插入一条中性 user-role 说明消息,保留轮次区别。

    签名 reasoning 原样保留(encrypted_content 不动),不改写原始历史与压缩块。

    回归测试

    tests/issue564-responses-ordering.test.ts:

    • 端到端:驱动真实 runCompressLoop + responses adapter 走完一次 live compress,断言的是实际发出的 re-request body(fetch probe 捕获),而非去重前的 fold 输出——正是你要的「针对最终发往上游的请求」。
    • 单元:orphaned reasoning 删除、reasoning-only 轮次保留(两组仍分离)、存活 acp_summary 载体不被误加分隔。

    本地复现:修复前该 e2e 确定性失败于 item N reasoning follows another reasoning Item,与你遇到的 rs_b follows assistant content 同型;修复后通过。

    Pre-flight

    • npm run typecheck ✓
    • npm test 1051 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

  3. added a commit that references this issue on Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions