Merge pull request #1176 from QwenLM/feat/acp-usage-metadata

Feat/acp usage metadata
This commit is contained in:
tanzhenxin
2025-12-11 14:09:43 +08:00
committed by GitHub
20 changed files with 503 additions and 35 deletions

View File

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

View File

@@ -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;

View File

@@ -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', () => {

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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;