From 56f2102c28a2fe7627ff85ffe0dd9891f8f3edc6 Mon Sep 17 00:00:00 2001 From: Peter Lindsey Date: Wed, 10 Jun 2026 07:48:58 +0800 Subject: [PATCH] fix(usage): thread real context occupancy + last-call usage into usageState context.used_tokens / pct_used were derived from the snapshot's aggregate prompt total (cacheRead+cacheWrite+input). Over a multi-call tool-loop turn that is the run AGGREGATE, overstating window occupancy (often past 100%) so a footer's context gauge pins full while /status shows the true ~7%. Add two optional fields to PluginHookReplyUsageState and populate them in the reply path: - contextUsedTokens: the final call's prompt size (agentMeta.promptTokens) = real end-of-turn occupancy, a point-in-time state, not the aggregate. - lastUsage: the final model call's usage only (vs `usage`, the turn aggregate), so a footer can render the last exchange's i/o + cache. Both optional and additive; consumers fall back to the aggregate when absent (correct for single-call turns). Renderer consumption lands separately (#89835). Co-Authored-By: Claude Opus 4.8 --- src/auto-reply/reply/agent-runner.ts | 19 +++++++++++++++++++ src/plugins/hook-types.ts | 22 ++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index f296cbd99b7d..6f0b27d99ff8 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -1748,6 +1748,7 @@ export async function runReplyAgent(params: { const winnerModel = runResult.meta?.executionTrace?.winnerModel ?? modelUsed; const ctxTokens = runResult.meta?.agentMeta?.contextTokens; const compactions = runResult.meta?.agentMeta?.compactionCount; + const lastCallUsage = runResult.meta?.agentMeta?.lastCallUsage; recordReplyUsageState( { runId, sessionKey }, { @@ -1790,6 +1791,14 @@ export async function runReplyAgent(params: { compactionCount: typeof compactions === "number" ? compactions : undefined, contextTokenBudget: typeof ctxTokens === "number" && Number.isFinite(ctxTokens) ? ctxTokens : undefined, + // Real end-of-turn context occupancy (final call's prompt tokens), so + // the footer's context gauge is a point-in-time state and not the + // multi-call aggregate. `promptTokens` is the agentMeta value already + // resolved above. + contextUsedTokens: + typeof promptTokens === "number" && Number.isFinite(promptTokens) + ? promptTokens + : undefined, usage: usage ? { input: usage.input, @@ -1799,6 +1808,16 @@ export async function runReplyAgent(params: { total: usage.total, } : undefined, + // Final model call only (vs the turn aggregate in `usage`). + lastUsage: lastCallUsage + ? { + input: lastCallUsage.input, + output: lastCallUsage.output, + cacheRead: lastCallUsage.cacheRead, + cacheWrite: lastCallUsage.cacheWrite, + total: lastCallUsage.total, + } + : undefined, // Provider subscription/limit windows for the 📊 readout. Non-blocking // (stale-while-revalidate): returns cached windows or undefined on a // cold cache and refreshes in the background, so it never delays the diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 9c20604ba924..50bdd2c26727 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -539,6 +539,15 @@ export type PluginHookReplyUsageState = { compactionCount?: number; /** Effective context-token budget after model/config/agent caps. */ contextTokenBudget?: number; + /** + * Actual context-window occupancy at the END of the turn — the final model + * call's prompt tokens, NOT the per-turn aggregate. This is the value + * `context.used_tokens` / `context.pct_used` must use: the aggregate prompt + * total over a multi-call tool loop overstates occupancy (often beyond the + * window). Absent on harnesses that don't report it (the contract then falls + * back to the aggregate prompt total, which is correct for single-call turns). + */ + contextUsedTokens?: number; usage?: { input?: number; output?: number; @@ -546,6 +555,19 @@ export type PluginHookReplyUsageState = { cacheWrite?: number; total?: number; }; + /** + * Usage from the FINAL model call of the turn only — vs `usage`, which is the + * turn aggregate summed across every tool-loop call. Lets a footer render the + * last exchange's i/o + cache instead of the whole turn. Absent on harnesses + * that don't report per-call usage. + */ + lastUsage?: { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + total?: number; + }; /** * Provider subscription/usage-limit windows for the active provider, attached * by core when it records the snapshot. Absent for api-key / unmapped