Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/max-tool-calls-budget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai': minor
---

Bound tool-call fan-out in agent loops: `AgentLoopState` now exposes `toolCallCount` and `lastTurnToolCallCount`, `maxToolCalls(n)` strategy caps cumulative tool calls, and `chat({ maxToolCallsPerTurn })` caps how many parallel calls execute in a single turn.
38 changes: 34 additions & 4 deletions docs/api/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ const stream = chat({
- `tools?` - Array of tools for function calling
- `context?` - Typed runtime context passed to server tools and middleware. If a tool or middleware declares a concrete context type, `chat()` requires a compatible value here
- `systemPrompts?` - System prompts to prepend to messages
- `agentLoopStrategy?` - Strategy for agent loops (default: `maxIterations(5)`)
- `agentLoopStrategy?` - Strategy for agent loops (default: `maxIterations(5)`). Strategies receive `{ iterationCount, finishReason, messages, toolCallCount, lastTurnToolCallCount }` and run between model turns. Prefer `maxToolCalls(n)` when you need a tool-call budget — iterations are model turns, not tool calls. `maxToolCalls` stops *further* turns once cumulative **emitted** tool calls are `>= n`; the turn that crosses the limit is not truncated.
- `maxToolCallsPerTurn?` - Cap how many tool calls from a single model turn (or pending/resume batch) are executed. Excess calls receive error results so message history stays consistent. Unset means no per-turn execution cap; `0` skips all execution for that batch. Pair with `maxToolCalls(n)` for a cumulative **emitted**-call budget (skipped calls still count).
- `abortController?` - AbortController for cancellation
- `modelOptions?` - Provider-native model options. This is where sampling parameters live — `temperature`, `top_p`/`topP`, and the provider's token-limit key (`max_output_tokens`, `max_tokens`, `maxOutputTokens`, …) — under each provider's canonical name, rather than as generic root-level props. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). (Renamed from `providerOptions`.)
- `threadId?` - AG-UI thread identifier propagated into `RUN_STARTED` events for run correlation
Expand Down Expand Up @@ -314,7 +315,7 @@ A merged tool record suitable for `chat({ tools })`.

## `maxIterations(count)`

Creates an agent loop strategy that limits iterations.
Creates an agent loop strategy that limits **model turns** (iterations), not tool calls. One turn can still emit many parallel tool calls — use `maxToolCalls` and/or `maxToolCallsPerTurn` for tool-call budgets.

```typescript
import { chat, maxIterations } from "@tanstack/ai";
Expand All @@ -329,11 +330,40 @@ const stream = chat({

### Parameters

- `count` - Maximum number of tool execution iterations
- `count` - Maximum number of model turns

### Returns

An `AgentLoopStrategy` object.
An `AgentLoopStrategy` function.

## `maxToolCalls(count)`

Creates an agent loop strategy that continues while cumulative **emitted** tool calls are below `count` (including calls skipped by `maxToolCallsPerTurn`).

Strategies only run between turns — the turn that crosses the limit is not truncated, so the final count may exceed `count` unless you also set `maxToolCallsPerTurn`. Pair with `chat({ maxToolCallsPerTurn })` to cap parallel fan-out inside a single turn.

```typescript
import { chat, combineStrategies, maxIterations, maxToolCalls } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";

const stream = chat({
adapter: openaiText("gpt-5.2"),
messages: [{ role: "user", content: "Hello!" }],
maxToolCallsPerTurn: 10,
agentLoopStrategy: combineStrategies([
maxIterations(20),
maxToolCalls(20),
]),
});
```

### Parameters

- `count` - Maximum cumulative tool calls across the run

### Returns

An `AgentLoopStrategy` function.

## Types

Expand Down
21 changes: 17 additions & 4 deletions docs/chat/agentic-cycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,18 @@ The loop continues only while the model's finish reason is `tool_calls` (with pe

### Controlling the loop

By default the loop is bounded by `maxIterations(5)` — after five iterations it stops even if the model would keep calling tools. Override this with the `agentLoopStrategy` option:
By default the loop is bounded by `maxIterations(5)` — after five **model turns** it stops even if the model would keep calling tools. Override this with the `agentLoopStrategy` option.

> **Iterations ≠ tool calls.** One model turn can emit many parallel tool calls. `maxIterations` only bounds turns. Use `maxToolCalls(n)` for a cumulative **emitted**-call budget (stops further turns once the count is reached; the crossing turn is not truncated), and `maxToolCallsPerTurn` to cap how many of those parallel calls **execute** in a single turn or pending/resume batch (strategies only run *between* turns, so without a per-turn cap a single runaway turn can still fan out unbounded). Skipped calls still count toward `maxToolCalls`.

```typescript
import { chat, maxIterations, toServerSentEventsResponse } from "@tanstack/ai";
import {
chat,
combineStrategies,
maxIterations,
maxToolCalls,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { getWeather, getClothingAdvice } from "./tools";

Expand All @@ -170,18 +178,23 @@ export async function POST(request: Request) {
adapter: openaiText("gpt-5.5"),
messages,
tools: [getWeather, getClothingAdvice],
agentLoopStrategy: maxIterations(3), // default is 5
maxToolCallsPerTurn: 10, // cap parallel fan-out inside one turn
agentLoopStrategy: combineStrategies([
maxIterations(20), // max model turns
maxToolCalls(20), // max tool calls across the run
]),
});
return toServerSentEventsResponse(stream);
}
```

Other built-in strategies:

- **`maxToolCalls(n)`** — continue while cumulative emitted tool calls are below `n` (including per-turn skips; not model turns).
- **`untilFinishReason([...])`** — continue until the model returns one of the given finish reasons (e.g. `untilFinishReason(["stop", "length"])`).
- **`combineStrategies([...])`** — combine multiple strategies with AND logic; the loop continues only while every strategy agrees.

A strategy is just a function that receives `{ iterationCount, finishReason, messages }` and returns `true` to allow another iteration or `false` to stop, so you can also write your own:
A strategy is just a function that receives `{ iterationCount, finishReason, messages, toolCallCount, lastTurnToolCallCount }` and returns `true` to allow another iteration or `false` to stop, so you can also write your own:

```typescript
import { chat, combineStrategies, maxIterations, toServerSentEventsResponse } from "@tanstack/ai";
Expand Down
6 changes: 4 additions & 2 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@
{
"label": "Agentic Cycle",
"to": "chat/agentic-cycle",
"addedAt": "2026-04-15"
"addedAt": "2026-04-15",
"updatedAt": "2026-07-20"
},
{
"label": "Streaming",
Expand Down Expand Up @@ -479,7 +480,8 @@
{
"label": "@tanstack/ai",
"to": "api/ai",
"addedAt": "2026-04-15"
"addedAt": "2026-04-15",
"updatedAt": "2026-07-20"
},
{
"label": "@tanstack/ai-client",
Expand Down
2 changes: 2 additions & 0 deletions packages/ai/skills/ai-core/tool-calling/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,8 @@ export async function POST(request: Request) {
adapter: openaiText('gpt-5.5'),
messages,
tools: [getProducts, compareProducts],
// maxIterations bounds model turns, not tool calls. Prefer maxToolCalls
// (and maxToolCallsPerTurn) when you need a tool-call budget.
agentLoopStrategy: maxIterations(20),
})
return toServerSentEventsResponse(stream)
Expand Down
46 changes: 43 additions & 3 deletions packages/ai/src/activities/chat/agent-loop-strategies.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import type { AgentLoopStrategy } from '../../types'

/**
* Creates a strategy that continues for a maximum number of iterations
* Creates a strategy that continues for a maximum number of **model turns**
* (iterations), not tool calls.
*
* @param max - Maximum number of iterations to allow
* One iteration can still emit many parallel tool calls. Prefer
* {@link maxToolCalls} (and optionally `maxToolCallsPerTurn` on `chat()`)
* when you need a tool-call budget.
*
* @param max - Maximum number of model turns to allow
* @returns AgentLoopStrategy that stops after max iterations
*
* @example
Expand All @@ -13,14 +18,48 @@ import type { AgentLoopStrategy } from '../../types'
* model: "gpt-4o",
* messages: [...],
* tools: [weatherTool],
* agentLoopStrategy: maxIterations(3), // Max 3 iterations
* agentLoopStrategy: maxIterations(3), // Max 3 model turns
* });
* ```
*/
export function maxIterations(max: number): AgentLoopStrategy {
return ({ iterationCount }) => iterationCount < max
}

/**
* Creates a strategy that continues while `toolCallCount < max`.
*
* Unlike {@link maxIterations} (which counts model turns), this bounds
* **emitted** tool calls counted during the run (including ones skipped by
* `maxToolCallsPerTurn`). Strategies only run between turns, so the turn that
* crosses `max` is not truncated — the final count (and executions, unless
* `maxToolCallsPerTurn` is set) may exceed `max`. Pair with
* `chat({ maxToolCallsPerTurn })` to also cap parallel fan-out inside a single
* turn.
*
* @param max - Maximum cumulative emitted tool calls before stopping further turns
* @returns AgentLoopStrategy that returns true while `toolCallCount < max`
*
* @example
* ```typescript
* import { chat, combineStrategies, maxIterations, maxToolCalls } from '@tanstack/ai'
*
* const stream = chat({
* adapter: openaiText('gpt-4o'),
* messages: [...],
* tools: [weatherTool],
* maxToolCallsPerTurn: 10,
* agentLoopStrategy: combineStrategies([
* maxIterations(20),
* maxToolCalls(20),
* ]),
* })
* ```
*/
export function maxToolCalls(max: number): AgentLoopStrategy {
return ({ toolCallCount }) => toolCallCount < max
}

/**
* Creates a strategy that continues until a specific finish reason is encountered
*
Expand Down Expand Up @@ -71,6 +110,7 @@ export function untilFinishReason(
* tools: [weatherTool],
* agentLoopStrategy: combineStrategies([
* maxIterations(10),
* maxToolCalls(20),
* ({ messages }) => messages.length < 100,
* ]),
* });
Expand Down
Loading
Loading