Skip to content

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

Merged
Hmbown merged 13 commits into
Hmbown:mainfrom
SparkofSpike:codex/compact-413-image-fallback
Sep 29, 2026
Merged

Hmbown merged 13 commits into
Hmbown:mainfrom
SparkofSpike:codex/compact-413-image-fallback

Conversation

@SparkofSpike

@SparkofSpike SparkofSpike commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

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 from 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.

Notes

  • An independent review found the in-budget dead end described above; it is
    fixed in ef5071605, with a test that refuses an in-budget request and
    expects the direct-to-replace path.
  • clippy::ptr_arg on the current stable toolchain: the retry helper takes
    &mut [Message].
  • The ladder's thresholds (2 MiB total, 96 KiB floor, 1024→128 px, JPEG q80)
    are heuristics, not derived from one documented provider cap; the replace rung
    is what covers a cap below the budget. Remote (http(s)) image URLs are not
    rewritten — parse_data_url only recognises inline data: payloads, which
    matches the existing wire behaviour.

Type of Change

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

Testing

  • cargo fmt --all -- --check
  • clippy under the CI allow list:
    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 green, 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)

Credit-parity follow-up verification

Commit 0e9e5d7c4bd0adafd94199363b6b57601f907e64 adds the missing v0.10.1 changelog credit for @SparkofSpike, matching this PR's existing contributor document, website list, and candidate facts. The contributor PR and its original ancestry are preserved; compaction code and assertions are unchanged.

  • Focused public-copy tests: 6 passed, 0 failed.
  • Required npm test: 636 passed, 0 failed (68 wrapper, 16 SDK, 50 host, 502 web).
  • Required npm run check:web: passed, including production build.
  • Diff check: passed.

An initial sandbox run denied the two local HTTP listeners; the permitted rerun passed. No Rust tests were rerun for this one-line credit change. Hosted checks at the new head remain pending.

Follow-up e6ad8d3a586070ed87bf3d168b7a6b7c6c0a7bb7 regenerates the tracked TUI changelog slice with the same credit. This fixes the Version drift failure found at the preceding head. Both scripts/sync-changelog.sh --check and scripts/release/check-versions.sh --range-audit-advisory pass, including 29 feature-note references and matching package/lock versions. The previously passing npm/web inputs are unchanged and those checks were not repeated for this generated documentation mirror. Hosted CI must qualify the new head.

