Skip to content

Commit 6f75953

Browse files
Backport: feat(ai): add new isLoopFinished stop condition helper for unlimited steps (#13966)
This is an automated backport of #13937 to the release-v6.0 branch. FYI @felixarntz ~~This backport has conflicts that need to be resolved manually.~~ Conflicts resolved. ### `git cherry-pick` output ``` Auto-merging content/docs/03-agents/02-building-agents.mdx Auto-merging content/docs/07-reference/01-ai-sdk-core/index.mdx Auto-merging packages/ai/src/generate-text/generate-text.test.ts CONFLICT (content): Merge conflict in packages/ai/src/generate-text/generate-text.test.ts Auto-merging packages/ai/src/generate-text/index.ts Auto-merging packages/ai/src/generate-text/stream-text.test.ts error: could not apply 5c4d910... feat(ai): add new `isLoopFinished` stop condition helper for unlimited steps (#13937) hint: After resolving the conflicts, mark them with hint: "git add/rm <pathspec>", then run hint: "git cherry-pick --continue". hint: You can instead skip this commit with "git cherry-pick --skip". hint: To abort and get back to the state before "git cherry-pick", hint: run "git cherry-pick --abort". hint: Disable this message with "git config set advice.mergeConflict false" ``` --------- Co-authored-by: Felix Arntz <felix.arntz@vercel.com>
1 parent cf15595 commit 6f75953

File tree

13 files changed

+282
-16
lines changed

13 files changed

+282
-16
lines changed

.changeset/old-stingrays-watch.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 new `isLoopFinished` stop condition helper for unlimited steps

content/docs/03-agents/01-overview.mdx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ These components work together:
2020
The ToolLoopAgent class handles these three components. Here's an agent that uses multiple tools in a loop to accomplish a task:
2121

2222
```ts
23-
import { ToolLoopAgent, stepCountIs, tool } from 'ai';
23+
import { ToolLoopAgent, tool } from 'ai';
2424
__PROVIDER_IMPORT__;
2525
import { z } from 'zod';
2626

@@ -48,8 +48,6 @@ const weatherAgent = new ToolLoopAgent({
4848
},
4949
}),
5050
},
51-
// Agent's default behavior is to stop after a maximum of 20 steps
52-
// stopWhen: stepCountIs(20),
5351
});
5452

5553
const result = await weatherAgent.generate({

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,15 +81,15 @@ const codeAgent = new ToolLoopAgent({
8181

8282
By default, agents run for 20 steps (`stopWhen: stepCountIs(20)`). In each step, the model either generates text or calls a tool. If it generates text, the agent completes. If it calls a tool, the AI SDK executes that tool.
8383

84-
To let agents call multiple tools in sequence, configure `stopWhen` to allow more steps. After each tool execution, the agent triggers a new generation where the model can call another tool or generate text:
84+
You can configure `stopWhen` differently to allow more steps. After each tool execution, the agent triggers a new generation where the model can call another tool or generate text:
8585

8686
```ts
8787
import { ToolLoopAgent, stepCountIs } from 'ai';
8888
__PROVIDER_IMPORT__;
8989

9090
const agent = new ToolLoopAgent({
9191
model: __MODEL__,
92-
stopWhen: stepCountIs(20), // Allow up to 20 steps
92+
stopWhen: stepCountIs(50), // Increase default from 20 to 50.
9393
});
9494
```
9595

@@ -160,7 +160,7 @@ const agent = new ToolLoopAgent({
160160
Define structured output schemas:
161161

162162
```ts
163-
import { ToolLoopAgent, Output, stepCountIs } from 'ai';
163+
import { ToolLoopAgent, Output } from 'ai';
164164
__PROVIDER_IMPORT__;
165165
import { z } from 'zod';
166166

@@ -173,7 +173,6 @@ const analysisAgent = new ToolLoopAgent({
173173
keyPoints: z.array(z.string()),
174174
}),
175175
}),
176-
stopWhen: stepCountIs(10),
177176
});
178177

