fix(server): scope the Claude context meter to the parent session - #8453
fix(server): scope the Claude context meter to the parent session#8453SamGu-NRX wants to merge 8 commits into
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
693de14 to
623fb57
Compare
The parent thread's Context Window meter counted tokens spent by its subagents, and could measure them against the largest context window in the agent tree rather than its own. A workflow with background agents could drive the parent toward 100% while its own transcript was small. Two paths caused it: - normalizeClaudeTaskProgressTokenUsage folded each child's task_progress and task_notification total into the parent's used count via Math.max. A child's tokens live in the child's own context, so they now advance only totalProcessedTokens. Task usage arriving before the parent has any usage of its own emits no parent event, since there is no baseline to attach a running total to; the child's numbers still ride on the task.progress event. - Where turn completion falls back to result modelUsage for the window, it took the maximum across every entry, so one 1M subagent widened a 200k parent's denominator. It now prefers the session model's entry. When getContextUsage() answers, its maxTokens still wins and neither change applies. Sessions without an explicit model selection had no recorded model to key that lookup on, so system/init's model is now recorded when none is set. Its value matches how modelUsage is keyed, suffix included. A refusal retry swaps the model for the rest of the session, so model_refusal_fallback now records the model that actually ran; without it the lookup keys the rejected model and silently falls back. Because child tokens now flow only into the running total, completing a turn had to stop overwriting it with the parent's own smaller figure. Total processed is cumulative thread work, so it keeps the larger value. Fixes pingdotgg#5942
623fb57 to
0741fda
Compare
A subagent's first small progress tick produced a cumulative figure below the parent's own used count, rendering as e.g. 3,000 used of 2,000 total (review finding on pingdotgg#8453). The snapshot builder already drops such totals; now the no-op event is skipped entirely instead of re-emitting the last snapshot unchanged. Also states at the accumulation site why the running total is a floor (Math.max) rather than a sum: the SDK does not document whether result usage aggregates children, and double-counting would overstate work while a floor only understates it.
Comments now state only what the code cannot: the floor-not-sum choice, the snapshot invariant, and why init and refusal fallback record the model. Removed restated mechanics and before/after changelog prose from tests.
ApprovabilityVerdict: Approved at Macroscope's review found this PR approvable — This is a contained Claude adapter bug fix that corrects parent token accounting and context-window selection while preserving existing model-selection behavior. Production changes are localized and accompanied by targeted regression tests, with no product-default, schema, security, billing, or deployment changes. You can add or adjust custom eligibility rules. Learn more. |
currentApiModelId is the last id handed to setModel, so sendTurn compares the user's selection against it to decide whether a mid-thread switch still needs sending. Recording the refusal fallback there made the next turn with an unchanged selection look like a switch and re-send the model the API had just refused. The window lookup needs the model that actually ran, which after a refusal is not the selected one, so it now reads a separate observedApiModelId that init and the refusal fallback write and setModel never consults.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 1ef15f8. Configure here.
observedApiModelId tracked what init and the refusal fallback reported but not a deliberate switch, so after sendTurn changed models the meter kept measuring against the previous model's window - or fell through to the maximum across modelUsage, which is the inflation this branch removes.
…meter-subagent-inflation
…gg#8610 Turn completion now prefers the last assistant usage over the cumulative result total, so the parent's used count here is its real 12,000 rather than the clamped window. The subagent total this guards is unchanged.
Dismissing prior approval to re-evaluate 50b5d09

Fixes #5942
Problem
The parent thread's Context Window meter counted tokens spent by its subagents, and could measure them against the largest context window in the agent tree instead of the session's own. A workflow with background agents drove the parent bar toward 100% while its own transcript stayed small.
Two people beyond the reporter confirmed it on the issue, one noting it "caused me to unnecessarily compact my session because I wasn't paying attention to how the parent context got so full." The meter is the signal for deciding whether to compact or start a fresh thread, so a wrong reading costs real work.
Evidence from a real session
A 13-turn Claude session (Fable, 1M window) that spawned 15 Luna scouts, read from its own provider event log:
Twelve parent meter events in that thread came from child
task_progress, and in all twelve the parent'susedTokensequals the child'stotal_tokensexactly. Each is followed by a drop of about 10,000 when the parent's next assistant snapshot arrives, which is the sawtooth described in #4650: the bar climbs on someone else's spend, then corrects.The same log shows the second half of this PR is not hypothetical either.
modelUsageat turn end carries bothclaude-fable-5[1m]at 1,000,000 andgpt-5.6-lunaat 200,000, so a maximum across that map is a coin flip on which window the meter reports.Fix
Both paths are in
ClaudeAdapter.ts:normalizeClaudeTaskProgressTokenUsagefolded each child'stask_progresstotal into the parent's used count viaMath.max. A child's tokens are spent in the child's own window, so they now advancetotalProcessedTokensonly. A follow-up commit answers review feedback: the running total is held back until it exceeds the parent's own usage (a child's first small tick would otherwise render as "3,000 used of 2,000 total"), and the floor-not-sum choice is stated at the accumulation site.completeTurnhad to stop replacing it with the parent's own smaller figure, which dropped the field from the snapshot entirely. Total processed is cumulative thread work, so it keeps the larger value. Both reviewers of this PR flagged this independently; it is included because the first change is what makes it reachable.modelUsagefor the window, it took the maximum across every entry, so one 1M subagent widened a 200k parent's denominator. It now prefers the session model's own entry. WhengetContextUsage()answers, itsmaxTokensstill wins and neither change applies.That lookup needs the id the SDK reports is actually serving the session, which is not always the selected one: sessions started without an explicit selection have none recorded, and a refusal retry swaps the model "persistent for the session" per the SDK's own docs.
system/initandmodel_refusal_fallbacktherefore record it in a separateobservedApiModelId, whose value matches howmodelUsageis keyed,[1m]suffix included. It is deliberately notcurrentApiModelId: that field mirrors the last id passed tosetModel, andsendTurncompares the user's selection against it, so writing a fallback there would make the next turn re-send the refused model. Its own test pins that.One consequence, pinned by its own test: subagent usage arriving before the parent has reported any usage no longer emits a parent meter event, because there is no baseline to attach a running total to. Previously that event was built from the child's numbers, which is the bug itself. The meter is briefly empty rather than wrong, and the child's tokens still ride on
task.progress.Verification
Nine regression tests, each verified failing on
mainand passing here:keeps subagent tokens out of the parent context meter (#5942)— the parent's own 4,200 survives a child's 900,000; before,usedTokensbecame 900,000.measures the meter against the session model's window, not a subagent's— a 200k session model alongside a 1M subagent reports 200,000; before, 1,000,000.uses the init model's window when no model was explicitly selected— covers the default-model path, which the second fix would otherwise miss.follows a persistent refusal fallback to the model that ran— after a refusal swap, reports the fallback's 1,000,000; before, the rejected model's 200,000.does not re-send a refused model on the next turn with the same selection— the second turn makes nosetModelcall at all. Caught by review; verified failing against the first version of this change.measures the window against the model a mid-thread switch selected— after switching from a 1M model to a 200k one, reports 200,000. Also caught by review on the commit that introduced the field.carries a subagent's running total into the parent's next snapshot— the child's 480,000 reachestotalProcessedTokenswhile the parent'susedTokensstays 3,000.keeps a subagent's running total when the parent turn completes— with a child at 900,000 and the parent's own result at 4,200, the final snapshot reports both; before,totalProcessedTokensdisappeared entirely.holds the running total until it exceeds the parent's own usage— a child's 2,000 tick under a 3,000-token parent emits nothing; the 5,000 tick that follows lands. Verified failing without the guard.One existing test,
preserves oversized Claude result totals after task progress snapshots are recorded, emitted its task progress before the parent had any usage, so the new guard discarded it and the assertion passed from the following result alone. It now establishes a parent baseline first, so the task total it names is actually retained.Three existing tests asserted the old behavior. Two counted an event that is no longer emitted; the third expected a child's 190,000 as the parent's
usedTokensand now expects the parent's own result. A fourth was renamed to state the contract directly.Overlap
fix(claude): report subagent model and effort) changes which model a subagent is labeled with; this changes whose tokens the parent meter counts and against which window. Different issues (Agents panel shows the parent session's model/effort for every subagent instead of the subagent's own #7281 vs [Bug]: Workflow/background agent tokens inflate main agent Context Window clock (parent should only show own context) #5942). I applied fix(claude): report subagent model and effort #7287 and this PR's source together onmain: they typecheck, and the suite passes at 85 tests once the three tests above are reconciled. Only the shared test file collides. If fix(claude): report subagent model and effort #7287 lands first I will rebase within a day.size:XL, +2790) rewrites compaction and session lifecycle and edits the same token-normalization functions, so it is the one open PR with hunk-level conflict here. It covers the accounting half of this bug but leaves themodelUsagewindow selection untouched, so neither PR makes the other unnecessary. Whichever lands first, the other needs its accounting hunks reconciled; happy to rebase this one within a day.main.Math.maxin the task-progress path this PR removes, and the issue was retitled accordingly; @t3dotgg linked [Bug]: Workflow/background agent tokens inflate main agent Context Window clock (parent should only show own context) #5942 as the canonical report for that half yesterday, and a fresh capture on 0.0.35 today notes the practical harm — the inflated meter convinces people autocompact is broken, so they compact sessions that are nowhere near full. The other half —compact_boundaryemitting nothing whenpost_tokensis absent, so/compactnever visibly resets the meter — is untouched here and is what fix(server): refresh Claude context meter after compact #7249 addresses.CodexAdapter.ts(child totals carrying inherited parent history). Different file; not addressed here.getContextUsage()misses its one-second budget,completeTurnfalls back toresult.usage, which is cumulative for the session, and the clamp turns that into a flat100%. Its author proposes preferring the lastmessage_deltareading over the cumulative one. That is a different branch ofcompleteTurnfrom either hunk here — this PR changes whichmodelUsageentry supplies the window, not which usage record is chosen — so the two are compatible, and [Bug]: Claude context meter jumps to 100% at end of turn - completeTurn falls back to cumulative session usage from result.usage #8594's fix would still want this one to pick the right denominator.Checklist