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