Skip to content

Commit ded661b

Browse files
authored
feat(ai): add onStepFinish to agent.generate and agent.stream (#11980)
## Background For things such as token tracking, it would be helpful if callbacks can be registered on a per-call basis. See #11468 ## Summary Add `onStepFinish` callback support to `Agent.generate()` and `Agent.stream()` ## Related Issues Resolves #11468
1 parent f5e270d commit ded661b

15 files changed

Lines changed: 448 additions & 15 deletions

.changeset/stale-schools-behave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'ai': patch
3+
---
4+
5+
feat(ai): add onStepFinish to agent.generate and agent.stream

content/docs/03-agents/02-building-agents.mdx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,45 @@ export async function POST(request: Request) {
329329
}
330330
```
331331

332+
### Track Step Progress
333+
334+
Use `onStepFinish` to track each step's progress, including token usage:
335+
336+
```ts
337+
const result = await myAgent.generate({
338+
prompt: 'Research and summarize the latest AI trends',
339+
onStepFinish: async ({ usage, finishReason, toolCalls }) => {
340+
console.log('Step completed:', {
341+
inputTokens: usage.inputTokens,
342+
outputTokens: usage.outputTokens,
343+
finishReason,
344+
toolsUsed: toolCalls?.map(tc => tc.toolName),
345+
});
346+
},
347+
});
348+
```
349+
350+
You can also define `onStepFinish` in the constructor for agent-wide tracking. When both constructor and method callbacks are provided, both are called (constructor first, then the method callback):
351+
352+
```ts
353+
const agent = new ToolLoopAgent({
354+
model: __MODEL__,
355+
onStepFinish: async ({ usage }) => {
356+
// Agent-wide logging
357+
console.log('Agent step:', usage.totalTokens);
358+
},
359+
});
360+
361+
// Method-level callback runs after constructor callback
362+
const result = await agent.generate({
363+
prompt: 'Hello',
364+
onStepFinish: async ({ usage }) => {
365+
// Per-call tracking (e.g., for billing)
366+
await trackUsage(usage);
367+
},
368+
});
369+
```
370+
332371
## End-to-end Type Safety
333372

334373
You can infer types for your agent's `UIMessage`s:

content/docs/07-reference/01-ai-sdk-core/15-agent.mdx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import { Output } from '../generate-text/output';
1818
import { GenerateTextResult } from '../generate-text/generate-text-result';
1919
import { StreamTextResult } from '../generate-text/stream-text-result';
2020

21-
export type AgentCallParameters<CALL_OPTIONS> = ([CALL_OPTIONS] extends [never]
21+
export type AgentCallParameters<CALL_OPTIONS, TOOLS extends ToolSet = {}> = ([
22+
CALL_OPTIONS,
23+
] extends [never]
2224
? { options?: never }
2325
: { options: CALL_OPTIONS }) &
2426
(
@@ -63,6 +65,10 @@ export type AgentCallParameters<CALL_OPTIONS> = ([CALL_OPTIONS] extends [never]
6365
* Can be used alongside abortSignal.
6466
*/
6567
timeout?: number | { totalMs?: number };
68+
/**
69+
* Callback that is called when each step (LLM call) is finished, including intermediate steps.
70+
*/
71+
onStepFinish?: ToolLoopAgentOnStepFinishCallback<TOOLS>;
6672
};
6773

6874
/**
@@ -97,14 +103,14 @@ export interface Agent<
97103
* Generates an output from the agent (non-streaming).
98104
*/
99105
generate(
100-
options: AgentCallParameters<CALL_OPTIONS>,
106+
options: AgentCallParameters<CALL_OPTIONS, TOOLS>,
101107
): PromiseLike<GenerateTextResult<TOOLS, OUTPUT>>;
102108

103109
/**
104110
* Streams an output from the agent (streaming).
105111
*/
106112
stream(
107-
options: AgentCallParameters<CALL_OPTIONS>,
113+
options: AgentStreamParameters<CALL_OPTIONS, TOOLS>,
108114
): PromiseLike<StreamTextResult<TOOLS, OUTPUT>>;
109115
}
110116
```
@@ -129,13 +135,14 @@ export interface Agent<
129135

130136
## Method Parameters
131137

132-
Both `generate()` and `stream()` accept an `AgentCallParameters<CALL_OPTIONS>` object with:
138+
Both `generate()` and `stream()` accept an `AgentCallParameters<CALL_OPTIONS, TOOLS>` object with:
133139

134140
- `prompt` (optional): A string prompt or array of `ModelMessage` objects
135141
- `messages` (optional): An array of `ModelMessage` objects (mutually exclusive with `prompt`)
136142
- `options` (optional): Additional call options when `CALL_OPTIONS` is not `never`
137143
- `abortSignal` (optional): An `AbortSignal` to cancel the operation
138144
- `timeout` (optional): A timeout in milliseconds. Can be specified as a number or as an object with a `totalMs` property. The call will be aborted if it takes longer than the specified timeout. Can be used alongside `abortSignal`.
145+
- `onStepFinish` (optional): A callback invoked after each agent step (LLM/tool call) completes. Useful for tracking token usage or logging.
139146

140147
## Example: Custom Agent Implementation
141148

content/docs/07-reference/01-ai-sdk-core/16-tool-loop-agent.mdx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,13 @@ const result = await agent.generate({
248248
description:
249249
'Timeout in milliseconds. Can be specified as a number or as an object with a totalMs property. The call will be aborted if it takes longer than the specified timeout. Can be used alongside abortSignal.',
250250
},
251+
{
252+
name: 'onStepFinish',
253+
type: 'ToolLoopAgentOnStepFinishCallback',
254+
isOptional: true,
255+
description:
256+
'Callback invoked after each agent step (LLM/tool call) completes. If also specified in the constructor, both callbacks are called (constructor first, then this one).',
257+
},
251258
]}
252259
/>
253260

