Repository navigation
Description
Product or interface: Source build or repository tooling
Version: source commit main (3c21163c), Node.js v26.7.0, pnpm 9.12.0
Platform: macOS
OS version and architecture: macOS 26.6.2 (25G83), arm64
Issue area: Context management / compaction
What happens
For every turn, the local context footprint is measurably larger than the usage
the provider reports for the same request. Comparing the two on the same turn, with
the same objects:
- provider side:
usage.input + usage.cacheRead + usage.cacheWritefor that
assistant message — the full prompt that was sent; - local side:
estimate(systemPrompt) + estimate(JSON.stringify(tools)) + Σ estimate(messages before this turn), i.e. the same arithmetic
createLocalContextFootprintMeasurerperforms.
Measured over six real sessions (30 to 447 turns each):
| session | turns | ratio (local / provider), median | tool-result tokens in the session |
|---|---|---|---|
| A | 447 | 1.164 | 246,567 |
| B | 258 | 1.207 | 190,497 |
| C | 207 | 1.278 | 139,677 |
| D | 183 | 1.076 | 109,119 |
| E | 30 | 1.104 | 19,228 |
| F | 395 | 1.044 | 200,515 |
The ratio is stable within a session and does not drift with history length. For the
same sessions, the fixed part (system prompt + tools) accounts for 36,250 local
tokens against 39,744 reported by the provider on a cold first turn — so the fixed
part is under-estimated, and the overshoot comes from the messages.
The dominant contributor is tool results: estimateMessage charges the full text
of every toolResult block, and in these sessions they are the largest single
component (110k-247k tokens each). Assistant toolCall arguments, thinking blocks
and plain text are estimated the same way.
Why it matters
This estimate is what decides when the context is compacted, and the user's view of
the same number comes from a different source — the provider-reported usage that
buildContextUsageMeasurement prefers. The two therefore disagree by a factor that
is invisible in the UI: the context ring shows one number, and the compaction
threshold is evaluated against a larger one.
The report that prompted this measurement described the context ring sitting at
~35% of the window while compaction fired at ~89% of it. Two effects stack:
- the local estimate runs 4-28% above the provider's own number for the same turn;
local-context-footprint.tstakesMath.max(localEstimate, anchor), and the
anchor only applies whenContextUsageAnchorState.resolvefinds the exact
assistant message it recorded. Whenever it does not resolve, the decision falls
back to the local estimate alone — so the provider's real number never gets a
chance to correct the local one.
Together those mean the compaction trigger can fire while the context ring still
shows a large amount of free space.
Steps to reproduce
- Take a session with tool calls and read its persisted
messages.jsonl. - For each assistant message with a
usageblock, record
usage.input + usage.cacheRead + usage.cacheWrite. - Walk the messages in order, accumulating the estimator's own per-message count
(estimateMessage, including the 4-token structural overhead), and compare
fixed + accumulatedagainst the recorded provider number at each assistant
message. - The ratio stays above 1.0 for the whole session.
What would help
Either of these would close the gap, and we are happy to help test whichever the
project prefers:
- a calibration of the per-message structural overhead — the flat 4 tokens per
message looks low once a turn carries several tool results, and the provider
appears to charge more for thetool_use/tool_resultpairing than we do; - or using the provider's own number as the primary source for the compaction
decision, with the local estimate only as a fallback when it is unavailable.
One caveat we want to state plainly: our measurement compares two numbers that are
close but not identical in construction, so we may be over- or under-stating the gap
by a few percent. What we are confident about is the sign and the order of
magnitude — the local estimate is consistently above the provider's number on the
same turn, on every session we measured, and tool results dominate the difference.
Activity
UncleMoc commentedon Nov 11, 2026
Two more findings that make this look like a wiring gap rather than a design choice.
The provider's own number is already available on another path.
ContextManagerinpackages/agent-modules/context-manager/src/manager.tsresolves its count through the injected token counter first:and
packages/local-runtime/src/context/remote-token-counter.tsdocuments that endpoint as ground truth, with the local estimate only as the fallback for a transient failure:It returns a source-tagged result (
source: 'remote' | 'estimate') so the caller can tell ground truth from an estimate, andpackages/local-runtime/src/context/context-usage-calibration.tsalready calibrates the tool portion against it.The comment above that call site also records a trap worth repeating here — headers and tool declarations have to be forwarded unchanged, because "tool declarations are part of the real LLM payload (tens of thousands of tokens in MCP-heavy sessions). Omitting them systematically undercounts and delays compaction triggers."
The automatic compactor does not use it.
packages/local-runtime-v2/src/service/turn-system/compaction/automatic-context-compactor.tsdecides throughcreateLocalContextFootprintMeasurerinstead, and that measurer is local-estimate only — we found no reference tocountContextTokensor to the remote counter anywhere underpackages/local-runtime-v2/src/.So the two paths disagree about where the number comes from: one asks the provider, the other estimates. That is consistent with what we measured (the estimate runs above the provider's number on the same turn), and it also means the fix we are asking for already exists in the codebase — just not on the path that decides when to compact.
Question: can the v2 compaction trigger reuse
ContextManager's counting, or is the separation deliberate? If it is deliberate, we would like to understand why, because the effect is visible to users as compaction firing while the context ring still shows room.UncleMoc commentedon Nov 11, 2026
The numbers in this issue reproduce exactly — and the
thinkingcorrection does not apply to the hosted pathThe reporter in #598 §9 re-ran our measurement and confirmed the direction and magnitude. We re-ran it ourselves after reading that, so here is the reproduction with the arithmetic spelled out.
Same six sessions, same formula:
estimate(systemPrompt) + estimate(JSON.stringify(tools)) + Σ estimate(messages before this turn)on the local side,usage.input + usage.cacheRead + usage.cacheWriteon the provider side for the same assistant message.Every row matches the published figure.
One thing to include when re-running this. The accumulated message count must include the
compactionSummarymessage itself. All six sessions start with one — the summary is the context root — and omitting its tokens shifts each ratio down by 0.02-0.05, which looks like a different measurement entirely.On the
thinkingquestion, the answer depends on the provider, and for the hosted path it is the opposite of what #598 §9 concluded. The drop isshouldDropPortableThinkinginoutbound-message-normalizer.ts:All of our sessions are
provider: "minimax",api: "anthropic-messages"— the hosted path, not the BYOK tree — soshouldDropPortableThinkingis false and the thinking block is kept (same-model messages are returned untouched; a siblinganthropic-messagesmodel replays the native block).Measured, not inferred. Same arithmetic, counting vs. excluding the thinking blocks:
Excluding thinking moves every session below 1 — the provider counts those tokens and the local estimate should too. On
custom_provider:*/minimax_apithe reporter's reading is the correct one and their numbers are the ones to trust; onminimax+anthropic-messagesthe published numbers are the right ones, and the local estimate really is above what the provider reports. The adjacent-turn difference form (which cancels the fixed part) agrees: median 0.99-1.08, in the same range as their 1.038.The ask is unchanged. The gap is real on the hosted path, it is not an artefact of the counting convention, and the fix is the same: take the provider's own number as the primary source for the compaction decision, keep the local estimate as the fallback when the provider cannot be asked.
ContextManagerinpackages/agent-modules/context-manager/src/manager.tsalready resolves through the injected counter first, andpackages/local-runtime/src/context/remote-token-counter.tstreats the provider endpoint as ground truth with the BPE estimate only as the degradation path — while the v2 compaction path underpackages/local-runtime-v2/src/service/turn-system/compaction/still recomputes the whole history locally. If that split is deliberate, we would like to understand the reasoning before assuming it is an oversight.