fix(agent): bound historical image payloads per request - #89965
Conversation
andrexibiza
left a comment
There was a problem hiding this comment.
Reviewed exact head fd77a2b4d67612d45cd3d859e1417a9163ca038a against base/current main 13ce0c5c675e843af70d19c9e5144249cd51c8d1, the #89938 reproduction, the request-assembly paths in conversation_loop.py and the independently assembled max-iteration summary, exact-head CI, and the overlapping image-lifecycle work in #64440/#63850/#87555/#89776.
The core direction is right: this belongs at request projection, not in durable history. I verified that the main loop structurally clones each message before the new transform, and the summary path's shallow copy is still safe here because _strip_old_image_parts() replaces the copied row's top-level content list rather than mutating nested image parts in place. Running before prompt-cache planning is also the correct ordering, and covering the summary path closes the same independent-send seam that earlier screenshot work had to discover separately.
I would not merge this exact head yet, because the unified newest-three policy drops the active user's image while that same user turn is still in flight.
Blocker — image recency is being counted across roles/messages, but the active user turn is semantic input, not historical media
_strip_old_image_parts() walks every image-bearing message newest-first with one shared remaining = 3 budget. It has no notion of current_turn_user_idx, role, or turn boundary. That means three later image-bearing tool results are enough to evict the image(s) from the user message that initiated the current turn.
The new integration test actually pins this behavior: it builds one image-bearing user message followed by five image-bearing vision_analyze tool results, then asserts only three image-bearing messages reach the provider. Under this implementation those three survivors are the newest tool results; the initiating user image is replaced by the placeholder before the model produces the final answer.
That is a correctness regression even though persistence is intact. A request such as “compare this screenshot against the references you inspect” can lose the screenshot the user is asking about after the agent has made three vision/tool calls during the same turn. The max-iteration summary path has the same problem: by the time it asks the model for the final response, enough tool-image messages can have displaced the active user image from the keep window.
The repository already has the useful split in the adjacent work:
- #64440 by
@bnikanjambounds historical tool-result screenshots while deliberately leaving user uploads alone. - #87555 by
@fangliquanflqbounds historical user images but explicitly preserves the newest/current user turn in full. - #63850 by
@bnikanjamis the complementary producer-side per-embed cap forbrowser_vision. - #89776 by
@AP3X-Devis complementary reactive 413 recovery: drop retained vision bytes before spending a compression attempt.
#89965 is the stronger candidate to supersede the first two request-projection implementations because it unifies formats/roles and covers both send sites, but it needs to preserve their ownership distinction rather than flatten it.
Required fix: anchor the policy to the current turn. The current user message (and, if needed, other user-authored multimodal input belonging to that active turn) must remain visible while the turn is executing; apply the newest-N eviction to historical media/tool results outside that protected input. Add a regression with user(image) -> >=3 image-bearing tool results -> final model call proving the user image is still present while older tool/history images are bounded. Mirror the same invariant in the max-iteration summary test.
I would also preserve contributor provenance explicitly if this PR supersedes #64440/#87555 rather than merely listing them as related: those PRs independently established the two halves of the request-time lifecycle this branch is combining.
CI / composition
The branch is based directly on current main. Nix and Docker are green. The CI workflow is red only in Python slice 8 on tests/tools/test_image_generation.py::TestFalCatalog::test_upscale_defaults_are_all_off (xai/grok-imagine-image/v2.0/text-to-image still has upscale=True), with 3,946 tests passing in that slice; that failure is outside this six-file diff and is the known current-main Grok catalog regression, not evidence against this patch. The changed-area lint, OS-specific, E2E, and the displayed Python slices are otherwise green.
Re-review gate: preserve the active-turn user multimodal input, keep the request-only/deterministic projection and both send-site coverage, retain the historical tool/user eviction witnesses, and attach the exact-head focused regression. I did not find a reason to reject the overall request-time eviction architecture.
|
I had the other half of #89938 built when this appeared, so rather than open a competing PR I opened #90001 for the part this one leaves in place, and here is the one thing in this diff I would want a second look at before it merges. The two changes are complementary, not alternativesThis PR bounds the bytes on every outgoing request, so the 413 stops happening. It does not touch That is the "7 compactions in 13 minutes, all below 200K tokens" in the report: the recovery pass ran seven times and freed nothing. With only this PR merged the wedge becomes unreachable in the common case but it is still there - any window that exceeds the provider's body limit on its own (a burst larger than #90001 is that fix and only that fix: The prefix churn is bigger than "once per image"
Both sentences are true, and I think they undersell the cost, because the property that matters for prompt caching is stability across turns, not across repeats of the same history. Walk two consecutive turns of a vision session,
The request sent at turn N+1 therefore diverges from the request sent at turn N at message A, which is about as early as a divergence can be. Every subsequent cached token is lost. In a session where most turns carry an image - which is the exact session this PR is for - that is a cache break on essentially every turn, not once per image. Running the pass before cache planning is the right call and I would keep it: it means Three ways to buy the stability back, none of which I would insist on:
Whatever the answer, it is worth a sentence in the PR body: right now a reader comes away thinking the cache cost is one break per image, and it is one per image-bearing turn. Two small ones
Two failures you will see in CI are not yours. On a pristine Happy to rebase #90001 onto this one in whichever order they land, or to fold it in here if you would rather ship a single change - it is 20 lines and its tests are self-contained. |
|
@jackulau I checked #90001 at
So “no overlapping line” is true, but it is not the same as no behavioral overlap. #89776 and #90001 both occupy the reactive 413/recovery class at different representations and different moments. #90001 is also not strictly a 413-only fix: I agree with the central topology point: #89965 alone does not close the provider-body-size class. Your prompt-cache correction is also correct. “Each image ages out once” describes per-message idempotence, not cross-turn prefix stability. Once the window is saturated, each new image-bearing turn advances the strip boundary and invalidates the warm suffix from that boundary forward. Running before cache planning guarantees an honest key for the payload actually sent; it does not preserve a hit. The PR body/docstring should say that plainly. Of the three alternatives you listed, I would not make quantization a merge requirement: it reduces invalidation frequency by accepting a looser payload ceiling and larger periodic cache breaks. A byte budget is the policy most directly aligned with HTTP 413, but it needs an explicit active-turn floor and a defensible estimate of serialized request bytes across transports. At minimum, the current PR must describe Agree on renaming One caution on #90001 for its own review: keeping only the newest tool image can discard multiple tool images gathered intentionally during the active turn, and because the hook is generic it can do so during ordinary compaction, not only emergency recovery. That may still be the right shedding policy, but it needs a deliberate turn/byte invariant and a regression for a current-turn multi-image comparison. I would review that independently rather than fold it into this PR by default. My exact-head merge position on #89965 therefore remains unchanged: preserve active-turn user multimodal input, prove that invariant in both send sites, correct the cache/body-bound language, rename the shared helper, and interlock—not flatten—the provenance and recovery roles of #64440, #87555, #89776, and #90001. |
Summary
Long-running multimodal sessions can accumulate inline base64 images in user and tool-result messages. Every subsequent model request re-sends those images, allowing the HTTP request body to grow by multiple megabytes even while the estimated token count remains well below the context-compression threshold.
This can trigger provider HTTP 413 responses. Compression alone may not recover because the existing
_strip_historical_mediapass only runs during compaction and anchors on the newest image-bearing user message. As a result, tool-result images and images in the first user turn can survive repeated compaction attempts.This PR adds a request-time image bound independent of context compression:
tool_call_idfields so tool-call/result pairing remains valid.api_contentsidecars from rewritten request messages so removed image bytes cannot be restored during replay.The transformation supports the three multimodal shapes Hermes currently handles:
image_urlinput_imageimageRemoving an image necessarily changes the request prefix once when that image leaves the retention window. The projection is deterministic afterward: repeated requests over identical history are byte-identical, and each historical image ages out only once. The transformation runs before prompt-cache planning so cache markers describe the actual post-eviction request. This mirrors the existing Anthropic computer-use screenshot retention policy.
Existing PR Relationship
This change was compared against the existing image-payload PRs:
This PR addresses #89938's combined failure mode proactively across user and tool messages, all supported image-part formats, the main request path, and the max-iteration summary path.
Changes Made
agent/context_compressor.py_strip_old_image_parts.api_contentsidecars from rewritten request rows.agent/conversation_loop.pyagent/chat_completion_helpers.pytests/agent/test_request_image_eviction.pyapi_contentcleanup.tests/run_agent/test_413_compression.pytests/run_agent/test_run_agent.pyHow to Test
vision_analyzeor other multimodal tool results.Focused canonical test suite:
✅ 334 tests passed, 0 failed
Adjacent multimodal and compression suites:
✅ 157 tests passed, 0 failed
Additional checks:
ruff check: passed
py_compile: passed
git diff --check: passed
The regression tests were also sabotage-tested with the eviction helper disabled: 4 expected failures, proving the tests exercise the fix.
Checklist
pytest tests/ -q— pending full-suite CIRisk & Impact
Low. The transformation applies only to the per-request API copy — persisted conversation history is never touched. Tool-call/result pairing is preserved via retained
tool_call_idfields. The projection is deterministic: repeated requests over identical history are byte-identical, and prompt-cache markers are computed after eviction so cache planning reflects the actual outgoing request.Type: 🐛 Bug fix / ✅ Tests
Fixes: #89938