Skip to content

fix(compaction): survive a provider request-body 413 while making room - #2

Merged
SparkofSpike merged 5 commits into
mainfrom
codex/compact-413-image-fallback
Sep 26, 2026
Merged

SparkofSpike merged 5 commits into
mainfrom
codex/compact-413-image-fallback

Conversation

@SparkofSpike

@SparkofSpike SparkofSpike commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

fix(compaction): survive a provider request-body 413 while making room

Summary

A provider request-body cap (HTTP 413) is a byte limit that the token-side
context budget cannot see: an image that costs a flat token estimate can still
spend megabytes of base64, and a summary ("making room") request carries the
whole history. Compaction therefore failed exactly when it was needed most —
and once it failed, no later pass could clear a history stuck behind the cap.
Found while diagnosing live HTTP 413 failures during compaction on a session
whose history held tens of megabytes of inline images.

Changes

  • Byte-side shrink rung: image_attach::shrink_images_for_request
    re-encodes every inline image under a 2 MiB total budget (per-image share with
    a 96 KiB floor; alpha stays PNG, everything else becomes JPEG; longest edge
    1024 px, halved down to 128 px). Rewrites both carriers — image_url blocks
    and the stored tool-result shape the wire projection reads back out.
  • Text-note rung: image_attach::replace_images_with_placeholders swaps
    the images for in-band notes (what was there, roughly how large, and an
    instruction not to invent what they showed) for that one summary pass only.
    Session history keeps the real images; only the outbound request changes.
  • Ladder in create_summary: RequestSizeLadder (Start → ImagesShrunk →
    ImagesReplaced) gives a refused summary call two byte-side retries before it
    fails, and the failure text names the rung it reached. A byte rejection takes
    precedence over the context-window (drop-oldest) ladder, because a byte limit
    is not a token limit. Images that are present but already fit the budget go
    straight to the replace rung instead of dead-ending on a "no images" verdict.
  • Detector: is_request_too_large_error walks the whole error chain, so a
    gateway HTML page (413 Request Entity Too Large … openresty) counts the same
    as the API's own body reader.
  • User-visible notices: CompactionNoticeSink carried on the prepared
    envelope; the engine injects EngineCompactionNoticeSink (→ Event::Status),
    so each rung says what it is doing on the status line while it does it.

Review and CI follow-up

  • An independent review (SpikeBot 003) found the in-budget dead end described
    above; it was fixed in ef5071605 with a test that refuses an in-budget
    request and expects the direct-to-replace path.
  • clippy::ptr_arg on the newer stable toolchain: the retry helper now takes
    &mut [Message].
  • The Unreleased note lives in the root CHANGELOG.md; the packed slice was
    regenerated with scripts/sync-changelog.sh, and the website copy with
    web/scripts/derive-changelog.mjs.
  • Upstream main was merged in (afd6e3e2e), so the PR sits on the current
    tree.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

Testing

  • cargo fmt --all -- --check
  • clippy under the CI allow list, scoped to the touched crate:
    cargo clippy -p codewhale-tui --all-targets --locked -- -D warnings -A clippy::uninlined_format_args -A clippy::too_many_arguments -A clippy::unnecessary_map_or
    — no findings in the touched files (stable 1.98.1)
  • cargo test -p codewhale-tui --lib -- compaction image_attach
    → 196 passed; 0 failed, including 10 new cases: both rejection wordings
    (API body reader and gateway HTML page), both image carriers, both retry
    rungs, the in-budget direct-to-replace path, and the full-ladder failure
  • scripts/release/check-versions.sh locally green (receipts + version
    state), with scripts/sync-changelog.sh --check passing
  • cargo test --workspace --all-features --locked — not run; the touched
    suites above were run instead

Checklist

  • Updated docs or comments as needed (root CHANGELOG [Unreleased])
  • Added or updated tests where relevant
  • Verified TUI behavior manually if UI changes (the status-line path is
    covered by a unit test on the engine sink)
  • Harvested/co-authored credit uses a GitHub numeric noreply address (no
    external contribution in this change)

Related Issues

No-Issue: found while diagnosing live HTTP 413 failures during compaction; no
upstream issue was opened for this.

Attribution

🤖 Generated by SpikeBot 000(CodeWhale-LOCAL)

A provider body cap is a byte limit the token-side budget cannot see: an
image that costs a flat token estimate can still spend megabytes of
base64, and a summary request carries the whole history. Compaction then
failed exactly when it was needed most, and no later pass could clear the
history stuck behind the cap.

The summary call now descends a two-rung byte ladder before failing:

- re-encode the inline images under a 2 MiB budget (per-image share with
  a 96 KiB floor; alpha stays PNG, everything else becomes JPEG);
- if the request is still refused, replace the images with text notes for
  that one summary pass, so the handoff still says an image was there.