179178
const { output } = await analysisAgent.generate({

content/docs/03-agents/04-loop-control.mdx

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,20 @@ The AI SDK provides built-in loop control through two parameters: `stopWhen` for
1616

1717
## Stop Conditions
1818

19-
The `stopWhen` parameter controls when to stop execution when there are tool results in the last step. By default, agents stop after 20 steps using `stepCountIs(20)`.
19+
The `stopWhen` parameter controls when to stop execution when there are tool results in the last step. By default, agents stop after 20 steps using `stepCountIs(20)`. This default is a safety measure to prevent runaway loops that could result in excessive API calls and costs.
2020

2121
When you provide `stopWhen`, the agent continues executing after tool calls until a stopping condition is met. When the condition is an array, execution stops when any of the conditions are met.
2222

2323
### Use Built-in Conditions
2424

2525
The AI SDK provides several built-in stopping conditions:
2626

27+
- `stepCountIs(count)` — stops after a specified number of steps
28+
- `hasToolCall(toolName)` — stops when a specific tool is called
29+
- `isLoopFinished()` — never triggers, letting the loop run until the agent is naturally finished
30+
31+
### Run Up to a Maximum Number of Steps
32+
2733
```ts
2834
import { ToolLoopAgent, stepCountIs } from 'ai';
2935
__PROVIDER_IMPORT__;
@@ -33,14 +39,41 @@ const agent = new ToolLoopAgent({
3339
tools: {
3440
// your tools
3541
},
36-
stopWhen: stepCountIs(20), // Default state: stop after 20 steps maximum
42+
stopWhen: stepCountIs(50), // Increasing the default of 20 to 50.
43+
});
44+
45+
const result = await agent.generate({
46+
prompt: 'Analyze this dataset and create a summary report',
47+
});
48+
```
49+
50+
### Run Until Finished
51+
52+
If you want the agent to run until the model naturally stops making tool calls, use `isLoopFinished()`. This removes the default step limit:
53+
54+
```ts
55+
import { ToolLoopAgent, isLoopFinished } from 'ai';
56+
__PROVIDER_IMPORT__;
57+
58+
const agent = new ToolLoopAgent({
59+
model: __MODEL__,
60+
tools: {
61+
// your tools
62+
},
63+
stopWhen: isLoopFinished(), // No maximum step limit.
3764
});
3865

3966
const result = await agent.generate({
4067
prompt: 'Analyze this dataset and create a summary report',
4168
});
4269
```
4370

71+
<Note>
72+
Use `isLoopFinished()` with caution. Without a step limit, the agent could
73+
potentially run indefinitely or incur significant costs if the model keeps
74+
making tool calls.
75+
</Note>
76+
4477
### Combine Multiple Conditions
4578

4679
Combine multiple stopping conditions. The loop stops when it meets any condition:

content/docs/03-agents/06-memory.mdx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,15 +187,14 @@ pnpm add @vectorize-io/hindsight-ai-sdk @vectorize-io/hindsight-client
187187
__PROVIDER_IMPORT__;
188188
import { HindsightClient } from '@vectorize-io/hindsight-client';
189189
import { createHindsightTools } from '@vectorize-io/hindsight-ai-sdk';
190-
import { ToolLoopAgent, stepCountIs } from 'ai';
190+
import { ToolLoopAgent } from 'ai';
191191
import { openai } from '@ai-sdk/openai';
192192

193193
const client = new HindsightClient({ baseUrl: process.env.HINDSIGHT_API_URL });
194194

195195
const agent = new ToolLoopAgent({
196196
model: __MODEL__,
197197
tools: createHindsightTools({ client, bankId: 'user-123' }),
198-
stopWhen: stepCountIs(10),
199198
instructions: 'You are a helpful assistant with long-term memory.',
200199
});
201200

content/docs/03-ai-sdk-core/15-tools-and-tool-calling.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,14 @@ When using `useChat`, the approval flow is handled through UI state. See [Chatbo
226226

227227
With the `stopWhen` setting, you can enable multi-step calls in `generateText` and `streamText`. When `stopWhen` is set and the model generates a tool call, the AI SDK will trigger a new generation passing in the tool result until there are no further tool calls or the stopping condition is met.
228228

229+
The AI SDK provides several built-in stopping conditions:
230+
231+
- `stepCountIs(count)` — stops after a specified number of steps (default: `stepCountIs(20)`)
232+
- `hasToolCall(toolName)` — stops when a specific tool is called
233+
- `isLoopFinished()` — never triggers, letting the loop run until naturally finished
234+
235+
You can also combine multiple conditions in an array or create custom conditions. See [Loop Control](/docs/agents/loop-control) for more details.
236+
229237
<Note>
230238
The `stopWhen` conditions are only evaluated when the last step contains tool
231239
results.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
---
2+
title: isLoopFinished
3+
description: API Reference for isLoopFinished.
4+
---
5+
6+
# `isLoopFinished()`
7+
8+
Creates a stop condition that never triggers, letting the agent loop run until it naturally finishes (i.e., the model stops making tool calls).
9+
10+
By default, `ToolLoopAgent` uses `stepCountIs(20)` as a safety measure to prevent runaway loops that could result in excessive API calls and costs. If you are confident that your agent will terminate naturally or you are less concerned about costs, `isLoopFinished()` removes that limit and lets the agent run until the model is truly done.
11+
12+
```ts
13+
import { ToolLoopAgent, isLoopFinished } from 'ai';
14+
__PROVIDER_IMPORT__;
15+
16+
const agent = new ToolLoopAgent({
17+
model: __MODEL__,
18+
tools: {
19+
// your tools
20+
},
21+
stopWhen: isLoopFinished(),
22+
});
23+
24+
const result = await agent.generate({
25+
prompt: 'Analyze this dataset and create a summary report',
26+
});
27+
```
28+
29+
## Import
30+
31+
<Snippet text={`import { isLoopFinished } from "ai"`} prompt={false} />
32+
33+
## API Signature
34+
35+
### Parameters
36+
37+
This function takes no parameters.
38+
39+
### Returns
40+
41+
A `StopCondition` function that always returns `false`, meaning it never triggers the stop condition. The agent loop will only stop through its natural termination conditions:
42+
43+
- The model stops making tool calls, or
44+
- A tool without an `execute` function is called, or
45+
- A tool call needs approval
46+
47+
## Examples
48+
49+
### Basic Usage
50+
51+
Let the agent run until it's finished:
52+
53+
```ts
54+
import { ToolLoopAgent, isLoopFinished } from 'ai';
55+
56+
const agent = new ToolLoopAgent({
57+
model: yourModel,
58+
tools: yourTools,
59+
stopWhen: isLoopFinished(),
60+
});
61+
```
62+
63+
### Combining with Other Conditions
64+
65+
You can combine `isLoopFinished()` with other conditions. Since `isLoopFinished()` never triggers, the other conditions still apply:
66+
67+
```ts
68+
import { ToolLoopAgent, isLoopFinished, hasToolCall } from 'ai';
69+
70+
const agent = new ToolLoopAgent({
71+
model: yourModel,
72+
tools: yourTools,
73+
stopWhen: [isLoopFinished(), hasToolCall('finalAnswer')],
74+
});
75+
```
76+
77+
In practice, this does not make much sense in this context, since you could just omit `isLoopFinished()`.
78+
79+
## See also
80+
81+
- [`stepCountIs()`](/docs/reference/ai-sdk-core/step-count-is)
82+
- [`hasToolCall()`](/docs/reference/ai-sdk-core/has-tool-call)
83+
- [`ToolLoopAgent`](/docs/reference/ai-sdk-core/tool-loop-agent)

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,24 @@ It also contains the following helper functions:
130130
'Extracts JSON from text content by stripping markdown code fences.',
131131
href: '/docs/reference/ai-sdk-core/extract-json-middleware',
132132
},
133+
{
134+
title: 'stepCountIs()',
135+
description:
136+
'Creates a stop condition that triggers after a specified number of steps.',
137+
href: '/docs/reference/ai-sdk-core/step-count-is',
138+
},
139+
{
140+
title: 'hasToolCall()',
141+
description:
142+
'Creates a stop condition that triggers when a specific tool is called.',
143+
href: '/docs/reference/ai-sdk-core/has-tool-call',
144+
},
145+
{
146+
title: 'isLoopFinished()',
147+
description:
148+
'Creates a stop condition that lets the agent loop run until it naturally finishes.',
149+
href: '/docs/reference/ai-sdk-core/loop-finished',
150+
},
133151
{
134152
title: 'simulateStreamingMiddleware()',
135153
description:

content/providers/03-community-providers/50-hindsight.mdx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ const { text } = await generateText({
105105
### ToolLoopAgent
106106

107107
```ts
108-
import { ToolLoopAgent, stepCountIs } from 'ai';
108+
import { ToolLoopAgent } from 'ai';
109109
import { openai } from '@ai-sdk/openai';
110110
import { HindsightClient } from '@vectorize-io/hindsight-client';
111111
import { createHindsightTools } from '@vectorize-io/hindsight-ai-sdk';
@@ -115,7 +115,6 @@ const client = new HindsightClient({ baseUrl: process.env.HINDSIGHT_API_URL });
115115
const agent = new ToolLoopAgent({
116116
model: openai('gpt-4o'),
117117
tools: createHindsightTools({ client, bankId: 'user-123' }),
118-
stopWhen: stepCountIs(10),
119118
instructions: 'You are a helpful assistant with long-term memory.',
120119
});
121120

packages/ai/src/generate-text/generate-text.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import {
4141
} from './generate-text';
4242
import { GenerateTextResult } from './generate-text-result';
4343
import { StepResult } from './step-result';
44-
import { stepCountIs } from './stop-condition';
44+
import { isLoopFinished, stepCountIs } from './stop-condition';
4545

4646
vi.mock('../version', () => {
4747
return {
@@ -3629,6 +3629,50 @@ describe('generateText', () => {
36293629
`);
36303630
});
36313631
});
3632+
3633+
it('should complete tool loop with isLoopFinished()', async () => {
3634+
let responseCount = 0;
3635+
const result = await generateText({
3636+
model: new MockLanguageModelV3({
3637+
doGenerate: async () => {
3638+
switch (responseCount++) {
3639+
case 0:
3640+
return {
3641+
...dummyResponseValues,
3642+
content: [
3643+
{
3644+
type: 'tool-call',
3645+
toolCallType: 'function',
3646+
toolCallId: 'call-1',
3647+
toolName: 'tool1',
3648+
input: `{ "value": "value" }`,
3649+
},
3650+
],
3651+
finishReason: { unified: 'tool-calls', raw: undefined },
3652+
};
3653+
case 1:
3654+
return {
3655+
...dummyResponseValues,
3656+
content: [{ type: 'text', text: 'Done!' }],
3657+
};
3658+
default:
3659+
throw new Error(`Unexpected response count: ${responseCount}`);
3660+
}
3661+
},
3662+
}),
3663+
tools: {
3664+
tool1: tool({
3665+
inputSchema: z.object({ value: z.string() }),
3666+
execute: async () => 'result1',
3667+
}),
3668+
},
3669+
prompt: 'test-input',
3670+
stopWhen: isLoopFinished(),
3671+
});
3672+
3673+
expect(result.text).toBe('Done!');
3674+
expect(result.steps).toHaveLength(2);
3675+
});
36323676
});
36333677

36343678
describe('options.headers', () => {

0 commit comments

Comments
 (0)