Skip to content

fix(server): scope the Claude context meter to the parent session - #8453

Open
SamGu-NRX wants to merge 8 commits into
pingdotgg:mainfrom
SamGu-NRX:fix/parent-context-meter-subagent-inflation
Open

fix(server): scope the Claude context meter to the parent session#8453
SamGu-NRX wants to merge 8 commits into
pingdotgg:mainfrom
SamGu-NRX:fix/parent-context-meter-subagent-inflation

Conversation

@SamGu-NRX

@SamGu-NRX SamGu-NRX commented Aug 27, 2026

Copy link
Copy Markdown

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:

claude/system/task_progress   subagent_type: theo-luna-context-scout
  child   usage.total_tokens : 141875
  parent  usedTokens         : 141875   <- the child's number, verbatim
          maxTokens          : 1000000

Twelve parent meter events in that thread came from child task_progress, and in all twelve the parent's usedTokens equals the child's total_tokens exactly. 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. modelUsage at turn end carries both claude-fable-5[1m] at 1,000,000 and gpt-5.6-luna at 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:

  • normalizeClaudeTaskProgressTokenUsage folded each child's task_progress total into the parent's used count via Math.max. A child's tokens are spent in the child's own window, so they now advance totalProcessedTokens only. 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.
  • Because child tokens now flow only into that running total, completeTurn had 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.
  • 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 own entry. When getContextUsage() answers, its maxTokens still 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/init and model_refusal_fallback therefore record it in a separate observedApiModelId, whose value matches how modelUsage is keyed, [1m] suffix included. It is deliberately not currentApiModelId: that field mirrors the last id passed to setModel, and sendTurn compares 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

vp test run src/provider/Layers/ClaudeAdapter.test.ts   87 passed (87)
tsgo --noEmit                                           no new errors
vp fmt                                                  clean

Nine regression tests, each verified failing on main and 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, usedTokens became 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 no setModel call 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 reaches totalProcessedTokens while the parent's usedTokens stays 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, totalProcessedTokens disappeared 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 usedTokens and now expects the parent's own result. A fourth was renamed to state the contract directly.

Overlap

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • Screenshots for UI changes — no client files changed; the existing meter renders corrected server data
  • Video for animation/interaction changes — not applicable

@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1822f7ba-3de6-494a-9d6d-7344a5d6b805

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Aug 27, 2026
Comment thread apps/server/src/provider/Layers/ClaudeAdapter.ts
@SamGu-NRX
SamGu-NRX force-pushed the fix/parent-context-meter-subagent-inflation branch from 693de14 to 623fb57 Compare August 27, 2026 22:06
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
@SamGu-NRX
SamGu-NRX force-pushed the fix/parent-context-meter-subagent-inflation branch from 623fb57 to 0741fda Compare August 27, 2026 22:44
Comment thread apps/server/src/provider/Layers/ClaudeAdapter.ts
Comment thread apps/server/src/provider/Layers/ClaudeAdapter.ts
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.
@SamGu-NRX
SamGu-NRX marked this pull request as ready for review August 29, 2026 01:37
Comment thread apps/server/src/provider/Layers/ClaudeAdapter.ts
@macroscopeapp

macroscopeapp Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Approved at 50b5d09

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.
Comment thread apps/server/src/provider/Layers/ClaudeAdapter.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread apps/server/src/provider/Layers/ClaudeAdapter.ts
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.
macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Aug 29, 2026
SamGu-NRX and others added 3 commits August 28, 2026 21:46
…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.
@macroscopeapp
macroscopeapp Bot dismissed their stale review August 29, 2026 08:19

Dismissing prior approval to re-evaluate 50b5d09

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M 30-99 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Workflow/background agent tokens inflate main agent Context Window clock (parent should only show own context)

1 participant