mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-21 22:39:45 +00:00
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:
parent
cbc8958379
commit
f663f779a1
5 changed files with 99 additions and 1 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue