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 <noreply@anthropic.com>
This commit is contained in:
Peter Lindsey
2026-06-10 07:48:58 +08:00
committed by Ayaan Zaidi
parent 99db98a7ce
commit 56f2102c28
2 changed files with 41 additions and 0 deletions
+19
View File
@@ -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
+22
View File
@@ -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