@@ -302,6 +309,13 @@ for await (const chunk of stream.textStream) {
302309
description:
303310
'Optional stream transformation(s). They are applied in the order provided and must maintain the stream structure. See `streamText` docs for details.',
304311
},
312+
{
313+
name: 'onStepFinish',
314+
type: 'ToolLoopAgentOnStepFinishCallback',
315+
isOptional: true,
316+
description:
317+
'Callback invoked after each agent step (LLM/tool call) completes. If also specified in the constructor, both callbacks are called (constructor first, then this one).',
318+
},
305319
]}
306320
/>
307321

content/docs/07-reference/01-ai-sdk-core/17-create-agent-ui-stream.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ export async function* streamAgent(
8686
description:
8787
'Optional transformations to apply to the agent output stream (experimental).',
8888
},
89+
{
90+
name: 'onStepFinish',
91+
type: 'ToolLoopAgentOnStepFinishCallback',
92+
isRequired: false,
93+
description:
94+
'Callback invoked after each agent step (LLM/tool call) completes. Useful for tracking token usage or logging intermediate steps.',
95+
},
8996
{
9097
name: '...UIMessageStreamOptions',
9198
type: 'UIMessageStreamOptions',

content/docs/07-reference/01-ai-sdk-core/18-create-agent-ui-stream-response.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ export async function POST(request: Request) {
8787
description:
8888
'Optional stream transforms to post-process text output—the same as in lower-level streaming APIs.',
8989
},
90+
{
91+
name: 'onStepFinish',
92+
type: 'ToolLoopAgentOnStepFinishCallback',
93+
isRequired: false,
94+
description:
95+
'Callback invoked after each agent step (LLM/tool call) completes. Useful for tracking token usage or logging intermediate steps.',
96+
},
9097
{
9198
name: '...UIMessageStreamOptions',
9299
type: 'UIMessageStreamOptions',

content/docs/07-reference/01-ai-sdk-core/18-pipe-agent-ui-stream-to-response.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ export async function handler(req, res) {
8888
description:
8989
'Optional stream text transformation(s) applied to agent output.',
9090
},
91+
{
92+
name: 'onStepFinish',
93+
type: 'ToolLoopAgentOnStepFinishCallback',
94+
isRequired: false,
95+
description:
96+
'Callback invoked after each agent step (LLM/tool call) completes. Useful for tracking token usage or logging intermediate steps.',
97+
},
9198
{
9299
name: '...UIMessageStreamResponseInit & UIMessageStreamOptions',
93100
type: 'object',
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { openai } from '@ai-sdk/openai';
2+
import { tool, ToolLoopAgent } from 'ai';
3+
import { z } from 'zod';
4+
import { run } from '../lib/run';
5+
6+
const agent = new ToolLoopAgent({
7+
model: openai('gpt-4o'),
8+
instructions: 'You are a helpful assistant that can look up weather.',
9+
tools: {
10+
weather: tool({
11+
description: 'Get the weather for a location',
12+
inputSchema: z.object({
13+
location: z.string().describe('The location to get weather for'),
14+
}),
15+
execute: ({ location }) => ({
16+
location,
17+
temperature: 72 + Math.floor(Math.random() * 21) - 10,
18+
condition: 'sunny',
19+
}),
20+
}),
21+
},
22+
});
23+
24+
run(async () => {
25+
const result = await agent.generate({
26+
prompt: 'What is the weather in San Francisco and New York?',
27+
onStepFinish: async ({ text, finishReason, usage, toolCalls }) => {
28+
console.log('\n--- Step Finished ---');
29+
console.log('Finish Reason:', finishReason);
30+
console.log('Token Usage:', {
31+
input: usage.inputTokens,
32+
output: usage.outputTokens,
33+
total: usage.totalTokens,
34+
});
35+
36+
if (toolCalls && toolCalls.length > 0) {
37+
console.log(
38+
'Tool Calls:',
39+
toolCalls.map(tc => tc.toolName),
40+
);
41+
}
42+
43+
if (text) {
44+
console.log(
45+
'Text:',
46+
text.substring(0, 100) + (text.length > 100 ? '...' : ''),
47+
);
48+
}
49+
},
50+
});
51+
52+
console.log('\n=== Final Result ===');
53+
console.log('Total Steps:', result.steps.length);
54+
console.log('Final Text:', result.text);
55+
});

packages/ai/src/agent/agent.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ import { StreamTextTransform } from '../generate-text/stream-text';
55
import { StreamTextResult } from '../generate-text/stream-text-result';
66
import { ToolSet } from '../generate-text/tool-set';
77
import { TimeoutConfiguration } from '../prompt/call-settings';
8+
import { ToolLoopAgentOnStepFinishCallback } from './tool-loop-agent-on-step-finish-callback';
89

910
/**
1011
* Parameters for calling an agent.
1112
*/
12-
export type AgentCallParameters<CALL_OPTIONS> = ([CALL_OPTIONS] extends [never]
13+
export type AgentCallParameters<CALL_OPTIONS, TOOLS extends ToolSet = {}> = ([
14+
CALL_OPTIONS,
15+
] extends [never]
1316
? { options?: never }
1417
: { options: CALL_OPTIONS }) &
1518
(
@@ -53,6 +56,11 @@ export type AgentCallParameters<CALL_OPTIONS> = ([CALL_OPTIONS] extends [never]
5356
* Timeout in milliseconds. Can be specified as a number or as an object with `totalMs`.
5457
*/
5558
timeout?: TimeoutConfiguration;
59+
60+
/**
61+
* Callback that is called when each step (LLM call) is finished, including intermediate steps.
62+
*/
63+
onStepFinish?: ToolLoopAgentOnStepFinishCallback<TOOLS>;
5664
};
5765

5866
/**
@@ -61,7 +69,7 @@ export type AgentCallParameters<CALL_OPTIONS> = ([CALL_OPTIONS] extends [never]
6169
export type AgentStreamParameters<
6270
CALL_OPTIONS,
6371
TOOLS extends ToolSet,
64-
> = AgentCallParameters<CALL_OPTIONS> & {
72+
> = AgentCallParameters<CALL_OPTIONS, TOOLS> & {
6573
/**
6674
* Optional stream transformations.
6775
* They are applied in the order they are provided.
@@ -104,7 +112,7 @@ export interface Agent<
104112
* Generates an output from the agent (non-streaming).
105113
*/
106114
generate(
107-
options: AgentCallParameters<CALL_OPTIONS>,
115+
options: AgentCallParameters<CALL_OPTIONS, TOOLS>,
108116
): PromiseLike<GenerateTextResult<TOOLS, OUTPUT>>;
109117

110118
/**

packages/ai/src/agent/create-agent-ui-stream-response.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ import { UIMessageStreamResponseInit } from '../ui-message-stream/ui-message-str
77
import { InferUITools, UIMessage } from '../ui/ui-messages';
88
import { Agent } from './agent';
99
import { createAgentUIStream } from './create-agent-ui-stream';
10+
import { ToolLoopAgentOnStepFinishCallback } from './tool-loop-agent-on-step-finish-callback';
1011

1112
/**
1213
* Runs the agent and returns a response object with a UI message stream.
1314
*
1415
* @param agent - The agent to run.
1516
* @param uiMessages - The input UI messages.
17+
* @param onStepFinish - Callback that is called when each step is finished. Optional.
1618
*
1719
* @returns The response object.
1820
*/
@@ -36,6 +38,7 @@ export async function createAgentUIStreamResponse<
3638
experimental_transform?:
3739
| StreamTextTransform<TOOLS>
3840
| Array<StreamTextTransform<TOOLS>>;
41+
onStepFinish?: ToolLoopAgentOnStepFinishCallback<TOOLS>;
3942
} & UIMessageStreamResponseInit &
4043
UIMessageStreamOptions<
4144
UIMessage<MESSAGE_METADATA, never, InferUITools<TOOLS>>

0 commit comments

Comments
 (0)