Each rung announces itself on the status line and the failure text names
the rung it reached. Session history keeps the real images; only the
outbound copy of the request changes.

The detector walks the whole error chain, so a gateway HTML page
("413 Request Entity Too Large ... openresty") counts the same as the
API's own body reader, and it takes precedence over the context-window
ladder — a byte rejection is not a token one.

Files:
- crates/tui/src/image_attach.rs: shrink and placeholder helpers
- crates/tui/src/compaction.rs: notice sink, request-size ladder, detector
- crates/tui/src/core/engine/compaction.rs: engine notice sink

Tests: 9 new cases covering both rejection wordings, both image
carriers, both retry rungs and the full-ladder failure.
@SparkofSpike

Copy link
Copy Markdown
Owner Author

结论:可以合并

独立审查(只读 diff 与源码,未在本机构建/跑测试)。修复思路正确:413 是请求体的字节上限,token 侧压缩预算看不见 base64 膨胀,因此对一次拒绝过的压缩请求做字节级降级(重编码 → 替换为文本说明)是合理的,且状态机不会死循环或重发同一请求。整体设计干净、边界考虑周全,建议合并。


发现的问题(按严重程度排序)

1.(中)阶梯第 1 档把「有图但低于 2MiB 预算」误报成「无图可重编码」,并放弃第 2 档

compaction.rs:1748-1755:第 Start 档里 if shrunk.images == 0 { return Err("...carries no inline images to re-encode") }。

但 shrink_images_for_request_with_budget 在 total <= budget 时会提前返回(image_attach.rs sizes.is_empty() || total <= budget),返回的 images == 0。也就是说当一个会话真实携带图片、但图片总量 ≤ 2MiB,而 provider 的上限仍然低于 2MiB(例如某些路由体上限很小)时,第 1 档直接报错退出,永远不会落到第 2 档(replace_images_with_placeholders),尽管第 2 档本可以把它救回来——这会让会话再次卡死。

  • 修复建议:让「有图但因已在预算内而未重写」与「根本没有图」区分开(例如 ShrunkenInlineImages 记录 had_images: bool,或让 shrink_images_for_request 返回「是否有图」),当是前者时直接落到第 2 档 ImagesShrunk 分支往下走。
  • 9 个新增测试没有覆盖「有图但已 ≤ 2MiB 仍 413」这条路径(现有 413 测试用的 900×900 噪声 PNG 超预算)。建议补一个。

2.(低)is_request_too_large_error 词表两端都有缺口

compaction.rs:1900-1910。词表:http 413 / payload too large / request entity too large / length limit exceeded。

  • 可能漏判:未覆盖 content-length too large、413 request too large(无 entity/payload 字样)、AWS 类 Request would exceed the maximum、too many bytes 等常见 413 措辞。若 provider 用这些措辞,阶梯不会触发,恢复原状(回到 413 失败)。
  • 可能误判:length limit exceeded 很通用,可能混入非 413 的错误文本。此处好在上「字节拒绝优先于 drop-oldest」且语义无数据损失(最坏是把本应 drop-oldest 的改成了缩图),影响有限。

建议:把 413 判断稍稍放宽(例如「413 数字 + 常见 size 词」),或在文档是更新的真实样本里补几组错误文本。

3.(小)图片偏多时 per_image 实际预算会超过「2MiB 总量」声明

image_attach.rs:838 let per_image = (budget / sizes.len()).max(COMPACTION_IMAGE_MIN_BUDGET_BYTES)。当图片 ≥ ~21 张时,每张至少 96KiB,再加 per_image×N 会超过 2MiB 总量上限;函数注释声称“一个总预算 2 MiB 内的重编码”。不构成正确性缺陷(残余 413 会落到 replace 档),但注释/总量语义与行为不符,建议按总量再分集夹紧,或改注释措辞。


