#1129, add usage update in ACP mode

This commit is contained in:
tanzhenxin
2025-12-09 09:58:19 +08:00
parent efbf50554d
commit d7b9466516
15 changed files with 279 additions and 28 deletions

View File

@@ -328,6 +328,7 @@ export type Usage = z.infer<typeof usageSchema>;
export const sessionUpdateMetaSchema = z.object({ export const sessionUpdateMetaSchema = z.object({
usage: usageSchema.optional().nullable(), usage: usageSchema.optional().nullable(),
durationMs: z.number().optional().nullable(),
}); });
export type SessionUpdateMeta = z.infer<typeof sessionUpdateMetaSchema>; export type SessionUpdateMeta = z.infer<typeof sessionUpdateMetaSchema>;

View 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',
});
});
});
});

View File

@@ -31,10 +31,16 @@ export class AcpFileSystemService implements FileSystemService {
}); });
if (response.content.startsWith('ERROR: ENOENT:')) { 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; const err = new Error(response.content) as NodeJS.ErrnoException;
err.code = 'ENOENT'; err.code = 'ENOENT';
err.errno = -2; err.errno = -2;
err.path = filePath; const rawPath = match?.groups?.['path']?.trim();
err['path'] = rawPath
? rawPath.replace(/^['"]|['"]$/g, '') || filePath
: filePath;
throw err; throw err;
} }

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import type { ChatRecord } from '@qwen-code/qwen-code-core'; import type { ChatRecord, TaskResultDisplay } from '@qwen-code/qwen-code-core';
import type { import type {
Content, Content,
GenerateContentResponseUsageMetadata, GenerateContentResponseUsageMetadata,
@@ -135,6 +135,54 @@ export class HistoryReplayer {
// Note: args aren't stored in tool_result records by default // Note: args aren't stored in tool_result records by default
args: undefined, 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);
}
} }
/** /**

View File

@@ -4,7 +4,12 @@
* SPDX-License-Identifier: Apache-2.0 * 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 { import type {
Config, Config,
GeminiChat, GeminiChat,
@@ -195,6 +200,8 @@ export class Session implements SessionContext {
} }
const functionCalls: FunctionCall[] = []; const functionCalls: FunctionCall[] = [];
let usageMetadata: GenerateContentResponseUsageMetadata | null = null;
const streamStartTime = Date.now();
try { try {
const responseStream = await chat.sendMessageStream( const responseStream = await chat.sendMessageStream(
@@ -225,22 +232,16 @@ export class Session implements SessionContext {
continue; continue;
} }
const content: acp.ContentBlock = { this.messageEmitter.emitMessage(
type: 'text', part.text,
text: part.text, 'assistant',
}; part.thought,
);
this.sendUpdate({
sessionUpdate: part.thought
? 'agent_thought_chunk'
: 'agent_message_chunk',
content,
});
} }
} }
if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) { if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) {
this.messageEmitter.emitUsageMetadata(resp.value.usageMetadata); usageMetadata = resp.value.usageMetadata;
} }
if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) { if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
@@ -258,6 +259,15 @@ export class Session implements SessionContext {
throw error; throw error;
} }
if (usageMetadata) {
const durationMs = Date.now() - streamStartTime;
await this.messageEmitter.emitUsageMetadata(
usageMetadata,
'',
durationMs,
);
}
if (functionCalls.length > 0) { if (functionCalls.length > 0) {
const toolResponseParts: Part[] = []; const toolResponseParts: Part[] = [];

View File

@@ -9,6 +9,7 @@ import type {
SubAgentToolCallEvent, SubAgentToolCallEvent,
SubAgentToolResultEvent, SubAgentToolResultEvent,
SubAgentApprovalRequestEvent, SubAgentApprovalRequestEvent,
SubAgentUsageEvent,
ToolCallConfirmationDetails, ToolCallConfirmationDetails,
AnyDeclarativeTool, AnyDeclarativeTool,
AnyToolInvocation, AnyToolInvocation,
@@ -20,6 +21,7 @@ import {
import { z } from 'zod'; import { z } from 'zod';
import type { SessionContext } from './types.js'; import type { SessionContext } from './types.js';
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js'; import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
import { MessageEmitter } from './emitters/MessageEmitter.js';
import type * as acp from '../acp.js'; import type * as acp from '../acp.js';
/** /**
@@ -62,6 +64,7 @@ const basicPermissionOptions: readonly PermissionOptionConfig[] = [
*/ */
export class SubAgentTracker { export class SubAgentTracker {
private readonly toolCallEmitter: ToolCallEmitter; private readonly toolCallEmitter: ToolCallEmitter;
private readonly messageEmitter: MessageEmitter;
private readonly toolStates = new Map< private readonly toolStates = new Map<
string, string,
{ {
@@ -76,6 +79,7 @@ export class SubAgentTracker {
private readonly client: acp.Client, private readonly client: acp.Client,
) { ) {
this.toolCallEmitter = new ToolCallEmitter(ctx); this.toolCallEmitter = new ToolCallEmitter(ctx);
this.messageEmitter = new MessageEmitter(ctx);
} }
/** /**
@@ -92,16 +96,19 @@ export class SubAgentTracker {
const onToolCall = this.createToolCallHandler(abortSignal); const onToolCall = this.createToolCallHandler(abortSignal);
const onToolResult = this.createToolResultHandler(abortSignal); const onToolResult = this.createToolResultHandler(abortSignal);
const onApproval = this.createApprovalHandler(abortSignal); const onApproval = this.createApprovalHandler(abortSignal);
const onUsageMetadata = this.createUsageMetadataHandler(abortSignal);
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall); eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult); eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval); eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.on(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
return [ return [
() => { () => {
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall); eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult); eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval); eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.off(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
// Clean up any remaining states // Clean up any remaining states
this.toolStates.clear(); 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. * Converts confirmation details to permission options for the client.
*/ */

View File

@@ -175,5 +175,32 @@ describe('MessageEmitter', () => {
}, },
}); });
}); });
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,
},
});
});
}); });
}); });

