fix(agent): stop double-counting api_content in the token estimator - #75102
fix(agent): stop double-counting api_content in the token estimator#75102israellot wants to merge 2 commits into
Conversation
`api_content` is a SUBSTITUTE for `content`, not an addition to it. `turn_context.substitute_api_content()` pops the sidecar and overwrites `content` at every API-bound message-build site (the `api_messages` build in `conversation_loop`, the max-iterations summary in `chat_completion_helpers`, the chat-completions transport), so exactly one of the two is ever sent to the provider. The preflight estimator counted both, because both `_estimate_message_chars` and `_estimate_message_tokens_without_images` walked every key of the persisted dict with a single-entry denylist (`_anthropic_content_blocks`). Any message whose sidecar differs from its clean stored content was counted twice — exactly 2.00x on a 40KB sidecar. The sidecar exists to keep the provider prompt-cache prefix byte-stable, so it is written on precisely the long, cache-pinned messages where the doubling hurts most. Because `estimate_messages_tokens_rough()` also feeds the compaction threshold via `context_compressor` and `conversation_loop`, the inflated estimate makes compression fire on phantom bytes. Fix: substitute rather than sum, mirroring the wire. The two estimator helpers had drifted into near-identical copies of the same shadow-building loop, so this factors the shared logic into `_wire_message_shadow()` and fixes the class once instead of patching one site and leaving the other. Image accounting is unchanged: base64 payloads are still replaced with a placeholder and charged at the flat `_count_image_tokens` rate, and the `_multimodal` text_summary path is preserved. Tests: three cases in `TestEstimateMessagesTokensRough` — sidecar equal to content is counted once, a sidecar that DIFFERS is still counted (a lower bound, so it fails if the field were dropped rather than substituted, which would undercount the real request), and a sidecar cannot smuggle raw base64 past the flat image rate. Verified on Linux (Python 3.11): 53 passed in tests/agent/test_model_metadata.py, 57 passed with tests/agent/test_context_breakdown.py, 656 passed / 3 skipped across the compression/context/token/estimate/prune surface of tests/agent. Mutation-tested: reverting the substitution fails the new equality test. `scripts/check-windows-footguns.py` is not applicable — no file I/O, process management, terminal handling, subprocesses, or signals.
|
Thanks for isolating this from the related reasoning/Anthropic estimator work. The premise is present on current main: Problems
Suggested changes
This is an automated hermes-sweeper review. |
…adow Review follow-up on NousResearch#75102. The shadow substituted the sidecar whenever the ``api_content`` key was merely PRESENT, but the wire only substitutes a non-empty string sidecar on a user/assistant row (see ``turn_context.substitute_api_content``). For any other shape the sidecar is popped and discarded while the clean ``content`` is sent -- so the shadow dropped real content from the estimate and UNDERcounted, the dangerous direction: compaction fires too late and the turn dies on a hard context-length error instead of merely compressing early. Gate the substitution on the same predicate, and cover the divergent shapes (None, empty string, int, list, non-user/assistant role) with a test that fails against the unconditional version. Also rename the image test: it never carried a sidecar, so it was not testing what its name claimed. It is a non-regression pin on the flat per-image accounting that moved into ``_wire_message_shadow()``, and is now named for that.
|
Addressed in 9910a13. Thanks — the mis-named test was a real defect in the diff, and chasing it turned up a second fidelity gap in my own change. 1. The mis-named image test (sweeper finding) — fixedYou're right on both counts: the fixture never carried 2. The shadow didn't mirror
|
…adow Review follow-up on #75102. The shadow substituted the sidecar whenever the ``api_content`` key was merely PRESENT, but the wire only substitutes a non-empty string sidecar on a user/assistant row (see ``turn_context.substitute_api_content``). For any other shape the sidecar is popped and discarded while the clean ``content`` is sent -- so the shadow dropped real content from the estimate and UNDERcounted, the dangerous direction: compaction fires too late and the turn dies on a hard context-length error instead of merely compressing early. Gate the substitution on the same predicate, and cover the divergent shapes (None, empty string, int, list, non-user/assistant role) with a test that fails against the unconditional version. Also rename the image test: it never carried a sidecar, so it was not testing what its name claimed. It is a non-regression pin on the flat per-image accounting that moved into ``_wire_message_shadow()``, and is now named for that.
|
Merged via #75892. Your commits cherry-picked with authorship preserved (rebase-merge). Thanks for the thorough fix and the excellent PR description! |
…adow Review follow-up on NousResearch#75102. The shadow substituted the sidecar whenever the ``api_content`` key was merely PRESENT, but the wire only substitutes a non-empty string sidecar on a user/assistant row (see ``turn_context.substitute_api_content``). For any other shape the sidecar is popped and discarded while the clean ``content`` is sent -- so the shadow dropped real content from the estimate and UNDERcounted, the dangerous direction: compaction fires too late and the turn dies on a hard context-length error instead of merely compressing early. Gate the substitution on the same predicate, and cover the divergent shapes (None, empty string, int, list, non-user/assistant role) with a test that fails against the unconditional version. Also rename the image test: it never carried a sidecar, so it was not testing what its name claimed. It is a non-regression pin on the flat per-image accounting that moved into ``_wire_message_shadow()``, and is now named for that.
What changed and why
api_contentis a substitute forcontent, not an addition to it.turn_context.substitute_api_content()pops the sidecar and overwritescontentat every API-bound message-build site (theapi_messagesbuild inconversation_loop, the max-iterations summary inchat_completion_helpers, the chat-completions transport), so exactly one of the two is ever sent to the provider.The preflight estimator counted both. Both
_estimate_message_charsand_estimate_message_tokens_without_imageswalked every key of the persisted message dict behind a single-entry denylist (_anthropic_content_blocks), so any message carrying a sidecar that differs from its clean stored content was counted twice — exactly 2.00x on a 40KB sidecar:Two reasons this is worth fixing rather than tolerating as "rough":
estimate_messages_tokens_rough()is not display-only — it feeds the compaction threshold throughcontext_compressorandconversation_loop. An inflated estimate makes compression fire on bytes that were never sent.Approach
Substitute rather than sum, mirroring what
substitute_api_content()does on the wire.The two estimator helpers had drifted into near-identical copies of the same shadow-building loop, so rather than patch one site and leave the other subtly different, this factors the shared logic into
_wire_message_shadow()and fixes the class once. Net effect on behaviour is limited toapi_content; the refactor is otherwise mechanical.Image accounting is deliberately untouched — base64 payloads are still replaced with a placeholder and charged at the flat
_count_image_tokensrate, and the_multimodaltext_summarypath is preserved.How to test
python -m pytest tests/agent/test_model_metadata.py -q python -m pytest tests/agent -q -k "compress or context or token or estimate or prune"Three new cases in
TestEstimateMessagesTokensRough:test_api_content_substitutes_for_content_not_added_to_it— sidecar equal to content is counted once (this is the regression guard; reverting the substitution fails it).test_api_content_is_counted_when_it_differs_from_content— a lower bound, so it fails if the field were dropped rather than substituted. Dropping it would undercount the real request, which is the more dangerous direction: compaction would fire too late and the turn dies on a hard context error instead of compacting early.test_api_content_does_not_defeat_image_stripping— a sidecar cannot smuggle raw base64 past the flat image rate.Results on this branch:
53 passed—tests/agent/test_model_metadata.py57 passed— withtests/agent/test_context_breakdown.py656 passed, 3 skipped— compression/context/token/estimate/prune surface oftests/agentPlatforms tested
Linux (x86_64, Python 3.11).
scripts/check-windows-footguns.pyis not applicable — this change touches no file I/O, process management, terminal handling, subprocesses, or signals. Pure dict/str logic, so cross-platform behaviour is identical.Related
Same function, deliberately not the same bug as the reasoning-field over-count tracked in #73298 (with #72087 and #73306 in flight). Those concern the
reasoning/reasoning_content/reasoning_detailstriple-count; this is the independentapi_contentdouble-count and touches a disjoint branch of the same loop. Kept separate to stay one-logical-change-per-PR and to avoid conflicting with that work.One note for whoever consolidates #72087 / #73306, since it bit me: the exclusion at the top of these loops names
_anthropic_content_blockswith a leading underscore, while the wire path writesanthropic_content_blocks(chat_completion_helpers→anthropic_adapter). The real replay channel is therefore counted only by accident today, and reshaping this loop into a field allowlist silently drops it — a ~1000x undercount on thinking models. Details in my comment on #72087. This PR keeps the denylist shape precisely to avoid that trap.