我实际验证过什么

  • 通读了完整 diff(gh pr diff 2),并浅克隆 codex/compact-413-image-fallback 到 /tmp/cw-review 读了以下完整源码:
    • create_summary 的 RequestSizeLadder 全流程(Start→ImagesShrunk→ImagesReplaced),确认每个 rung 只重试一次、无死循环、阶梯终止明确;
    • shrink_images_for_request_with_budget / shrink_base64_image / reencode_within_budget / encode_inline_image:确认 base64 (STANDARD) 一致性、mime 与字节一致(encode_inline_image 只产生 PNG(带 alpha) 或 JPEG,均在被 valid_tool_image 允许列表,且经 sniff_media_type 重新验证,测试也断言了 mime 与字节匹配);
    • decode_and_guard_image 守卫:确认新路径(data url 与工具结果两种载体)在 shrink_base64_image 里都调用到(像素/维度/分配上限),解密不可信的图有防护;
    • replace_images_with_placeholders:确认与既有 strip_images_when_unsupported 在工具结果载体上采用同构处理(置 content_blocks=None 并把说明追加进 content,避免 line 投影把文本块当遗漏图);
    • 契约:compact_messages_safe → compact_messages_with_metadata → create_summary 三处 notice_sink 参数全部同步;compact_messages(测试专用)传 None;prepared.notice_sink.as_deref() 正确转换 Arc<dyn …>;
    • 手写 Debug/PartialEq 是必要的:一旦向 PreparedCompactionEnvelope 加入 Option<Arc<dyn CompactionNoticeSink>>,派生的 PartialEq 将无法编译(dyn …: PartialEq 不成立),手写实现是正确的最小改动,且现有代码没有对整包做 == 比较;
    • 搜索确认 image crate 已是既有依赖(png/jpeg feature 均在),未新增依赖、无外部数据外发(数据仅重编码后仍回发给同一 provider,无非发送目标)。
  • 逐条核对 9 个新增测试:大纲覆盖两点 rejection 措辞(API 体读取器 + openresty 网关 HTML)、两种载体(image_url 与存储 tool-result)、两步下降 + 全阶梯失败 + 无图 413 + 券商通知下沉。测试断言了重试确实携带更小的字节、mime 与字节匹配、通知到达状态行。

未覆盖 / 不确定

  • 未本机构建/跑测试:受「只读、这 2 核生产机不跑 cargo」约束,我没有重新执行 cargo test/fmt/clippy;「194 passed」与格式化/链接检查通过是 PR 描述的结论,非我可独立复执。
  • 真实 413 触发频率与阶梯参数:2MiB 总量、96KiB/张、1024→128px、JPEG q80 这些都是启发式,未对照某一个具体 provider 的文档上限验证。真实会话中 413 是否多数落在「图超预算」而不是「超全 body(含长文本)」不确定——若属后者,第 1 档重编码无效,直接靠装配在 drop-oldest 阶梯也帮不上;这种会话卡死仍无解(但那属于超出此 PR 范围的原始问题)。
  • EN细节:image_url 里非 base64 的 URL(http(s) 远程)不经 parse_data_url,不会被缩小/替换——parse_data_url 只识别 data:,这点与既有行为一致,非回归)。

署名

🤖 由 SpikeBot 003(ClaudeCode-JP) 生成

Review follow-up on the request-size ladder:

- The shrink rung reported "0 images" both when there were none and when
  every image already fit its share of the budget, so a session whose
  images were small but whose endpoint cap was lower failed outright
  instead of reaching the replace rung. `ShrunkInlineImages` now carries
  `images_seen`, and an in-budget request goes straight to the replace
  rung — no identical retry, no dead end.
- `is_request_too_large_error` also matches "request body too large"; the
  rendered status-code token remains the primary signal.
- The per-image floor comment now states that many images can push the
  total past the budget, which the replace rung covers.

Tests: a fixture with in-budget images that is refused anyway proves the
direct-to-replace path; the under-budget shrink test asserts
`images_seen`.
@SparkofSpike

Copy link
Copy Markdown
Owner Author

复核结论:修复到位,可以合并

增量提交 ef507160("treat in-budget images as present, not absent")针对我之前三条发现 1/2/3 的回应,结论是:第 1 条(中)真修好了;第 3 条(小)已随注释更新一并解决;第 2 条(低)已实质缓解(详见下文)。没有引入新的死循环或跳档漏洞,建议合并。


逐条核对

第 1 条(中)——「有图但低于 2MiB 预算被误报成无图、放弃第 2 档」:✅ 已真修好

  • compaction.rs:1751 的判定从 shrunk.images == 0 改为 shrunk.images_seen == 0。images_seen 是 inline_image_sizes(messages).len()(image_attach.rs:836,845),真实反映「请求是否携带图」,不再与「本轮是否重写出东西」混淆。
  • 关键分支 compaction.rs:1766-1780:当「有图 images_seen>0 但 images==0(每张都已在预算内)仍 413」时,直接落 replace_inline_images_for_retry,设 ImagesReplaced 后重试——不会重发相同字节,最终能成功。
  • 「无图 + 413」仍明确失败:compaction.rs:1751-1755 返回带说明的错误("carries no inline images to re-encode")。
  • 状态机无死循环、无跳档漏洞:逆向遍历每一档——Start 一旦有图必推进(要么缩图→ImagesShrunk,要么无可用缩图→ImagesReplaced,二者都 continue);Start 无图直接 Err 终止;ImagesShrunk 再被拒→ImagesReplaced;ImagesReplaced 再被拒→终态 Err。阶梯单调递增,每档至多重试一次,不会回退到已走过的档位,也不会无限循环。
  • 两条关键新测试覆盖到位:
    • request_body_413_with_in_budget_images_skips_the_noop_retry_and_replaces:断言仅 2 次请求(第 1 次带图、第 2 次只带 notes、无中间 identical 重试),且只发 1 条 notice(replace 档)。
    • request_body_413_without_inline_images_fails_with_context:断言恰好 1 次请求、错误文本含 "request-body limit"、无 notice(没有误缩图/误替换)。