View File

@@ -52,6 +52,7 @@ export class MessageEmitter extends BaseEmitter {
async emitUsageMetadata( async emitUsageMetadata(
usageMetadata: GenerateContentResponseUsageMetadata, usageMetadata: GenerateContentResponseUsageMetadata,
text: string = '', text: string = '',
durationMs?: number,
): Promise<void> { ): Promise<void> {
const usage: Usage = { const usage: Usage = {
promptTokens: usageMetadata.promptTokenCount, promptTokens: usageMetadata.promptTokenCount,
@@ -61,10 +62,13 @@ export class MessageEmitter extends BaseEmitter {
cachedTokens: usageMetadata.cachedContentTokenCount, cachedTokens: usageMetadata.cachedContentTokenCount,
}; };
const meta =
typeof durationMs === 'number' ? { usage, durationMs } : { usage };
await this.sendUpdate({ await this.sendUpdate({
sessionUpdate: 'agent_message_chunk', sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text }, content: { type: 'text', text },
_meta: { usage }, _meta: meta,
}); });
} }

View File

@@ -279,10 +279,14 @@ export class ToolCallEmitter extends BaseEmitter {
string, string,
unknown unknown
>; >;
const outputField = resp['output'];
const errorField = resp['error'];
const responseText = const responseText =
(resp['output'] as string) ?? typeof outputField === 'string'
(resp['error'] as string) ?? ? outputField
JSON.stringify(resp); : typeof errorField === 'string'
? errorField
: JSON.stringify(resp);
result.push({ result.push({
type: 'content', type: 'content',
content: { type: 'text', text: responseText }, content: { type: 'text', text: responseText },

View File

@@ -58,6 +58,7 @@ export type {
SubAgentStartEvent, SubAgentStartEvent,
SubAgentRoundEvent, SubAgentRoundEvent,
SubAgentStreamTextEvent, SubAgentStreamTextEvent,
SubAgentUsageEvent,
SubAgentToolCallEvent, SubAgentToolCallEvent,
SubAgentToolResultEvent, SubAgentToolResultEvent,
SubAgentFinishEvent, SubAgentFinishEvent,

View File

@@ -10,7 +10,7 @@ import type {
ToolConfirmationOutcome, ToolConfirmationOutcome,
ToolResultDisplay, ToolResultDisplay,
} from '../tools/tools.js'; } from '../tools/tools.js';
import type { Part } from '@google/genai'; import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai';
export type SubAgentEvent = export type SubAgentEvent =
| 'start' | 'start'
@@ -20,6 +20,7 @@ export type SubAgentEvent =
| 'tool_call' | 'tool_call'
| 'tool_result' | 'tool_result'
| 'tool_waiting_approval' | 'tool_waiting_approval'
| 'usage_metadata'
| 'finish' | 'finish'
| 'error'; | 'error';
@@ -31,6 +32,7 @@ export enum SubAgentEventType {
TOOL_CALL = 'tool_call', TOOL_CALL = 'tool_call',
TOOL_RESULT = 'tool_result', TOOL_RESULT = 'tool_result',
TOOL_WAITING_APPROVAL = 'tool_waiting_approval', TOOL_WAITING_APPROVAL = 'tool_waiting_approval',
USAGE_METADATA = 'usage_metadata',
FINISH = 'finish', FINISH = 'finish',
ERROR = 'error', ERROR = 'error',
} }
@@ -57,6 +59,14 @@ export interface SubAgentStreamTextEvent {
timestamp: number; timestamp: number;
} }
export interface SubAgentUsageEvent {
subagentId: string;
round: number;
usage: GenerateContentResponseUsageMetadata;
durationMs?: number;
timestamp: number;
}
export interface SubAgentToolCallEvent { export interface SubAgentToolCallEvent {
subagentId: string; subagentId: string;
round: number; round: number;

View File

@@ -50,6 +50,15 @@ describe('SubagentStatistics', () => {
expect(summary.outputTokens).toBe(600); expect(summary.outputTokens).toBe(600);
expect(summary.totalTokens).toBe(1800); 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', () => { describe('tool usage statistics', () => {
@@ -93,14 +102,14 @@ describe('SubagentStatistics', () => {
stats.start(baseTime); stats.start(baseTime);
stats.setRounds(2); stats.setRounds(2);
stats.recordToolCall('file_read', true, 100); stats.recordToolCall('file_read', true, 100);
stats.recordTokens(1000, 500); stats.recordTokens(1000, 500, 20, 10);
const result = stats.formatCompact('Test task', baseTime + 5000); const result = stats.formatCompact('Test task', baseTime + 5000);
expect(result).toContain('📋 Task Completed: Test task'); expect(result).toContain('📋 Task Completed: Test task');
expect(result).toContain('🔧 Tool Usage: 1 calls, 100.0% success'); expect(result).toContain('🔧 Tool Usage: 1 calls, 100.0% success');
expect(result).toContain('⏱️ Duration: 5.0s | 🔁 Rounds: 2'); 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', () => { it('should handle zero tool calls', () => {

View File

@@ -23,6 +23,8 @@ export interface SubagentStatsSummary {
successRate: number; successRate: number;
inputTokens: number; inputTokens: number;
outputTokens: number; outputTokens: number;
thoughtTokens: number;
cachedTokens: number;
totalTokens: number; totalTokens: number;
estimatedCost: number; estimatedCost: number;
toolUsage: ToolUsageStats[]; toolUsage: ToolUsageStats[];
@@ -36,6 +38,8 @@ export class SubagentStatistics {
private failedToolCalls = 0; private failedToolCalls = 0;
private inputTokens = 0; private inputTokens = 0;
private outputTokens = 0; private outputTokens = 0;
private thoughtTokens = 0;
private cachedTokens = 0;
private toolUsage = new Map<string, ToolUsageStats>(); private toolUsage = new Map<string, ToolUsageStats>();
start(now = Date.now()) { start(now = Date.now()) {
@@ -74,9 +78,16 @@ export class SubagentStatistics {
this.toolUsage.set(name, tu); 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.inputTokens += Math.max(0, input || 0);
this.outputTokens += Math.max(0, output || 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 { getSummary(now = Date.now()): SubagentStatsSummary {
@@ -86,7 +97,11 @@ export class SubagentStatistics {
totalToolCalls > 0 totalToolCalls > 0
? (this.successfulToolCalls / totalToolCalls) * 100 ? (this.successfulToolCalls / totalToolCalls) * 100
: 0; : 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; const estimatedCost = this.inputTokens * 3e-5 + this.outputTokens * 6e-5;
return { return {
rounds: this.rounds, rounds: this.rounds,
@@ -97,6 +112,8 @@ export class SubagentStatistics {
successRate, successRate,
inputTokens: this.inputTokens, inputTokens: this.inputTokens,
outputTokens: this.outputTokens, outputTokens: this.outputTokens,
thoughtTokens: this.thoughtTokens,
cachedTokens: this.cachedTokens,
totalTokens, totalTokens,
estimatedCost, estimatedCost,
toolUsage: Array.from(this.toolUsage.values()), toolUsage: Array.from(this.toolUsage.values()),
@@ -116,8 +133,12 @@ export class SubagentStatistics {
`⏱️ Duration: ${this.fmtDuration(stats.totalDurationMs)} | 🔁 Rounds: ${stats.rounds}`, `⏱️ Duration: ${this.fmtDuration(stats.totalDurationMs)} | 🔁 Rounds: ${stats.rounds}`,
]; ];
if (typeof stats.totalTokens === 'number') { if (typeof stats.totalTokens === 'number') {
const parts = [
`in ${stats.inputTokens ?? 0}`,
`out ${stats.outputTokens ?? 0}`,
];
lines.push( 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'); 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)`, `🔧 Tools: ${stats.totalToolCalls} calls, ${sr.toFixed(1)}% success (${stats.successfulToolCalls} ok, ${stats.failedToolCalls} failed)`,
); );
if (typeof stats.totalTokens === 'number') { if (typeof stats.totalTokens === 'number') {
const parts = [
`in ${stats.inputTokens ?? 0}`,
`out ${stats.outputTokens ?? 0}`,
];
lines.push( 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) { if (stats.toolUsage && stats.toolUsage.length) {

View File

@@ -41,6 +41,7 @@ import type {
SubAgentToolResultEvent, SubAgentToolResultEvent,
SubAgentStreamTextEvent, SubAgentStreamTextEvent,
SubAgentErrorEvent, SubAgentErrorEvent,
SubAgentUsageEvent,
} from './subagent-events.js'; } from './subagent-events.js';
import { import {
type SubAgentEventEmitter, type SubAgentEventEmitter,
@@ -369,6 +370,7 @@ export class SubAgentScope {
}, },
}; };
const roundStreamStart = Date.now();
const responseStream = await chat.sendMessageStream( const responseStream = await chat.sendMessageStream(
this.modelConfig.model || this.modelConfig.model ||
this.runtimeContext.getModel() || this.runtimeContext.getModel() ||
@@ -439,10 +441,19 @@ export class SubAgentScope {
if (lastUsage) { if (lastUsage) {
const inTok = Number(lastUsage.promptTokenCount || 0); const inTok = Number(lastUsage.promptTokenCount || 0);
const outTok = Number(lastUsage.candidatesTokenCount || 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( this.stats.recordTokens(
isFinite(inTok) ? inTok : 0, isFinite(inTok) ? inTok : 0,
isFinite(outTok) ? outTok : 0, isFinite(outTok) ? outTok : 0,
isFinite(thoughtTok) ? thoughtTok : 0,
isFinite(cachedTok) ? cachedTok : 0,
); );
// mirror legacy fields for compatibility // mirror legacy fields for compatibility
this.executionStats.inputTokens = this.executionStats.inputTokens =
@@ -453,11 +464,20 @@ export class SubAgentScope {
(isFinite(outTok) ? outTok : 0); (isFinite(outTok) ? outTok : 0);
this.executionStats.totalTokens = this.executionStats.totalTokens =
(this.executionStats.inputTokens || 0) + (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.estimatedCost =
(this.executionStats.inputTokens || 0) * 3e-5 + (this.executionStats.inputTokens || 0) * 3e-5 +
(this.executionStats.outputTokens || 0) * 6e-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) { if (functionCalls.length > 0) {

View File

@@ -23,6 +23,12 @@ export type UiEvent =
| (ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR }) | (ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR })
| (ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL }); | (ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL });
export {
EVENT_API_ERROR,
EVENT_API_RESPONSE,
EVENT_TOOL_CALL,
} from './constants.js';
export interface ToolCallStats { export interface ToolCallStats {
count: number; count: number;
success: number; success: number;