Current-main integration (c16a8a4): preserved the original contributor branch and both release credits. npm test passed 636/0 and check:web passed after merging current main. New fork CI runs were approved; current-head platform CI is required before merge.

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.
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`.
… 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

Copy link
Copy Markdown
Contributor Author

Three notes from the authoring side:

  • Self-review record. This PR has already been through self-review. The
    record lives on our fork's PR —
    fix(compaction): survive a provider request-body 413 while making room SparkofSpike/CodeWhale#2 — two independent passes,
    both concluding "ready to merge", and the one substantive finding (a session
    whose inline images already fit the byte budget dead-ending the retry ladder)
    is fixed in ef5071605 with its own test.
  • The red checks are not from this change. On the fork, the only job that
    went red was Version drift, and it fails in the check's environment rather
    than in the diff: the same commit is green locally with the tags present
    (scripts/release/check-versions.sh reports receipts OK and version state
    OK), and the touched suites pass (196 tests). The Rust CI runs for this PR
    have not run yet at all — they are sitting at action_required, waiting for
    approval. Please don't read those reds as a verdict on this patch.
  • Trial run. This is SpikeBot's trial run. Feedback of any kind is welcome.

Attribution

🤖 Generated by SpikeBot 000(CodeWhale-LOCAL)

CodeWhale Bot and others added 2 commits September 26, 2026 10:47
…he web changelog

Merging origin/main brought in another [Unreleased] bullet, so the committed
web/lib/changelog.generated.ts no longer matched a fresh derivation
(itemCount skew that failed web vitest lib/changelog.test.ts on the merge
ref). The contributor-credit check also failed because @SparkofSpike was not
in UNRELEASED_CONTRIBUTORS.

- CHANGELOG.md [Unreleased]: Contributors line and PR link/thanks on the
  Fixed bullet; crates/tui/CHANGELOG.md resynced by scripts/sync-changelog.sh.
- docs/CONTRIBUTORS.md v0.10.1 band: Hmbown#6642 line.
- web/lib/release-credits.ts: @SparkofSpike in UNRELEASED_CONTRIBUTORS.
- web/lib/changelog.generated.ts: regenerated by derive-changelog.mjs.

Gates run locally: web vitest full suite 498 passed / 0 failed (55 files);
lib/changelog.test.ts + lib/public-copy.test.ts 12/12;
check-contributor-credit.py v0.10.0 OK (6 contributors, all surfaces);
check-versions.sh --range-audit-advisory OK. No Rust files changed in this
commit; no Rust build run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Hmbown and others added 3 commits September 28, 2026 21:41
…ge-fallback

Conflicts:
- crates/tui/src/compaction.rs: main made the drop-oldest arm fail loud
  when nothing but the handoff note is left to drop; this branch excluded
  byte-side (HTTP 413) rejections from that arm. Kept both: the guard is
  !is_request_too_large_error && is_context_window_error, and the body is
  main's drop_oldest_history_messages check (which subsumes the old
  len() > 2 guard).
- CHANGELOG.md, crates/tui/CHANGELOG.md: took main's (v0.10.1 was cut on
  main); the Hmbown#6642 entry is carried by the release lane instead.
- web/lib/changelog.generated.ts: deleted on main, kept deleted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
docs/public-surface-facts.json requiredCandidateCredits now lists
@SparkofSpike for Hmbown#6642, matching docs/CONTRIBUTORS.md (v0.10.1 band) and
RELEASE_CONTRIBUTORS in web/lib/release-credits.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
The shrink rung decodes, Lanczos-resizes and re-encodes every inline image
in the summary request -- seconds of CPU for the multi-megabyte histories
this ladder exists for -- and it ran inline in create_summary on a Tokio
worker. It now runs under spawn_blocking with the outbound request moved
in and back; a join failure fails the pass with the 413 context instead of
panicking. Session history is untouched either way (the request is a copy).

Tests: cargo test -p codewhale-tui --lib --locked -- compaction image_attach
test result: ok. 211 passed; 0 failed; 0 ignored; 0 measured; 13616 filtered out
cargo fmt --all -- --check: clean

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
CodeWhale Bot and others added 3 commits September 29, 2026 00:38
PR Hmbown#6642 already credits @SparkofSpike in the contributor document, website
release list and candidate facts, but the current changelog ledger omitted
the contributor. Add the matching credit under v0.10.1 so all four surfaces
agree and the existing website parity check passes.

This commit extends the contributor's original PR ancestry and changes only
CHANGELOG.md. No compaction implementation or test assertion was changed.

Validation:
- npm --prefix web test -- lib/public-copy.test.ts: 6 passed, 0 failed.
- npm test: 636 passed, 0 failed (npm wrapper 68, runtime SDK 16,
  extension host 50, website 502); SDK type checks passed.
- npm run check:web: passed, including facts/docs/tokens, lint, TypeScript
  and the production website build.
- git diff --check: passed.

The initial sandboxed npm test attempt failed two wrapper HTTP fixtures
with listen EPERM on 127.0.0.1 (66 passed, 2 failed); rerunning with local
listener permission passed all gates above. No Rust builds were run.
Regenerate crates/tui/CHANGELOG.md from the already-tested root credit
addition in 0e9e5d7. The generated slice differs by that same one line.
This repairs the hosted Version drift failure without changing compaction,
website logic, test assertions, or contributor history.

Validation: scripts/sync-changelog.sh --check passed;
scripts/release/check-versions.sh --range-audit-advisory passed, including
29 feature-release-note references and matching 0.10.1 package/lock versions;
git diff --check passed.

The previous root-credit commit's npm test (636 passed, 0 failed) and
npm run check:web passed. Their inputs are unchanged by this generated
TUI documentation mirror, so those passing checks were not repeated.
No Rust build or release action was needed.
Preserve both contributor credits while retaining the original PR and compaction changes. Validation: npm test 636 passed, 0 failed (134 root, 502 web); npm run check:web passed. Rust source changes in this merge are already-reviewed main commits; full platform CI remains required.
@Hmbown
Hmbown merged commit be5205e into Hmbown:main Sep 29, 2026
27 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.

2 participants