Skip to content

Commit d1fa323

Browse files
Feat/a2a expose usage metadata (google-gemini#27288)
1 parent ba04e99 commit d1fa323

2 files changed

Lines changed: 81 additions & 0 deletions

File tree

packages/a2a-server/src/agent/task.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,66 @@ describe('Task', () => {
294294
]);
295295
});
296296

297+
it('should capture usageMetadata on Finished event and include it in final status update', async () => {
298+
const mockConfig = createMockConfig();
299+
const mockEventBus: ExecutionEventBus = {
300+
publish: vi.fn(),
301+
on: vi.fn(),
302+
off: vi.fn(),
303+
once: vi.fn(),
304+
removeAllListeners: vi.fn(),
305+
finished: vi.fn(),
306+
};
307+
308+
// @ts-expect-error - Calling private constructor for test purposes.
309+
const task = new Task(
310+
'task-id',
311+
'context-id',
312+
mockConfig as Config,
313+
mockEventBus,
314+
);
315+
316+
const finishedEvent = {
317+
type: GeminiEventType.Finished,
318+
value: {
319+
reason: 'STOP',
320+
usageMetadata: {
321+
promptTokenCount: 100,
322+
candidatesTokenCount: 50,
323+
totalTokenCount: 150,
324+
},
325+
},
326+
};
327+
328+
await task.acceptAgentMessage(finishedEvent);
329+
expect(task.usageMetadata).toEqual({
330+
promptTokenCount: 100,
331+
candidatesTokenCount: 50,
332+
totalTokenCount: 150,
333+
});
334+
335+
task.setTaskStateAndPublishUpdate(
336+
'input-required',
337+
{ kind: CoderAgentEvent.StateChangeEvent },
338+
undefined,
339+
undefined,
340+
true, // final
341+
);
342+
343+
expect(mockEventBus.publish).toHaveBeenCalledWith(
344+
expect.objectContaining({
345+
final: true,
346+
metadata: expect.objectContaining({
347+
usageMetadata: {
348+
promptTokenCount: 100,
349+
candidatesTokenCount: 50,
350+
totalTokenCount: 150,
351+
},
352+
}),
353+
}),
354+
);
355+
});
356+
297357
it('should update modelInfo and reflect it in metadata and status updates', async () => {
298358
const mockConfig = createMockConfig();
299359
const mockEventBus: ExecutionEventBus = {

packages/a2a-server/src/agent/task.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ export class Task {
8989
currentAgentMessageId = uuidv4();
9090
promptCount = 0;
9191
autoExecute: boolean;
92+
usageMetadata?: {
93+
promptTokenCount?: number;
94+
candidatesTokenCount?: number;
95+
totalTokenCount?: number;
96+
cachedContentTokenCount?: number;
97+
};
9298
private get isYoloMatch(): boolean {
9399
return (
94100
this.autoExecute || this.config.getApprovalMode() === ApprovalMode.YOLO
@@ -274,6 +280,7 @@ export class Task {
274280
userTier?: UserTierId;
275281
error?: string;
276282
traceId?: string;
283+
usageMetadata?: Task['usageMetadata'];
277284
} = {
278285
coderAgent: coderAgentMessage,
279286
model: this.modelInfo || this.config.getModel(),
@@ -288,6 +295,10 @@ export class Task {
288295
metadata.traceId = traceId;
289296
}
290297

298+
if (final && this.usageMetadata) {
299+
metadata.usageMetadata = this.usageMetadata;
300+
}
301+
291302
return {
292303
kind: 'status-update',
293304
taskId: this.id,
@@ -857,8 +868,18 @@ export class Task {
857868
break;
858869
case GeminiEventType.Finished:
859870
logger.info(`[Task ${this.id}] Agent finished its turn.`);
871+
// Capture the usage metadata when the stream finishes
872+
if (
873+
event.value &&
874+
typeof event.value === 'object' &&
875+
'usageMetadata' in event.value
876+
) {
877+
this.usageMetadata = event.value
878+
.usageMetadata as typeof this.usageMetadata;
879+
}
860880
break;
861881
case GeminiEventType.ModelInfo:
882+
this.usageMetadata = undefined;
862883
this.modelInfo = event.value;
863884
break;
864885
case GeminiEventType.Retry:

0 commit comments

Comments
 (0)