Skip to content

Commit 0b40656

Browse files
committed
feat(agent): accumulate cumulative totalUsage and emit on turn_end/agent_end
Add totalUsage to AgentState and accumulate per-message Usage (tokens and cost.*) as each turn completes. Snapshot the rolling total onto turn_end and agent_end events so consumers can read a cumulative figure without re-walking messages[]. Reset on prompt() (matching stepCount semantics), preserve across continue().
1 parent d6087d2 commit 0b40656

4 files changed

Lines changed: 213 additions & 26 deletions

File tree

packages/agent/__tests__/agent.test.ts

Lines changed: 181 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
import {
2+
createScriptedProvider,
3+
makeFakeAssistantMessage,
4+
makeFakeModel,
5+
ZERO_USAGE,
6+
} from '@test/index';
17
import {
28
type AssistantMessage,
39
type Context,
@@ -7,11 +13,6 @@ import {
713
type ModelDescriptor,
814
type ToolCallContent,
915
} from 'agentic-kit';
10-
import {
11-
createScriptedProvider,
12-
makeFakeAssistantMessage,
13-
makeFakeModel,
14-
} from '@test/index';
1516

1617
import {
1718
Agent,
@@ -183,14 +184,7 @@ describe('@agentic-kit/agent', () => {
183184
});
184185

185186
function makeUsage() {
186-
return {
187-
input: 1,
188-
output: 1,
189-
cacheRead: 0,
190-
cacheWrite: 0,
191-
totalTokens: 2,
192-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
193-
};
187+
return { ...ZERO_USAGE, cost: { ...ZERO_USAGE.cost }, input: 1, output: 1, totalTokens: 2 };
194188
}
195189

196190
describe('@agentic-kit/agent — pausable tools', () => {
@@ -736,3 +730,177 @@ describe('@agentic-kit/agent — maxSteps', () => {
736730
expect(agent.state.stepCount).toBe(1);
737731
});
738732
});
733+
734+
describe('@agentic-kit/agent — totalUsage accumulation', () => {
735+
function makeUsageTurn(input: number, output: number) {
736+
const totalTokens = input + output;
737+
return {
738+
input,
739+
output,
740+
reasoning: 0,
741+
cacheRead: 0,
742+
cacheWrite: 0,
743+
totalTokens,
744+
cost: { input: input * 0.01, output: output * 0.02, cacheRead: 0, cacheWrite: 0, total: input * 0.01 + output * 0.02 },
745+
};
746+
}
747+
748+
it('accumulates totalUsage across two turns and attaches snapshot to turn_end and agent_end events', async () => {
749+
const turn1Usage = makeUsageTurn(10, 20);
750+
const turn2Usage = makeUsageTurn(30, 40);
751+
752+
const responses = [
753+
makeFakeAssistantMessage({
754+
stopReason: 'toolUse',
755+
usage: turn1Usage,
756+
content: [{ type: 'toolCall', id: 'tool_1', name: 'echo', arguments: { text: 'hi' } }],
757+
}),
758+
makeFakeAssistantMessage({
759+
stopReason: 'stop',
760+
usage: turn2Usage,
761+
content: [{ type: 'text', text: 'done' }],
762+
}),
763+
];
764+
765+
const provider = createScriptedProvider({ responses });
766+
const agent = new Agent({
767+
initialState: { model: makeFakeModel() },
768+
streamFn: provider.stream,
769+
});
770+
agent.setTools([
771+
{
772+
name: 'echo',
773+
label: 'Echo',
774+
description: 'Echo text',
775+
parameters: {
776+
type: 'object',
777+
properties: { text: { type: 'string' } },
778+
required: ['text'],
779+
},
780+
execute: async (_id, params) => ({
781+
content: [{ type: 'text', text: String(params.text) }],
782+
}),
783+
},
784+
]);
785+
786+
const events: AgentEvent[] = [];
787+
agent.subscribe((e) => events.push(e));
788+
789+
await agent.prompt('go');
790+
791+
expect(agent.state.totalUsage.input).toBe(40);
792+
expect(agent.state.totalUsage.output).toBe(60);
793+
expect(agent.state.totalUsage.totalTokens).toBe(turn1Usage.totalTokens + turn2Usage.totalTokens);
794+
expect(agent.state.totalUsage.cost.total).toBeCloseTo(
795+
turn1Usage.cost.total + turn2Usage.cost.total,
796+
10
797+
);
798+
799+
const agentEndEvent = events.find(
800+
(e): e is Extract<AgentEvent, { type: 'agent_end' }> => e.type === 'agent_end'
801+
);
802+
expect(agentEndEvent).toBeDefined();
803+
expect(agentEndEvent!.totalUsage.input).toBe(agent.state.totalUsage.input);
804+
expect(agentEndEvent!.totalUsage.output).toBe(agent.state.totalUsage.output);
805+
expect(agentEndEvent!.totalUsage.totalTokens).toBe(agent.state.totalUsage.totalTokens);
806+
expect(agentEndEvent!.totalUsage.cost.total).toBeCloseTo(agent.state.totalUsage.cost.total, 10);
807+
808+
// Decision #17: events carry a snapshot, not a live reference. Mutating
809+
// the agent's state after emit must not leak into the captured event.
810+
const capturedInput = agentEndEvent!.totalUsage.input;
811+
const capturedCostTotal = agentEndEvent!.totalUsage.cost.total;
812+
agent.state.totalUsage.input = 9999;
813+
agent.state.totalUsage.cost.total = 9999;
814+
expect(agentEndEvent!.totalUsage.input).toBe(capturedInput);
815+
expect(agentEndEvent!.totalUsage.cost.total).toBe(capturedCostTotal);
816+
817+
const turnEndEvents = events.filter(
818+
(e): e is Extract<AgentEvent, { type: 'turn_end' }> => e.type === 'turn_end'
819+
);
820+
const lastTurnEnd = turnEndEvents[turnEndEvents.length - 1];
821+
expect(lastTurnEnd).toBeDefined();
822+
expect(lastTurnEnd!.totalUsage.input).toBe(40);
823+
});
824+
825+
it('prompt() resets totalUsage; a second prompt only reflects its own turns', async () => {
826+
const turn1Usage = makeUsageTurn(10, 20);
827+
const turn2Usage = makeUsageTurn(30, 40);
828+
829+
const provider = createScriptedProvider({
830+
responses: [
831+
makeFakeAssistantMessage({ stopReason: 'stop', usage: turn1Usage, content: [{ type: 'text', text: 'p1' }] }),
832+
makeFakeAssistantMessage({ stopReason: 'stop', usage: turn2Usage, content: [{ type: 'text', text: 'p2' }] }),
833+
],
834+
});
835+
const agent = new Agent({
836+
initialState: { model: makeFakeModel() },
837+
streamFn: provider.stream,
838+
});
839+
840+
await agent.prompt('first');
841+
expect(agent.state.totalUsage.input).toBe(turn1Usage.input);
842+
expect(agent.state.totalUsage.output).toBe(turn1Usage.output);
843+
844+
await agent.prompt('second');
845+
expect(agent.state.totalUsage.input).toBe(turn2Usage.input);
846+
expect(agent.state.totalUsage.output).toBe(turn2Usage.output);
847+
});
848+
849+
it('continue() does NOT reset totalUsage — it keeps growing', async () => {
850+
const turn1Usage = makeUsageTurn(10, 20);
851+
const turn2Usage = makeUsageTurn(30, 40);
852+
853+
const pauseResponse = makeFakeAssistantMessage({
854+
stopReason: 'toolUse',
855+
usage: turn1Usage,
856+
content: [{ type: 'toolCall', id: 'tool_1', name: 'approve', arguments: { target: 'thing' } }],
857+
});
858+
const finalResponse = makeFakeAssistantMessage({
859+
stopReason: 'stop',
860+
usage: turn2Usage,
861+
content: [{ type: 'text', text: 'done' }],
862+
});
863+
864+
const provider = createScriptedProvider({ responses: [pauseResponse, finalResponse] });
865+
866+
const approveTool: AgentTool = {
867+
name: 'approve',
868+
label: 'Approve',
869+
description: 'Requires decision',
870+
parameters: {
871+
type: 'object',
872+
properties: { target: { type: 'string' } },
873+
required: ['target'],
874+
},
875+
decision: {
876+
type: 'object',
877+
properties: { approved: { type: 'boolean' } },
878+
required: ['approved'],
879+
},
880+
execute: async () => ({ content: [{ type: 'text', text: 'ok' }] }),
881+
};
882+
883+
const agent = new Agent({
884+
initialState: { model: makeFakeModel() },
885+
streamFn: provider.stream,
886+
});
887+
agent.setTools([approveTool]);
888+
889+
await agent.prompt('go');
890+
expect(agent.state.totalUsage.input).toBe(turn1Usage.input);
891+
892+
const messages = agent.state.messages;
893+
const last = messages[messages.length - 1] as ReturnType<typeof makeFakeAssistantMessage>;
894+
const updatedContent = last.content.map((block) =>
895+
block.type === 'toolCall' && block.id === 'tool_1'
896+
? ({ ...block, decision: { approved: true } } as ToolCallContent)
897+
: block
898+
);
899+
agent.replaceMessages([...messages.slice(0, -1), { ...last, content: updatedContent }]);
900+
901+
await agent.continue();
902+
903+
expect(agent.state.totalUsage.input).toBe(turn1Usage.input + turn2Usage.input);
904+
expect(agent.state.totalUsage.output).toBe(turn1Usage.output + turn2Usage.output);
905+
});
906+
});

packages/agent/__tests__/run-handle.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1+
import {
2+
createScriptedProvider,
3+
makeFakeAssistantMessage,
4+
makeFakeModel,
5+
} from '@test/index';
16
import {
27
type AssistantMessageEvent,
38
type Context,
49
createAssistantMessageEventStream,
510
type ModelDescriptor,
611
type StreamOptions,
712
} from 'agentic-kit';
8-
import {
9-
createScriptedProvider,
10-
makeFakeAssistantMessage,
11-
makeFakeModel,
12-
} from '@test/index';
1313

1414
import { Agent, type AgentEvent, type AgentTool, parseSSEStream } from '../src';
1515

@@ -239,7 +239,7 @@ describe('AgentRunHandle', () => {
239239
function makeAbortableStreamFn(): {
240240
streamFn: (model: ModelDescriptor, context: Context, options?: StreamOptions) => ReturnType<typeof createAssistantMessageEventStream>;
241241
getSignal: () => AbortSignal | undefined;
242-
} {
242+
} {
243243
let capturedSignal: AbortSignal | undefined;
244244
const streamFn = (
245245
_model: ModelDescriptor,

packages/agent/src/agent.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import {
2+
addUsage,
23
type AssistantMessage,
34
type Context,
5+
createEmptyUsage,
46
createToolResultMessage,
57
createUserMessage,
68
type Message,
9+
snapshotUsage,
710
stream,
811
type StreamOptions,
912
type ToolCallContent,
@@ -47,6 +50,7 @@ export class Agent {
4750
messages: [],
4851
isStreaming: false,
4952
stepCount: 0,
53+
totalUsage: createEmptyUsage(),
5054
streamMessage: null,
5155
streamOptions: undefined,
5256
...options.initialState,
@@ -116,6 +120,7 @@ export class Agent {
116120

117121
const message = typeof input === 'string' ? createUserMessage(input) : input;
118122
this._state.stepCount = 0;
123+
this._state.totalUsage = createEmptyUsage();
119124

120125
const handle: AgentRunHandle = new DefaultAgentRunHandle(async (push, signal) => {
121126
if (this.outstandingHandle === handle) {
@@ -293,7 +298,7 @@ export class Agent {
293298

294299
if (assistantMessage.stopReason === 'error' || assistantMessage.stopReason === 'aborted') {
295300
this._state.error = assistantMessage.errorMessage;
296-
await this.emit({ type: 'turn_end', message: assistantMessage, toolResults: [] });
301+
await this.emit({ type: 'turn_end', message: assistantMessage, toolResults: [], totalUsage: snapshotUsage(this._state.totalUsage) });
297302
break;
298303
}
299304
}
@@ -302,7 +307,7 @@ export class Agent {
302307
(block): block is ToolCallContent => block.type === 'toolCall'
303308
);
304309
if (toolCalls.length === 0) {
305-
await this.emit({ type: 'turn_end', message: assistantMessage, toolResults: [] });
310+
await this.emit({ type: 'turn_end', message: assistantMessage, toolResults: [], totalUsage: snapshotUsage(this._state.totalUsage) });
306311
break;
307312
}
308313

@@ -312,15 +317,15 @@ export class Agent {
312317
return;
313318
}
314319

315-
await this.emit({ type: 'turn_end', message: assistantMessage, toolResults: outcome.results });
320+
await this.emit({ type: 'turn_end', message: assistantMessage, toolResults: outcome.results, totalUsage: snapshotUsage(this._state.totalUsage) });
316321

317322
if (localAbortController.signal.aborted) {
318323
stopReason = 'aborted';
319324
break;
320325
}
321326
}
322327

323-
await this.emit({ type: 'agent_end', messages: [...this._state.messages], stopReason });
328+
await this.emit({ type: 'agent_end', messages: [...this._state.messages], stopReason, totalUsage: snapshotUsage(this._state.totalUsage) });
324329
} finally {
325330
if (opts.externalAbortSignal) {
326331
opts.externalAbortSignal.removeEventListener('abort', onExternalAbort);
@@ -381,7 +386,9 @@ export class Agent {
381386
}
382387
}
383388

384-
return streamResult.result();
389+
const message = await streamResult.result();
390+
addUsage(this._state.totalUsage, message.usage);
391+
return message;
385392
}
386393

387394
private async executeToolCalls(

packages/agent/src/types.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
StreamOptions,
99
ToolDefinition,
1010
ToolResultMessage,
11+
Usage,
1112
} from 'agentic-kit';
1213

1314
export interface AgentToolResult<TDetails = unknown> {
@@ -41,6 +42,7 @@ export interface AgentState {
4142
streamOptions?: Omit<StreamOptions, 'signal'>;
4243
systemPrompt: string;
4344
tools: AgentTool[];
45+
totalUsage: Usage;
4446
}
4547

4648
export interface AgentEventBase {
@@ -49,9 +51,19 @@ export interface AgentEventBase {
4951

5052
export type AgentEvent =
5153
| { type: 'agent_start' }
52-
| { type: 'agent_end'; messages: Message[]; stopReason?: 'completed' | 'max_steps' | 'aborted' }
54+
| {
55+
type: 'agent_end';
56+
messages: Message[];
57+
totalUsage: Usage;
58+
stopReason?: 'completed' | 'max_steps' | 'aborted';
59+
}
5360
| { type: 'turn_start' }
54-
| { type: 'turn_end'; message: AssistantMessage; toolResults: ToolResultMessage[] }
61+
| {
62+
type: 'turn_end';
63+
message: AssistantMessage;
64+
toolResults: ToolResultMessage[];
65+
totalUsage: Usage;
66+
}
5567
| { type: 'message_start'; message: Message }
5668
| { type: 'message_update'; message: AssistantMessage; assistantMessageEvent: AssistantMessageEvent }
5769
| { type: 'message_end'; message: Message }

0 commit comments

Comments
 (0)