Charge image tokens by area instead of digest length (#783) - #854
Merged
mpfaffenberger merged 1 commit intoAug 24, 2026
Merged
Conversation
) stringify_part reduces a BinaryContent to a 16-hex-char digest, and estimate_tokens then scores that string. A 2048x2048 screenshot came out at 16 tokens against the ~5600 a vision model actually bills. Twenty screenshots estimated at 252 tokens instead of ~112k, so compact() returned early every cycle, the /context badge showed headroom, and the run died on a provider 400 with compaction having never executed. estimate_tokens_for_message now adds a per-BinaryContent charge derived from real image dimensions, read via Pillow which is already a dependency. Image.open parses only the header, so this does not decode pixels. Unreadable images and non-image attachments fall back to a deliberately generous constant, since undercounting is the failure mode that hurts. stringify_part still emits the digest, so existing dedup hashes are unchanged. Also gives the list-content loop an else arm. ImageUrl and DocumentUrl items matched neither branch and contributed nothing at all, so two messages pointing at entirely different URLs produced the same string and hashed identically. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Owner
|
Nice. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #783.
stringify_partreduces aBinaryContentto a 16-hex-char digest, andestimate_tokensthen scores that string. So an image costs whatever its digest happens to be long, no matter what is in it. Reproduced onmain:That undercount flows into
compact()'s threshold,filter_huge_messages' 50k rule, and the/contextbadge, which is why an image-heavy session sails past the real limit and dies on a provider 400 with compaction having never run.While reproducing it I found a second bug in the same loop. The
for item in content:arm handlesstrandBinaryContentand nothing else, soImageUrlandDocumentUrlcontribute nothing:Two different images dedup into one.
What this does
estimate_tokens_for_messagenow adds a per-BinaryContentcharge from real image dimensions, using(width * height) / 750— the ratio Anthropic documents, and close enough to OpenAI's tile math. Dimensions come from Pillow, already a dependency;Image.openparses just the header, so no pixel decoding on the estimation path.Unreadable images and non-image attachments (PDFs and such) take a deliberately generous constant rather than a small one. Overcharging costs an early compaction; undercharging is what kills the run.
stringify_partstill emits the digest exactly as before, so the charge is purely additive and existing dedup hashes are untouched. There's a test pinning that.The list loop gets an
elsearm appendingrepr(item), which fixes both the zero-token contribution and the hash collision.After:
Testing
12 new tests in
tests/agents/test_history_binary_tokens.py: the 2048x2048 case from the issue, area scaling, accumulation across 20 screenshots, both fallback paths, png/jpeg/webp, the collision fix, and two guards that text-only estimates and digest-based hashing are unchanged.I checked the tests actually fail when each fix is reverted — dropping the binary charge fails the large-image test, dropping the
elsearm fails the collision test — so they are not passing vacuously.tests/agents/is 415 passed.ruff checkandruff formatclean at 0.15.