Skip to content

fix(agent): stop double-counting api_content in the token estimator - #75892

Merged
kshitijk4poor merged 2 commits into
NousResearch:mainfrom
kshitijk4poor:salvage/pr-75102-api-content-double-count
Aug 1, 2026
Merged

fix(agent): stop double-counting api_content in the token estimator#75892
kshitijk4poor merged 2 commits into
NousResearch:mainfrom
kshitijk4poor:salvage/pr-75102-api-content-double-count

Conversation

@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Summary

Stops the preflight token estimator from double-counting api_content — the sidecar is a substitute for content, not an addition, so counting both inflated estimates 2.00x on cache-pinned messages and made compaction fire on bytes never sent to the provider.

Changes

  • agent/model_metadata.py: factors shared shadow-building logic from _estimate_message_chars and _estimate_message_tokens_without_images into _wire_message_shadow(), which substitutes api_content for content mirroring turn_context.substitute_api_content()'s guard exactly (non-empty string sidecar on user/assistant row only)
  • tests/agent/test_model_metadata.py: 4 new tests covering substitution, lower-bound on sidecar counting, non-string/role guard, and image stripping survival

Validation

Before (upstream/main) After (this PR)
40KB sidecar estimate 20,013 tokens (2.00x) 10,008 tokens (1.00x)
test_model_metadata.py 54 passed
broader surface (compress/context/token/estimate/prune) 656 passed, 4 skipped
E2E (real imports, temp HERMES_HOME) 5/5 passed

Salvage of #75102 by @israellot. Cherry-picked both commits with authorship preserved.

Closes #75102

`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.
…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.
@kshitijk4poor
kshitijk4poor enabled auto-merge (rebase) August 1, 2026 05:32
@kshitijk4poor
kshitijk4poor merged commit 5c45d9c into NousResearch:main Aug 1, 2026
35 checks passed
@kshitijk4poor
kshitijk4poor deleted the salvage/pr-75102-api-content-double-count branch August 5, 2026 07:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants