fix(agent): bound native vision history payloads - #92748
JoaoMarcos44 wants to merge 2 commits into
Conversation
Keep model-token estimation separate from inline wire-byte budgeting. Bound native vision history, project historical images on both request paths, and prune protected-tail media without mutating canonical history.
The native history embed guard validates Pillow dimensions, so the browser vision regression fixture must be a decodable PNG rather than a header-only stub.
browser_vision's native fast path base64-encoded screenshots at full resolution and baked them into the tool result uncapped — the exact sibling of the vision_analyze path #92699 fixed. Apply the same proactive 256KB/1568px resize before the embed enters reusable history. Fail-open by design: without Pillow the resize helper falls back to raw bytes and the compressor's keep-newest pass still retires stale embeds. Sibling-gap follow-up for the #92725 salvage; the shared-cap approach mirrors the policy-owner idea from #92748. Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com>
|
Thanks for the thorough work here — closing in favor of #92783, which salvages the narrower #92725 plus a follow-up that adopts this PR's key insight (one shared cap policy covering BOTH Why the narrower fix was taken (both findings probe-confirmed against your branch):
Also noted during review: the fail-closed Pillow dimension check hard-fails native vision for any image Pillow can't decode (exotic formats/truncated files), where main degraded gracefully to byte-checks. The mechanism itself (copy-on-write projection, sidecar handling, max-iteration-summary parity) is genuinely well-built — if the maintainers ever want request-time byte budgeting as a policy decision, this PR is the reference implementation. Thanks again! |
browser_vision's native fast path base64-encoded screenshots at full resolution and baked them into the tool result uncapped — the exact sibling of the vision_analyze path NousResearch#92699 fixed. Apply the same proactive 256KB/1568px resize before the embed enters reusable history. Fail-open by design: without Pillow the resize helper falls back to raw bytes and the compressor's keep-newest pass still retires stale embeds. Sibling-gap follow-up for the NousResearch#92725 salvage; the shared-cap approach mirrors the policy-owner idea from NousResearch#92748. Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com>
Summary
Native-vision tool results could make long Hermes sessions exceed provider context, quota, or request-body limits even when the model-token estimate remained low.
This PR adds a shared, copy-on-write projection for inline image payloads, bounds native vision images before they enter reusable history, and applies the same policy to every request path that can resend conversation history.
Fixes #92699
User-visible problem
When
vision_analyzeor nativebrowser_visionhandled a screenshot, Hermes could embed the image as a large base64/data URL inside a tool result. That tool result remained in the conversation history and was replayed on every later model request.The previous history-entry limits were:
Those values were reasonable as one-shot provider limits, but not as history-reuse limits. A multi-megabyte screenshot could therefore be transmitted again and again throughout a session.
The issue reported sessions where:
Root cause
Five independent boundaries allowed the payload to escape protection:
toolresults, including images in the protected tail.image_url, Responsesinput_image, Anthropicsource.data,_multimodalenvelopes, Anthropic sidecars, and native browser screenshots all needed coverage.The core failure was not only that one image could be large. The deeper failure was allowing large visual data to become a persistent part of every future request without a shared aggregate byte policy.
Implementation
1. Bound images before they enter reusable history
tools/vision_tools.pynow uses history-specific limits:These are intentionally smaller than one-shot provider ceilings. The goal is to keep a native result cheap enough to reuse in a long conversation, not merely small enough to pass one individual API call.
A shared
_prepare_native_vision_embed()function now owns this policy for bothvision_analyzeand nativebrowser_vision.The preparation flow is:
If resizing cannot produce a payload within the history cap, the tool returns a structured error instead of inserting an unsafe image into reusable history.
If dimensions cannot be verified, the operation now fails closed.
_image_exceeds_dimension()distinguishes:True: the image exceeds the dimension limit;False: the image was verified and is within the limit;None: the dimensions could not be verified.This prevents malformed, corrupt, or undecodable images from bypassing the history guard.
2. Keep model-token estimation separate from wire-byte budgeting
agent/model_metadata.pyadds:_inline_image_part_payload_bytes();estimate_messages_inline_image_bytes().The byte metric understands:
image_urlparts;input_imageparts;imageblocks withsource.data;_multimodalenvelopes;_anthropic_content_blockssidecars.Remote HTTP image references do not contribute inline bytes because their binary contents are not serialized into the request.
The existing model-token estimator remains intentionally unchanged. Base64 transport bytes and model tokens are different currencies:
The production protection uses the actual inline-byte measurement for projection instead of pretending that base64 length is a universal model-token count.
3. Add a shared copy-on-write request projection
agent/context_compressor.pyadds_bound_inline_image_payloads()with these internal limits:The projection:
tool_call_idpairing;api_contentsidecars from rewritten copies.The current user image has continuity priority. The 512 KiB budget is therefore a soft aggregate safety bound around the active exchange, rather than a rule that can delete the input currently being answered.
The projection never mutates the canonical transcript. Only the API-bound copy is rewritten, so persistence, UI replay, and session history retain the original logical messages.
4. Make compression remove images from the protected tail
_strip_historical_media()now delegates to the same projection with:At a compression boundary, this explicitly ages out historical media even when it sits inside
protect_last_n.The compression projection retains the current user image and the newest active tool result for continuity, while replacing older image parts with text placeholders. This makes compression reclaim real payload bytes instead of reporting low savings while large screenshots remain protected.
Historical remote image references are also removed at this compression boundary even though they do not count as inline base64 bytes during ordinary request projection.
5. Apply the projection before prompt-cache planning
agent/conversation_loop.pyapplies_bound_inline_image_payloads()to the normal API message copy before prompt-cache planning and before the provider call.This ordering is intentional:
Cache planning therefore sees the same bounded message representation that will be sent to the provider.
A necessary image eviction may change the provider prefix once, but subsequent projections are deterministic and stable. The persisted transcript remains unchanged.
6. Apply the same policy to max-iteration summaries
agent/chat_completion_helpers.pyapplies the same projection to the independently assembled max-iteration summary request.This closes a separate request path that previously bypassed the main conversation-loop projection and could reintroduce historical native-vision payloads at the point where the session was already under the most context pressure.
7. Enforce the same cap for native browser screenshots
tools/browser_tool.pynow sends native browser screenshots through_prepare_native_vision_embed()before building the native tool result.This prevents
browser_visionfrom bypassing the history cap by constructing its own data URL and calling the result builder directly.Placeholder and compatibility behavior
When an image is removed from an outbound copy, the message itself remains present.
The replacement is a short text part:
The replacement type is adapted to the input format:
text;input_imageparts becomeinput_text;This preserves:
tool_call_idpairing;When a message is rewritten, its stale
api_contentsidecar is dropped so an older exact-wire representation cannot restore the removed image later in the request pipeline.Tests added and updated
Byte metric and projection tests
tests/agent/test_inline_image_payload_budget.pycovers:_multimodalenvelopes;api_contentremoval;input_imageplaceholders;Production-path tests
tests/run_agent/test_413_compression.pyverifies the real conversation path:tests/agent/test_api_content_sidecar.pyverifies that the separately assembled summary request applies the same image projection.Native vision and browser tests
tests/tools/test_vision_native_fast_path.pyverifies:tests/tools/test_vision_tools.pyupdates the dimension-validation contract so unknown dimensions returnNoneinstead of being treated as safe.tests/tools/test_browser_console.pynow uses a valid decodable 1x1 PNG fixture because the native history guard verifies real image dimensions.Verification
Focused canonical test runs recorded on Windows 11 / Python 3.11:
The following local runs were not reported as green because they were blocked by unrelated environment issues:
tests/tools/test_browser_use_cli.py: 70 passed and 22 failed because the Windows fixture attempted to launch POSIX shell scripts and received[WinError 193].tests/run_agent/test_run_agent.py: 274 passed and one pre-existing test failed because the optionalanthropicpackage was not installed locally.The first remote CI run identified an invalid browser PNG fixture after the new fail-closed dimension validation. The follow-up commit replaces that header-only fixture with a valid decodable PNG.
The current PR head is
28fef2dcbce8c258d4ef1ba27f63a959a3f5a2df.Remote GitHub status for the current head:
All required checks pass;Behavioral trade-offs
Risk and rollback
The change consists of in-memory request projection plus the existing image resize machinery. The canonical conversation transcript is not destructively rewritten by normal request projection, and placeholders preserve message ordering and tool-call pairing in the outbound copy.
Reverting the two commits restores the previous image retention and native history behavior.
Infographic