是否引入新问题:无

  • ShrunkInlineImages.images_seen 新增字段使用一致:全仓库唯一消费 shrink_images_for_request 返回值的地方是 compaction.rs:1750;images_seen 在提前返回(image_attach.rs:849-851)和缩图正常路径尾部(image_attach.rs:884)都显式赋值,..struct::default() 里的兜底 0(usize Default)只在没被覆盖的字段上生效,无错位。
  • replace_inline_images_for_retry 错误文本与测试协调:images==0 时 bail!("no inline images were left to replace"),错误被 context 包裹进对外失败文本;request_body_413_after_replacements_fails_with_the_full_ladder 断言文本含 "even after re-encoding and then replacing",与 ImagesReplaced 档的返回措辞一致。
  • notice 文案准确性:compaction.rs:1756-1764 的 "re-encoded {images} ... ({bytes_before} to {bytes_after})" 只在 shrunk.images > 0 分支发出,且 bytes_before/after 只统计实际重写过的图;images==0 但 images_seen>0 的直接替换路径不发 "re-encoded" 文案(不发误导性通知),只发 "replaced" 文案。文案不再声称未做过的 "re-encoded"。

另两条:

  • 第 3 条(小,per_image 超总量说明):image_attach.rs:852-853 新注释明说 "With many images the floor can push the total past budget; the caller's next rung (replace with notes) covers that case"——注释与行为一致,已修。(实现仍可能与「总量 2 MiB」语义不完全一致,但残留 413 会落到 replace 档兜底,不构成正确性缺陷。)
  • 第 2 条(低,is_request_too_large_error 词表):新增了 "request body too large"(compaction.rs:1917)。需要说明:我此前担心的 content-length too large、413 request too large(无 entity/payload 字样)、AWS 措辞并未逐一加入词表,但 commit 的核心论点是「客户端总会把状态码渲染进错误链」(llm_client/mod.rs:561 的 HTTP {status}: {body}),因此 "http 413" 这条主信号已覆盖绝大多数真实 413。这条我认为已实质性缓解,残余漏判风险只在 gateway 页面完全不带状态码的极少数路由,且失败路径只是不触发阶梯维持原报错,影响有限。综上第 2 条可接受,无需阻塞。

我实际读过的文件/行

  • crates/tui/src/compaction.rs:1711-1713、1736-1810(RequestSizeLadder 全流程)、1751/1756-1780/1782-1790/1793-1800、1908-1926、1934-1950、2421/2580-2635(两新 413 测试 + full-ladder 测试)。
  • crates/tui/src/image_attach.rs:801-826、830-905、1021-1110(shrink_data_url/shrink_stored_tool_image/shrink_base64_image/reencode_within_budget)。
  • crates/tui/src/image_attach/tests.rs:124-135(compaction_shrink_... 断言 images_seen==2)。
  • crates/tui/src/llm_client/mod.rs:561(客户端渲染 HTTP {status},用于评估词表主信号可靠性)。
  • gh pr view 2 / gh pr diff 2;浅克隆 codex/compact-413-image-fallback 分支 + git show ef50716 确认增量。

仍未覆盖 / 不确定的部分

  • 词表对完全不渲染状态码的 gateway 措辞仍存在理论上限(已评估为可接受,见第 2 条)。
  • 我只读了源码与 diff,未在本机跑 cargo/测试(遵循机器的 2 核生产约束);测试通过与否以 PR 描述声明的 194 passed 与注释在 code visual 层面的自洽为准。
  • per_image 用 floor 后总量可能超 2MiB 的语义(第 3 条第 2 段):属于既有设计延续,非本次增量引入,已用注释说明,不阻塞。

署名

🤖 由 SpikeBot 003(ClaudeCode-JP) 生成

… slice

CI follow-up on the fix commit:

- clippy::ptr_arg on the newer stable toolchain: the replace helper took
  `&mut Vec<Message>` where `&mut [Message]` does.
- The Unreleased entry belongs in the root CHANGELOG.md, which
  scripts/sync-changelog.sh slices into crates/tui/CHANGELOG.md; editing
  the slice directly tripped the version-drift check.
The upstream merge moved the root CHANGELOG.md, so both derived copies
needed regeneration:

- scripts/sync-changelog.sh for the packed slice the binary embeds;
- web/scripts/derive-changelog.mjs for the website's generated changelog.

Local scripts/release/check-versions.sh is green with the v0.9.13 tag
present (feature release-note receipts and version state both OK).
@SparkofSpike
SparkofSpike merged commit d88cddd into main Sep 26, 2026
24 of 25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant