feat(agents): charge tokens from the session's own counter

Moving bodies to sessions removed the source the token gate was fed from:
usage came from USAGE_METADATA events on the host process's emitter, and a
session does not raise those here. Deleting that path without a replacement
would have left the 200k tree budget permanently unreachable, which is worse
than a wrong number because nothing would say so.

A session reports what it has spent in total, not what each round cost. So
the run records a baseline when it starts and is charged the difference when
it ends, and the difference is written under one synthetic round rather than
invented per-round detail the source cannot support. The baseline matters
because a body is long-lived across threads: without it the first settlement
would charge one thread tree for everything that agent has ever spent.

A runtime that cannot report usage charges nothing rather than guessing. The
gate then trips late rather than blocking work that was never measured, which
is the safe direction for a limit whose job is to be noticed before it bites.

Not run: no tests or build on this machine.
This commit is contained in:
yiliang114 2026-09-08 00:23:40 +09:00
parent cbc8958379
commit f663f779a1
5 changed files with 99 additions and 1 deletions

View file

@ -38,7 +38,11 @@ import {
/** What the port needs from the bridge, so a test can supply four functions. */
export type AgentSessionBridge = Pick<
AcpSessionBridge,
'spawnOrAttach' | 'sendPrompt' | 'listWorkspaceSessions' | 'cancelSession'
| 'spawnOrAttach'
| 'sendPrompt'
| 'listWorkspaceSessions'
| 'cancelSession'
| 'getSessionStatsStatus'
>;
export interface CreateSessionDispatchPortInput {
@ -153,6 +157,24 @@ export function createSessionDispatchPort(
}
},
async totalTokens(agent): Promise<number | undefined> {
const session = sessionFor(bridge, workspaceCwd, agent);
if (!session) return undefined;
try {
const stats = await bridge.getSessionStatsStatus(session.sessionId);
// Summed across models: an agent may switch model mid-life, and the
// budget is money rather than a per-model quota.
return Object.values(stats.models).reduce(
(total, model) => total + (model.tokens?.total ?? 0),
0,
);
} catch {
// A body that cannot be read has not spent anything this pass. The
// gate under-counts rather than blocking work on a failed probe.
return undefined;
}
},
async cancel({ agent }): Promise<boolean> {
const session = sessionFor(bridge, workspaceCwd, agent);
if (!session) return false;

View file

@ -41,6 +41,7 @@ import {
requeueRun,
releaseRunClaim,
SYSTEM_AUTHOR_ID,
upsertRunUsage,
} from './thread-actions.js';
import type {
WorkspaceAgent,
@ -115,6 +116,17 @@ export interface AgentDispatchPort {
attempt: number;
contextThroughSequence: number;
}): Promise<AgentStartResult>;
/**
* Total tokens this agent's body has spent since it started, or undefined
* when the runtime cannot say.
*
* A cumulative reading rather than a per-round event: a session reports what
* it has spent, not what each round cost, so the dispatcher charges the
* difference across a run. That is why `usageByRound` records a
* monotonically increasing total under one synthetic round rather than
* pretending to per-round detail the source does not have.
*/
totalTokens?(agent: WorkspaceAgent): Promise<number | undefined>;
/** Definition content hash, when the port can supply one (§9.4). */
definitionVersion?(agent: WorkspaceAgent): Promise<string | undefined>;
}
@ -428,6 +440,10 @@ async function reconcileInterruptedRuns(
run.status === 'finishing' ||
(state.kind === 'completed' && !hasUndrainedInput)
) {
// Charge before the run goes terminal: once it is completed the
// baseline it was started with has nowhere left to live, and an
// uncharged run would let a tree spend past its budget silently.
await chargeRunUsage(projectRoot, port, agent, thread.id, run);
await withAgentStoreTransaction(projectRoot, (transaction) =>
finishRunInTransaction(transaction, {
threadId: thread.id,
@ -568,6 +584,45 @@ async function deliverRunningInputs(
* about this instant, and the next pass re-reads them rather than persisting a
* decision that was already stale when it was written.
*/
/**
* The synthetic round a session's cumulative reading is recorded under.
*
* A session reports what it has spent in total, not what each round cost, so
* there is no honest per-round breakdown to write. One entry that grows is the
* truthful shape; inventing rounds would make the record look more precise
* than the source.
*/
const SESSION_USAGE_ROUND = 1;
/**
* Charges what this run cost, as the difference from its starting reading.
*
* An agent's body is long-lived and works many threads, so its total is not
* this run's total. The baseline is written when the run starts; the delta is
* what this thread tree owes. A runtime that cannot report usage charges
* nothing rather than guessing, which under-counts — the gate then trips late
* rather than blocking work that was never measured.
*/
async function chargeRunUsage(
projectRoot: string,
port: AgentDispatchPort,
agent: WorkspaceAgent,
threadId: string,
run: ThreadRun,
): Promise<void> {
if (!port.totalTokens) return;
const total = await port.totalTokens(agent);
if (total === undefined) return;
const baseline = run.usageBaselineTokens ?? 0;
const spent = Math.max(0, total - baseline);
if (spent === 0) return;
await upsertRunUsage(projectRoot, threadId, run.id, {
attempt: run.attempts,
round: SESSION_USAGE_ROUND,
tokens: spent,
});
}
export async function dispatchOnce(
projectRoot: string,
port: AgentDispatchPort,
@ -652,6 +707,11 @@ export async function dispatchOnce(
});
if (result.status === 'started') {
// Read after the body started, so the reading includes the turn's own
// opening prompt in neither direction: the baseline and the settlement
// are taken from the same counter, and only what happens between them is
// charged to this thread tree.
const usageBaselineTokens = await port.totalTokens?.(agent);
await bindRunSession(projectRoot, {
threadId: thread.id,
runId: run.id,
@ -663,6 +723,7 @@ export async function dispatchOnce(
...(result.transcriptStartOffset !== undefined
? { transcriptStartOffset: result.transcriptStartOffset }
: {}),
...(usageBaselineTokens !== undefined ? { usageBaselineTokens } : {}),
});
records.push({ ...base, kind: 'started' });
continue;

View file

@ -312,6 +312,8 @@ function isValidRun(value: unknown): value is ThreadRun {
isOptionalNonNegativeInteger(value['closeAcknowledgedAtSequence']) &&
(value['finalMessageId'] === undefined ||
isValidId(value['finalMessageId'])) &&
(value['usageBaselineTokens'] === undefined ||
isNonNegativeInteger(value['usageBaselineTokens'])) &&
Array.isArray(value['usageByRound']) &&
value['usageByRound'].every(isValidUsageRound) &&
(value['failureStage'] === undefined ||

View file

@ -566,6 +566,8 @@ export interface BindRunSessionInput {
definitionVersion?: string;
/** Byte offset into the agent's transcript where this run's slice begins. */
transcriptStartOffset?: number;
/** The body's cumulative token total at start; the run is charged the delta. */
usageBaselineTokens?: number;
}
export async function bindRunSession(
@ -639,6 +641,9 @@ export async function bindRunSession(
...(input.transcriptStartOffset !== undefined
? { transcriptStartOffset: input.transcriptStartOffset }
: {}),
...(input.usageBaselineTokens !== undefined
? { usageBaselineTokens: input.usageBaselineTokens }
: {}),
}
: run,
),

View file

@ -239,6 +239,14 @@ export interface ThreadRun {
closeAcknowledgedAtSequence?: number;
finalMessageId?: string;
usageByRound: RunUsageRound[];
/**
* The agent body's cumulative token total when this run started.
*
* A body is long-lived and works many threads, so its total is not this
* run's. Without the baseline the first settlement would charge one thread
* tree for everything the agent has ever spent.
*/
usageBaselineTokens?: number;
failureStage?: string;
/** Workspace-wide FIFO key. */
queueSequence: number;