mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Merge pull request #1176 from QwenLM/feat/acp-usage-metadata
Feat/acp usage metadata
This commit is contained in:
@@ -25,6 +25,14 @@ type PendingRequest = {
|
||||
timeout: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
type UsageMetadata = {
|
||||
promptTokens?: number | null;
|
||||
completionTokens?: number | null;
|
||||
thoughtsTokens?: number | null;
|
||||
totalTokens?: number | null;
|
||||
cachedTokens?: number | null;
|
||||
};
|
||||
|
||||
type SessionUpdateNotification = {
|
||||
sessionId?: string;
|
||||
update?: {
|
||||
@@ -39,6 +47,9 @@ type SessionUpdateNotification = {
|
||||
text?: string;
|
||||
};
|
||||
modeId?: string;
|
||||
_meta?: {
|
||||
usage?: UsageMetadata;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -587,4 +598,52 @@ function setupAcpTest(
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('receives usage metadata in agent_message_chunk updates', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp usage metadata');
|
||||
|
||||
const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig);
|
||||
|
||||
try {
|
||||
await sendRequest('initialize', {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
|
||||
});
|
||||
await sendRequest('authenticate', { methodId: 'openai' });
|
||||
|
||||
const newSession = (await sendRequest('session/new', {
|
||||
cwd: rig.testDir!,
|
||||
mcpServers: [],
|
||||
})) as { sessionId: string };
|
||||
|
||||
await sendRequest('session/prompt', {
|
||||
sessionId: newSession.sessionId,
|
||||
prompt: [{ type: 'text', text: 'Say "hello".' }],
|
||||
});
|
||||
|
||||
await delay(500);
|
||||
|
||||
// Find updates with usage metadata
|
||||
const updatesWithUsage = sessionUpdates.filter(
|
||||
(u) =>
|
||||
u.update?.sessionUpdate === 'agent_message_chunk' &&
|
||||
u.update?._meta?.usage,
|
||||
);
|
||||
|
||||
expect(updatesWithUsage.length).toBeGreaterThan(0);
|
||||
|
||||
const usage = updatesWithUsage[0].update?._meta?.usage;
|
||||
expect(usage).toBeDefined();
|
||||
expect(
|
||||
typeof usage?.promptTokens === 'number' ||
|
||||
typeof usage?.totalTokens === 'number',
|
||||
).toBe(true);
|
||||
} catch (e) {
|
||||
if (stderr.length) console.error('Agent stderr:', stderr.join(''));
|
||||
throw e;
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -316,6 +316,23 @@ export const annotationsSchema = z.object({
|
||||
priority: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export const usageSchema = z.object({
|
||||
promptTokens: z.number().optional().nullable(),
|
||||
completionTokens: z.number().optional().nullable(),
|
||||
thoughtsTokens: z.number().optional().nullable(),
|
||||
totalTokens: z.number().optional().nullable(),
|
||||
cachedTokens: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export type Usage = z.infer<typeof usageSchema>;
|
||||
|
||||
export const sessionUpdateMetaSchema = z.object({
|
||||
usage: usageSchema.optional().nullable(),
|
||||
durationMs: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export type SessionUpdateMeta = z.infer<typeof sessionUpdateMetaSchema>;
|
||||
|
||||
export const requestPermissionResponseSchema = z.object({
|
||||
outcome: requestPermissionOutcomeSchema,
|
||||
});
|
||||
@@ -500,10 +517,12 @@ export const sessionUpdateSchema = z.union([
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
sessionUpdate: z.literal('agent_message_chunk'),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
sessionUpdate: z.literal('agent_thought_chunk'),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
content: z.array(toolCallContentSchema).optional(),
|
||||
|
||||
59
packages/cli/src/acp-integration/service/filesystem.test.ts
Normal file
59
packages/cli/src/acp-integration/service/filesystem.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { FileSystemService } from '@qwen-code/qwen-code-core';
|
||||
import { AcpFileSystemService } from './filesystem.js';
|
||||
|
||||
const createFallback = (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
findFiles: vi.fn().mockReturnValue([]),
|
||||
});
|
||||
|
||||
describe('AcpFileSystemService', () => {
|
||||
describe('readTextFile ENOENT handling', () => {
|
||||
it('parses path from ACP ENOENT message (quoted)', async () => {
|
||||
const client = {
|
||||
readTextFile: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ content: 'ERROR: ENOENT: "/remote/file.txt"' }),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
'session-1',
|
||||
{ readTextFile: true, writeTextFile: true },
|
||||
createFallback(),
|
||||
);
|
||||
|
||||
await expect(svc.readTextFile('/local/file.txt')).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
path: '/remote/file.txt',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to requested path when none provided', async () => {
|
||||
const client = {
|
||||
readTextFile: vi.fn().mockResolvedValue({ content: 'ERROR: ENOENT:' }),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
'session-2',
|
||||
{ readTextFile: true, writeTextFile: true },
|
||||
createFallback(),
|
||||
);
|
||||
|
||||
await expect(
|
||||
svc.readTextFile('/fallback/path.txt'),
|
||||
).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
path: '/fallback/path.txt',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,20 @@ export class AcpFileSystemService implements FileSystemService {
|
||||
limit: null,
|
||||
});
|
||||
|
||||
if (response.content.startsWith('ERROR: ENOENT:')) {
|
||||
// Treat ACP error strings as structured ENOENT errors without
|
||||
// assuming a specific platform format.
|
||||
const match = /^ERROR:\s*ENOENT:\s*(?<path>.*)$/i.exec(response.content);
|
||||
const err = new Error(response.content) as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
err.errno = -2;
|
||||
const rawPath = match?.groups?.['path']?.trim();
|
||||
err['path'] = rawPath
|
||||
? rawPath.replace(/^['"]|['"]$/g, '') || filePath
|
||||
: filePath;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return response.content;
|
||||
}
|
||||
|
||||
|
||||
@@ -411,4 +411,48 @@ describe('HistoryReplayer', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('usage metadata replay', () => {
|
||||
it('should emit usage metadata after assistant message content', async () => {
|
||||
const record: ChatRecord = {
|
||||
uuid: 'assistant-uuid',
|
||||
parentUuid: 'user-uuid',
|
||||
sessionId: 'test-session',
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'assistant',
|
||||
cwd: '/test',
|
||||
version: '1.0.0',
|
||||
message: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'Hello!' }],
|
||||
},
|
||||
usageMetadata: {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
totalTokenCount: 150,
|
||||
},
|
||||
};
|
||||
|
||||
await replayer.replay([record]);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledTimes(2);
|
||||
expect(sendUpdateSpy).toHaveBeenNthCalledWith(1, {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'Hello!' },
|
||||
});
|
||||
expect(sendUpdateSpy).toHaveBeenNthCalledWith(2, {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: '' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
thoughtsTokens: undefined,
|
||||
totalTokens: 150,
|
||||
cachedTokens: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ChatRecord } from '@qwen-code/qwen-code-core';
|
||||
import type { Content } from '@google/genai';
|
||||
import type { ChatRecord, TaskResultDisplay } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
Content,
|
||||
GenerateContentResponseUsageMetadata,
|
||||
} from '@google/genai';
|
||||
import type { SessionContext } from './types.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
@@ -52,6 +55,9 @@ export class HistoryReplayer {
|
||||
if (record.message) {
|
||||
await this.replayContent(record.message, 'assistant');
|
||||
}
|
||||
if (record.usageMetadata) {
|
||||
await this.replayUsageMetadata(record.usageMetadata);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_result':
|
||||
@@ -88,11 +94,22 @@ export class HistoryReplayer {
|
||||
toolName: functionName,
|
||||
callId,
|
||||
args: part.functionCall.args as Record<string, unknown>,
|
||||
status: 'in_progress',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays usage metadata.
|
||||
* @param usageMetadata - The usage metadata to replay
|
||||
*/
|
||||
private async replayUsageMetadata(
|
||||
usageMetadata: GenerateContentResponseUsageMetadata,
|
||||
): Promise<void> {
|
||||
await this.messageEmitter.emitUsageMetadata(usageMetadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays a tool result record.
|
||||
*/
|
||||
@@ -118,6 +135,54 @@ export class HistoryReplayer {
|
||||
// Note: args aren't stored in tool_result records by default
|
||||
args: undefined,
|
||||
});
|
||||
|
||||
// Special handling: Task tool execution summary contains token usage
|
||||
const { resultDisplay } = result ?? {};
|
||||
if (
|
||||
!!resultDisplay &&
|
||||
typeof resultDisplay === 'object' &&
|
||||
'type' in resultDisplay &&
|
||||
(resultDisplay as { type?: unknown }).type === 'task_execution'
|
||||
) {
|
||||
await this.emitTaskUsageFromResultDisplay(
|
||||
resultDisplay as TaskResultDisplay,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits token usage from a TaskResultDisplay execution summary, if present.
|
||||
*/
|
||||
private async emitTaskUsageFromResultDisplay(
|
||||
resultDisplay: TaskResultDisplay,
|
||||
): Promise<void> {
|
||||
const summary = resultDisplay.executionSummary;
|
||||
if (!summary) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usageMetadata: GenerateContentResponseUsageMetadata = {};
|
||||
|
||||
if (Number.isFinite(summary.inputTokens)) {
|
||||
usageMetadata.promptTokenCount = summary.inputTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.outputTokens)) {
|
||||
usageMetadata.candidatesTokenCount = summary.outputTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.thoughtTokens)) {
|
||||
usageMetadata.thoughtsTokenCount = summary.thoughtTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.cachedTokens)) {
|
||||
usageMetadata.cachedContentTokenCount = summary.cachedTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.totalTokens)) {
|
||||
usageMetadata.totalTokenCount = summary.totalTokens;
|
||||
}
|
||||
|
||||
// Only emit if we captured at least one token metric
|
||||
if (Object.keys(usageMetadata).length > 0) {
|
||||
await this.messageEmitter.emitUsageMetadata(usageMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content, FunctionCall, Part } from '@google/genai';
|
||||
import type {
|
||||
Content,
|
||||
FunctionCall,
|
||||
GenerateContentResponseUsageMetadata,
|
||||
Part,
|
||||
} from '@google/genai';
|
||||
import type {
|
||||
Config,
|
||||
GeminiChat,
|
||||
@@ -55,6 +60,7 @@ import type { SessionContext, ToolCallStartParams } from './types.js';
|
||||
import { HistoryReplayer } from './HistoryReplayer.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
import { PlanEmitter } from './emitters/PlanEmitter.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import { SubAgentTracker } from './SubAgentTracker.js';
|
||||
|
||||
/**
|
||||
@@ -79,6 +85,7 @@ export class Session implements SessionContext {
|
||||
private readonly historyReplayer: HistoryReplayer;
|
||||
private readonly toolCallEmitter: ToolCallEmitter;
|
||||
private readonly planEmitter: PlanEmitter;
|
||||
private readonly messageEmitter: MessageEmitter;
|
||||
|
||||
// Implement SessionContext interface
|
||||
readonly sessionId: string;
|
||||
@@ -96,6 +103,7 @@ export class Session implements SessionContext {
|
||||
this.toolCallEmitter = new ToolCallEmitter(this);
|
||||
this.planEmitter = new PlanEmitter(this);
|
||||
this.historyReplayer = new HistoryReplayer(this);
|
||||
this.messageEmitter = new MessageEmitter(this);
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
@@ -192,6 +200,8 @@ export class Session implements SessionContext {
|
||||
}
|
||||
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
let usageMetadata: GenerateContentResponseUsageMetadata | null = null;
|
||||
const streamStartTime = Date.now();
|
||||
|
||||
try {
|
||||
const responseStream = await chat.sendMessageStream(
|
||||
@@ -222,20 +232,18 @@ export class Session implements SessionContext {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content: acp.ContentBlock = {
|
||||
type: 'text',
|
||||
text: part.text,
|
||||
};
|
||||
|
||||
this.sendUpdate({
|
||||
sessionUpdate: part.thought
|
||||
? 'agent_thought_chunk'
|
||||
: 'agent_message_chunk',
|
||||
content,
|
||||
});
|
||||
this.messageEmitter.emitMessage(
|
||||
part.text,
|
||||
'assistant',
|
||||
part.thought,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) {
|
||||
usageMetadata = resp.value.usageMetadata;
|
||||
}
|
||||
|
||||
if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
|
||||
functionCalls.push(...resp.value.functionCalls);
|
||||
}
|
||||
@@ -251,6 +259,15 @@ export class Session implements SessionContext {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (usageMetadata) {
|
||||
const durationMs = Date.now() - streamStartTime;
|
||||
await this.messageEmitter.emitUsageMetadata(
|
||||
usageMetadata,
|
||||
'',
|
||||
durationMs,
|
||||
);
|
||||
}
|
||||
|
||||
if (functionCalls.length > 0) {
|
||||
const toolResponseParts: Part[] = [];
|
||||
|
||||
@@ -444,7 +461,9 @@ export class Session implements SessionContext {
|
||||
}
|
||||
|
||||
const confirmationDetails =
|
||||
await invocation.shouldConfirmExecute(abortSignal);
|
||||
this.config.getApprovalMode() !== ApprovalMode.YOLO
|
||||
? await invocation.shouldConfirmExecute(abortSignal)
|
||||
: false;
|
||||
|
||||
if (confirmationDetails) {
|
||||
const content: acp.ToolCallContent[] = [];
|
||||
@@ -522,6 +541,7 @@ export class Session implements SessionContext {
|
||||
callId,
|
||||
toolName: fc.name,
|
||||
args,
|
||||
status: 'in_progress',
|
||||
};
|
||||
await this.toolCallEmitter.emitStart(startParams);
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ describe('SubAgentTracker', () => {
|
||||
expect.objectContaining({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-123',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'read_file',
|
||||
content: [],
|
||||
locations: [],
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
SubAgentToolCallEvent,
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentApprovalRequestEvent,
|
||||
SubAgentUsageEvent,
|
||||
ToolCallConfirmationDetails,
|
||||
AnyDeclarativeTool,
|
||||
AnyToolInvocation,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
import { z } from 'zod';
|
||||
import type { SessionContext } from './types.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import type * as acp from '../acp.js';
|
||||
|
||||
/**
|
||||
@@ -62,6 +64,7 @@ const basicPermissionOptions: readonly PermissionOptionConfig[] = [
|
||||
*/
|
||||
export class SubAgentTracker {
|
||||
private readonly toolCallEmitter: ToolCallEmitter;
|
||||
private readonly messageEmitter: MessageEmitter;
|
||||
private readonly toolStates = new Map<
|
||||
string,
|
||||
{
|
||||
@@ -76,6 +79,7 @@ export class SubAgentTracker {
|
||||
private readonly client: acp.Client,
|
||||
) {
|
||||
this.toolCallEmitter = new ToolCallEmitter(ctx);
|
||||
this.messageEmitter = new MessageEmitter(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,16 +96,19 @@ export class SubAgentTracker {
|
||||
const onToolCall = this.createToolCallHandler(abortSignal);
|
||||
const onToolResult = this.createToolResultHandler(abortSignal);
|
||||
const onApproval = this.createApprovalHandler(abortSignal);
|
||||
const onUsageMetadata = this.createUsageMetadataHandler(abortSignal);
|
||||
|
||||
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
|
||||
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
|
||||
eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
||||
eventEmitter.on(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
|
||||
|
||||
return [
|
||||
() => {
|
||||
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
|
||||
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
|
||||
eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
||||
eventEmitter.off(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
|
||||
// Clean up any remaining states
|
||||
this.toolStates.clear();
|
||||
},
|
||||
@@ -252,6 +259,20 @@ export class SubAgentTracker {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a handler for usage metadata events.
|
||||
*/
|
||||
private createUsageMetadataHandler(
|
||||
abortSignal: AbortSignal,
|
||||
): (...args: unknown[]) => void {
|
||||
return (...args: unknown[]) => {
|
||||
const event = args[0] as SubAgentUsageEvent;
|
||||
if (abortSignal.aborted) return;
|
||||
|
||||
this.messageEmitter.emitUsageMetadata(event.usage, '', event.durationMs);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts confirmation details to permission options for the client.
|
||||
*/
|
||||
|
||||
@@ -148,4 +148,59 @@ describe('MessageEmitter', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitUsageMetadata', () => {
|
||||
it('should emit agent_message_chunk with _meta.usage containing token counts', async () => {
|
||||
const usageMetadata = {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
thoughtsTokenCount: 25,
|
||||
totalTokenCount: 175,
|
||||
cachedContentTokenCount: 10,
|
||||
};
|
||||
|
||||
await emitter.emitUsageMetadata(usageMetadata);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: '' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
thoughtsTokens: 25,
|
||||
totalTokens: 175,
|
||||
cachedTokens: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should include durationMs in _meta when provided', async () => {
|
||||
const usageMetadata = {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 5,
|
||||
thoughtsTokenCount: 2,
|
||||
totalTokenCount: 17,
|
||||
cachedContentTokenCount: 1,
|
||||
};
|
||||
|
||||
await emitter.emitUsageMetadata(usageMetadata, 'done', 1234);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'done' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
thoughtsTokens: 2,
|
||||
totalTokens: 17,
|
||||
cachedTokens: 1,
|
||||
},
|
||||
durationMs: 1234,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
|
||||
import type { Usage } from '../../schema.js';
|
||||
import { BaseEmitter } from './BaseEmitter.js';
|
||||
|
||||
/**
|
||||
@@ -24,6 +26,16 @@ export class MessageEmitter extends BaseEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an agent thought chunk.
|
||||
*/
|
||||
async emitAgentThought(text: string): Promise<void> {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an agent message chunk.
|
||||
*/
|
||||
@@ -35,12 +47,28 @@ export class MessageEmitter extends BaseEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an agent thought chunk.
|
||||
* Emits usage metadata.
|
||||
*/
|
||||
async emitAgentThought(text: string): Promise<void> {
|
||||
async emitUsageMetadata(
|
||||
usageMetadata: GenerateContentResponseUsageMetadata,
|
||||
text: string = '',
|
||||
durationMs?: number,
|
||||
): Promise<void> {
|
||||
const usage: Usage = {
|
||||
promptTokens: usageMetadata.promptTokenCount,
|
||||
completionTokens: usageMetadata.candidatesTokenCount,
|
||||
thoughtsTokens: usageMetadata.thoughtsTokenCount,
|
||||
totalTokens: usageMetadata.totalTokenCount,
|
||||
cachedTokens: usageMetadata.cachedContentTokenCount,
|
||||
};
|
||||
|
||||
const meta =
|
||||
typeof durationMs === 'number' ? { usage, durationMs } : { usage };
|
||||
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text },
|
||||
_meta: meta,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('ToolCallEmitter', () => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-123',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'unknown_tool', // Falls back to tool name
|
||||
content: [],
|
||||
locations: [],
|
||||
@@ -94,7 +94,7 @@ describe('ToolCallEmitter', () => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-456',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'edit_file: Test tool description',
|
||||
content: [],
|
||||
locations: [{ path: '/test/file.ts', line: 10 }],
|
||||
@@ -144,7 +144,7 @@ describe('ToolCallEmitter', () => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-fail',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'failing_tool', // Fallback to tool name
|
||||
content: [],
|
||||
locations: [], // Fallback to empty
|
||||
@@ -493,7 +493,7 @@ describe('ToolCallEmitter', () => {
|
||||
type: 'content',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: '{"output":"test output"}',
|
||||
text: 'test output',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -650,7 +650,7 @@ describe('ToolCallEmitter', () => {
|
||||
content: [
|
||||
{
|
||||
type: 'content',
|
||||
content: { type: 'text', text: '{"output":"Function output"}' },
|
||||
content: { type: 'text', text: 'Function output' },
|
||||
},
|
||||
],
|
||||
rawOutput: 'raw result',
|
||||
|
||||
@@ -59,7 +59,7 @@ export class ToolCallEmitter extends BaseEmitter {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: params.callId,
|
||||
status: 'in_progress',
|
||||
status: params.status || 'pending',
|
||||
title,
|
||||
content: [],
|
||||
locations,
|
||||
@@ -275,7 +275,18 @@ export class ToolCallEmitter extends BaseEmitter {
|
||||
// Handle functionResponse parts - stringify the response
|
||||
if ('functionResponse' in part && part.functionResponse) {
|
||||
try {
|
||||
const responseText = JSON.stringify(part.functionResponse.response);
|
||||
const resp = part.functionResponse.response as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const outputField = resp['output'];
|
||||
const errorField = resp['error'];
|
||||
const responseText =
|
||||
typeof outputField === 'string'
|
||||
? outputField
|
||||
: typeof errorField === 'string'
|
||||
? errorField
|
||||
: JSON.stringify(resp);
|
||||
result.push({
|
||||
type: 'content',
|
||||
content: { type: 'text', text: responseText },
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface ToolCallStartParams {
|
||||
callId: string;
|
||||
/** Arguments passed to the tool */
|
||||
args?: Record<string, unknown>;
|
||||
/** Status of the tool call */
|
||||
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -58,6 +58,7 @@ export type {
|
||||
SubAgentStartEvent,
|
||||
SubAgentRoundEvent,
|
||||
SubAgentStreamTextEvent,
|
||||
SubAgentUsageEvent,
|
||||
SubAgentToolCallEvent,
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentFinishEvent,
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
ToolConfirmationOutcome,
|
||||
ToolResultDisplay,
|
||||
} from '../tools/tools.js';
|
||||
import type { Part } from '@google/genai';
|
||||
import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai';
|
||||
|
||||
export type SubAgentEvent =
|
||||
| 'start'
|
||||
@@ -20,6 +20,7 @@ export type SubAgentEvent =
|
||||
| 'tool_call'
|
||||
| 'tool_result'
|
||||
| 'tool_waiting_approval'
|
||||
| 'usage_metadata'
|
||||
| 'finish'
|
||||
| 'error';
|
||||
|
||||
@@ -31,6 +32,7 @@ export enum SubAgentEventType {
|
||||
TOOL_CALL = 'tool_call',
|
||||
TOOL_RESULT = 'tool_result',
|
||||
TOOL_WAITING_APPROVAL = 'tool_waiting_approval',
|
||||
USAGE_METADATA = 'usage_metadata',
|
||||
FINISH = 'finish',
|
||||
ERROR = 'error',
|
||||
}
|
||||
@@ -57,6 +59,14 @@ export interface SubAgentStreamTextEvent {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface SubAgentUsageEvent {
|
||||
subagentId: string;
|
||||
round: number;
|
||||
usage: GenerateContentResponseUsageMetadata;
|
||||
durationMs?: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface SubAgentToolCallEvent {
|
||||
subagentId: string;
|
||||
round: number;
|
||||
|
||||
@@ -50,6 +50,15 @@ describe('SubagentStatistics', () => {
|
||||
expect(summary.outputTokens).toBe(600);
|
||||
expect(summary.totalTokens).toBe(1800);
|
||||
});
|
||||
|
||||
it('should track thought and cached tokens', () => {
|
||||
stats.recordTokens(100, 50, 10, 5);
|
||||
|
||||
const summary = stats.getSummary();
|
||||
expect(summary.thoughtTokens).toBe(10);
|
||||
expect(summary.cachedTokens).toBe(5);
|
||||
expect(summary.totalTokens).toBe(165); // 100 + 50 + 10 + 5
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool usage statistics', () => {
|
||||
@@ -93,14 +102,14 @@ describe('SubagentStatistics', () => {
|
||||
stats.start(baseTime);
|
||||
stats.setRounds(2);
|
||||
stats.recordToolCall('file_read', true, 100);
|
||||
stats.recordTokens(1000, 500);
|
||||
stats.recordTokens(1000, 500, 20, 10);
|
||||
|
||||
const result = stats.formatCompact('Test task', baseTime + 5000);
|
||||
|
||||
expect(result).toContain('📋 Task Completed: Test task');
|
||||
expect(result).toContain('🔧 Tool Usage: 1 calls, 100.0% success');
|
||||
expect(result).toContain('⏱️ Duration: 5.0s | 🔁 Rounds: 2');
|
||||
expect(result).toContain('🔢 Tokens: 1,500 (in 1000, out 500)');
|
||||
expect(result).toContain('🔢 Tokens: 1,530 (in 1000, out 500)');
|
||||
});
|
||||
|
||||
it('should handle zero tool calls', () => {
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface SubagentStatsSummary {
|
||||
successRate: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
thoughtTokens: number;
|
||||
cachedTokens: number;
|
||||
totalTokens: number;
|
||||
estimatedCost: number;
|
||||
toolUsage: ToolUsageStats[];
|
||||
@@ -36,6 +38,8 @@ export class SubagentStatistics {
|
||||
private failedToolCalls = 0;
|
||||
private inputTokens = 0;
|
||||
private outputTokens = 0;
|
||||
private thoughtTokens = 0;
|
||||
private cachedTokens = 0;
|
||||
private toolUsage = new Map<string, ToolUsageStats>();
|
||||
|
||||
start(now = Date.now()) {
|
||||
@@ -74,9 +78,16 @@ export class SubagentStatistics {
|
||||
this.toolUsage.set(name, tu);
|
||||
}
|
||||
|
||||
recordTokens(input: number, output: number) {
|
||||
recordTokens(
|
||||
input: number,
|
||||
output: number,
|
||||
thought: number = 0,
|
||||
cached: number = 0,
|
||||
) {
|
||||
this.inputTokens += Math.max(0, input || 0);
|
||||
this.outputTokens += Math.max(0, output || 0);
|
||||
this.thoughtTokens += Math.max(0, thought || 0);
|
||||
this.cachedTokens += Math.max(0, cached || 0);
|
||||
}
|
||||
|
||||
getSummary(now = Date.now()): SubagentStatsSummary {
|
||||
@@ -86,7 +97,11 @@ export class SubagentStatistics {
|
||||
totalToolCalls > 0
|
||||
? (this.successfulToolCalls / totalToolCalls) * 100
|
||||
: 0;
|
||||
const totalTokens = this.inputTokens + this.outputTokens;
|
||||
const totalTokens =
|
||||
this.inputTokens +
|
||||
this.outputTokens +
|
||||
this.thoughtTokens +
|
||||
this.cachedTokens;
|
||||
const estimatedCost = this.inputTokens * 3e-5 + this.outputTokens * 6e-5;
|
||||
return {
|
||||
rounds: this.rounds,
|
||||
@@ -97,6 +112,8 @@ export class SubagentStatistics {
|
||||
successRate,
|
||||
inputTokens: this.inputTokens,
|
||||
outputTokens: this.outputTokens,
|
||||
thoughtTokens: this.thoughtTokens,
|
||||
cachedTokens: this.cachedTokens,
|
||||
totalTokens,
|
||||
estimatedCost,
|
||||
toolUsage: Array.from(this.toolUsage.values()),
|
||||
@@ -116,8 +133,12 @@ export class SubagentStatistics {
|
||||
`⏱️ Duration: ${this.fmtDuration(stats.totalDurationMs)} | 🔁 Rounds: ${stats.rounds}`,
|
||||
];
|
||||
if (typeof stats.totalTokens === 'number') {
|
||||
const parts = [
|
||||
`in ${stats.inputTokens ?? 0}`,
|
||||
`out ${stats.outputTokens ?? 0}`,
|
||||
];
|
||||
lines.push(
|
||||
`🔢 Tokens: ${stats.totalTokens.toLocaleString()}${stats.inputTokens || stats.outputTokens ? ` (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})` : ''}`,
|
||||
`🔢 Tokens: ${stats.totalTokens.toLocaleString()}${parts.length ? ` (${parts.join(', ')})` : ''}`,
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
@@ -152,8 +173,12 @@ export class SubagentStatistics {
|
||||
`🔧 Tools: ${stats.totalToolCalls} calls, ${sr.toFixed(1)}% success (${stats.successfulToolCalls} ok, ${stats.failedToolCalls} failed)`,
|
||||
);
|
||||
if (typeof stats.totalTokens === 'number') {
|
||||
const parts = [
|
||||
`in ${stats.inputTokens ?? 0}`,
|
||||
`out ${stats.outputTokens ?? 0}`,
|
||||
];
|
||||
lines.push(
|
||||
`🔢 Tokens: ${stats.totalTokens.toLocaleString()} (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})`,
|
||||
`🔢 Tokens: ${stats.totalTokens.toLocaleString()} (${parts.join(', ')})`,
|
||||
);
|
||||
}
|
||||
if (stats.toolUsage && stats.toolUsage.length) {
|
||||
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentStreamTextEvent,
|
||||
SubAgentErrorEvent,
|
||||
SubAgentUsageEvent,
|
||||
} from './subagent-events.js';
|
||||
import {
|
||||
type SubAgentEventEmitter,
|
||||
@@ -369,6 +370,7 @@ export class SubAgentScope {
|
||||
},
|
||||
};
|
||||
|
||||
const roundStreamStart = Date.now();
|
||||
const responseStream = await chat.sendMessageStream(
|
||||
this.modelConfig.model ||
|
||||
this.runtimeContext.getModel() ||
|
||||
@@ -439,10 +441,19 @@ export class SubAgentScope {
|
||||
if (lastUsage) {
|
||||
const inTok = Number(lastUsage.promptTokenCount || 0);
|
||||
const outTok = Number(lastUsage.candidatesTokenCount || 0);
|
||||
if (isFinite(inTok) || isFinite(outTok)) {
|
||||
const thoughtTok = Number(lastUsage.thoughtsTokenCount || 0);
|
||||
const cachedTok = Number(lastUsage.cachedContentTokenCount || 0);
|
||||
if (
|
||||
isFinite(inTok) ||
|
||||
isFinite(outTok) ||
|
||||
isFinite(thoughtTok) ||
|
||||
isFinite(cachedTok)
|
||||
) {
|
||||
this.stats.recordTokens(
|
||||
isFinite(inTok) ? inTok : 0,
|
||||
isFinite(outTok) ? outTok : 0,
|
||||
isFinite(thoughtTok) ? thoughtTok : 0,
|
||||
isFinite(cachedTok) ? cachedTok : 0,
|
||||
);
|
||||
// mirror legacy fields for compatibility
|
||||
this.executionStats.inputTokens =
|
||||
@@ -453,11 +464,20 @@ export class SubAgentScope {
|
||||
(isFinite(outTok) ? outTok : 0);
|
||||
this.executionStats.totalTokens =
|
||||
(this.executionStats.inputTokens || 0) +
|
||||
(this.executionStats.outputTokens || 0);
|
||||
(this.executionStats.outputTokens || 0) +
|
||||
(isFinite(thoughtTok) ? thoughtTok : 0) +
|
||||
(isFinite(cachedTok) ? cachedTok : 0);
|
||||
this.executionStats.estimatedCost =
|
||||
(this.executionStats.inputTokens || 0) * 3e-5 +
|
||||
(this.executionStats.outputTokens || 0) * 6e-5;
|
||||
}
|
||||
this.eventEmitter?.emit(SubAgentEventType.USAGE_METADATA, {
|
||||
subagentId: this.subagentId,
|
||||
round: turnCounter,
|
||||
usage: lastUsage,
|
||||
durationMs: Date.now() - roundStreamStart,
|
||||
timestamp: Date.now(),
|
||||
} as SubAgentUsageEvent);
|
||||
}
|
||||
|
||||
if (functionCalls.length > 0) {
|
||||
|
||||
@@ -23,6 +23,12 @@ export type UiEvent =
|
||||
| (ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR })
|
||||
| (ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL });
|
||||
|
||||
export {
|
||||
EVENT_API_ERROR,
|
||||
EVENT_API_RESPONSE,
|
||||
EVENT_TOOL_CALL,
|
||||
} from './constants.js';
|
||||
|
||||
export interface ToolCallStats {
|
||||
count: number;
|
||||
success: number;
|
||||
|
||||
Reference in New Issue
Block a user