Skip to content

fix(agent): drop vision payloads before compressing on a 413 - #89776

Open
AP3X-Dev wants to merge 1 commit into
NousResearch:mainfrom
AP3X-Dev:fix/413-strip-images-before-compressing
Open

fix(agent): drop vision payloads before compressing on a 413#89776
AP3X-Dev wants to merge 1 commit into
NousResearch:mainfrom
AP3X-Dev:fix/413-strip-images-before-compressing

Conversation

@AP3X-Dev

Copy link
Copy Markdown

What does this PR do?

Fixes the 413 -> compress -> 413 loop a screenshot-heavy session can wedge in, far below its context window.

The causal chain. In agent/conversation_loop.py the is_payload_too_large branch was ordered:

  1. compression_attempts += 1
  2. agent._compress_context(...) — a slow LLM round-trip
  3. did messages/tokens actually shrink?
  4. only if step 3 showed no progress_try_strip_image_parts_from_tool_messages(...)

A 413 is a verdict on the byte size of this request body. On a session carrying browser/vision tool results, retained base64 image_url parts are typically the bulk of those bytes — and text summarization cannot shrink base64. So steps 1–3 reliably no-op, each pass costing ~50–70s of wall clock and one of max_compression_attempts, before the code reaches the one operation that would have helped. From the reported session (context window 1,000,000, threshold 468,000):

context compression started: messages=112 tokens=~83,832
context compression done:    messages=112->51
context compression started: messages=51  tokens=~47,641
context compression done:    messages=51->48
context compression started: messages=48  tokens=~46,261
context compression done:    messages=48->48      <-- zero progress

Repeated compaction at ~84K tokens against a 1M window, because the trigger was the provider's byte cap, not the token threshold.

The fix inverts the order: attempt the vision-payload strip first (instant, local, targets the actual bytes), and fall through to text compression only when there was no image left to drop.

Why hoisting the strip is safe — verified rather than assumed:

  • The mutation survives the retry. api_messages is built once above the while retry_count < max_retries loop and is never reassigned inside it; _compress_context rewrites messages, not api_messages. So the strip persists across the continue.
  • It cannot spin. _try_strip_image_parts_from_tool_messages only rewrites tool messages whose content is a list containing image parts, and replaces that content with a string. A second call therefore finds no list content and returns False, falling through to compression. At most one extra round-trip.
  • Text-only 413s are unaffected. No image parts → strip returns False → the branch proceeds to compression exactly as before, including the existing lock-defer and terminal paths.
  • remember_model=False is preserved — already the convention here. A 413 means this body was too large, not that the provider rejects list-type tool content in general (the [Bug] computer_use multimodal tool message causes 400 error on providers that don't support multimodal tool content (e.g. Xiaomi MiMo) #27344 recovery path). The test asserts _no_list_tool_content_models stays empty.

One deletion worth calling out: the post-compression strip retry (the old step 4) is removed as dead code. With the strip hoisted, by the time compression has failed to reduce, api_messages is already image-free and that call is provably a no-op — leaving it would also emit a status message ("Compression could not reduce the request further — removed retained vision payloads") that can no longer be true. A comment records why it is gone.

Related Issue

Fixes #89286

Related: #59587 covers the threshold semantics (the 0.95 progress gate) of the same branch. This change is about ordering and is independent — even with a perfect threshold, an image-heavy request never reached the stripper until compression had already failed.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • agent/conversation_loop.py — in the is_payload_too_large branch, run _try_strip_image_parts_from_tool_messages(api_messages, remember_model=False) before compression_attempts += 1; continue on success. Removed the now-unreachable post-compression strip retry, with a comment explaining why it cannot fire.
  • tests/run_agent/test_413_compression.py — updated the existing vision-strip test to the corrected ordering and added two tests (details below).

How to Test

pytest tests/run_agent/test_413_compression.py -q

28 passed on this branch. On main, two of them fail:

FAILED ...::test_413_strips_vision_payloads_before_compressing
FAILED ...::test_413_image_strip_does_not_consume_a_compression_attempt
2 failed, 26 passed
  • test_413_strips_vision_payloads_before_compressing — the existing test, updated. It already proved images get evicted and text survives; it previously also asserted _compress_context was called once, which encoded the ordering this issue reports as wrong. It now asserts _compress_context is not called: dropping the images alone recovers the request, with no summarization round-trip. Every outcome assertion it made before (image gone, text preserved, model not blacklisted) is unchanged.
  • test_413_image_strip_does_not_consume_a_compression_attempt — new. Sets max_compression_attempts = 0 to stand in for a budget already exhausted by earlier futile passes, then sends a 413 with an image-bearing tool result. On main this hard-fails with 413 compression failed after 0 attempts while the base64 is still in the body; with the fix it recovers, because dropping images costs nothing.
  • test_413_still_compresses_when_there_is_no_image_to_drop — new guard for the fallthrough. A text-only 413 must still reach compression, so the reorder cannot become a way to skip it. (This one passes both ways by design — it exists to stop a future change from turning the strip into an unconditional bypass.)

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass — left unchecked deliberately. I have not run the whole suite green on this Windows box; it carries pre-existing failures unrelated to this change. What I did run:
    • tests/run_agent/test_413_compression.py28 passed
    • tests/run_agent/266 passed, plus tests/run_agent/test_callable_api_key.py, which fails identically (4 failed, 8 passed) on clean main with my changes stashed — pre-existing and unrelated to this branch.
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: Windows 11 (build 26100), Python 3.12.10

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — inline comments record the ordering rationale and why the removed branch is unreachable; no user-facing docs describe this path
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A, no config keys
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — pure control-flow reorder in provider-error recovery, no file I/O, process, or shell surface. scripts/check-windows-footguns.py --diff main is clean.
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A

Screenshots / Logs

Behavior on main with an image-bearing 413 and no compression budget left — the request fails while the base64 is still in the body:

WARNING agent.conversation_loop: API call failed (attempt 1/3) summary=HTTP 413: Request entity too large
ERROR   agent.conversation_loop: 413 compression failed after 0 attempts.

With this change the same request recovers on the next round-trip, with no summarization call.

A screenshot-heavy session could wedge in a 413 -> compress -> 413 loop far
below its context window. The payload-too-large branch spent every recovery
attempt on text summarization before it ever tried dropping image payloads,
and text summarization cannot shrink base64: each pass cost a slow LLM
round-trip, reported "no reduction" because the images were untouched, and
consumed one of max_compression_attempts. A session on a 1M-token window was
compacting repeatedly at ~84K tokens because the trigger was the provider's
byte-size limit, not the token threshold.

Retained vision parts are typically the bulk of an oversized body, so the
strip now runs first: it is instant, local, and targets the actual bytes.
Text compression still runs on the next pass when there was no image left to
drop, so text-heavy 413s are unaffected.

The strip is safe to hoist: api_messages is built once above the retry loop
and _compress_context rewrites `messages` rather than api_messages, so the
mutation survives the `continue`. It is also self-limiting — stripped tool
content becomes a string, so a second call finds no list content and returns
False rather than looping.

remember_model=False is preserved: a 413 means this body was too large, not
that the provider rejects list-type tool content in general.

The post-compression strip retry is removed as dead code — by the time
compression has failed to reduce, api_messages is already image-free.

Fixes NousResearch#89286
@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint tool/vision Vision analysis and image generation P2 Medium — degraded but workaround exists labels Aug 19, 2026
@brianbaldock

Copy link
Copy Markdown

#88960

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists tool/vision Vision analysis and image generation type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

413 payload-too-large recovery loops: text compression runs before image stripping, so base64 vision payloads are never dropped

3 participants