diff --git a/.changeset/max-tool-calls-budget.md b/.changeset/max-tool-calls-budget.md new file mode 100644 index 000000000..7af07f52a --- /dev/null +++ b/.changeset/max-tool-calls-budget.md @@ -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. diff --git a/docs/api/ai.md b/docs/api/ai.md index 6ba6dc753..ad4418bad 100644 --- a/docs/api/ai.md +++ b/docs/api/ai.md @@ -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 @@ -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"; @@ -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 diff --git a/docs/chat/agentic-cycle.md b/docs/chat/agentic-cycle.md index b4808e0ca..70ebd10f6 100644 --- a/docs/chat/agentic-cycle.md +++ b/docs/chat/agentic-cycle.md @@ -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"; @@ -170,7 +178,11 @@ 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); } @@ -178,10 +190,11 @@ export async function POST(request: Request) { 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"; diff --git a/docs/config.json b/docs/config.json index cf22278f5..c9325a7cc 100644 --- a/docs/config.json +++ b/docs/config.json @@ -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", @@ -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", diff --git a/packages/ai/skills/ai-core/tool-calling/SKILL.md b/packages/ai/skills/ai-core/tool-calling/SKILL.md index 0196f73b9..62c605de6 100644 --- a/packages/ai/skills/ai-core/tool-calling/SKILL.md +++ b/packages/ai/skills/ai-core/tool-calling/SKILL.md @@ -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) diff --git a/packages/ai/src/activities/chat/agent-loop-strategies.ts b/packages/ai/src/activities/chat/agent-loop-strategies.ts index 25b7b1334..442ebe01c 100644 --- a/packages/ai/src/activities/chat/agent-loop-strategies.ts +++ b/packages/ai/src/activities/chat/agent-loop-strategies.ts @@ -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 @@ -13,7 +18,7 @@ import type { AgentLoopStrategy } from '../../types' * model: "gpt-4o", * messages: [...], * tools: [weatherTool], - * agentLoopStrategy: maxIterations(3), // Max 3 iterations + * agentLoopStrategy: maxIterations(3), // Max 3 model turns * }); * ``` */ @@ -21,6 +26,40 @@ 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 * @@ -71,6 +110,7 @@ export function untilFinishReason( * tools: [weatherTool], * agentLoopStrategy: combineStrategies([ * maxIterations(10), + * maxToolCalls(20), * ({ messages }) => messages.length < 100, * ]), * }); diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index 93e7a6481..707f14753 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -240,6 +240,11 @@ export interface TextActivityOptions< abortController?: TextOptions['abortController'] /** Strategy for controlling the agent loop */ agentLoopStrategy?: TextOptions['agentLoopStrategy'] + /** + * Cap how many tool calls from a single model turn are executed. + * Excess calls receive error results. See {@link TextOptions.maxToolCallsPerTurn}. + */ + maxToolCallsPerTurn?: TextOptions['maxToolCallsPerTurn'] /** * Optional configuration for lazy-tool discovery (tools marked `lazy: true`). * Tunes how much of each lazy tool's description appears in the discovery @@ -473,6 +478,23 @@ interface TextEngineConfig< type ToolPhaseResult = 'continue' | 'stop' | 'wait' type CyclePhase = 'processText' | 'executeToolCalls' +/** + * Validate and normalize `maxToolCallsPerTurn`. + * Unset → unlimited. `0` → execute none. Negatives / non-finite → throw + * (Array#slice treats negatives as "from end", which is not a useful cap). + */ +function resolveMaxToolCallsPerTurn( + cap: number | undefined, +): number | undefined { + if (cap == null) return undefined + if (!Number.isFinite(cap) || cap < 0) { + throw new Error( + `maxToolCallsPerTurn must be a non-negative finite number, got ${cap}`, + ) + } + return Math.floor(cap) +} + /** * Combine two optional AbortSignals into one that aborts when either does. * Returns the other signal directly when one is absent or already aborted. @@ -519,6 +541,12 @@ class TextEngine< private messages: Array private iterationCount = 0 + /** Cumulative tool calls counted in this run (emitted + pending resume). */ + private toolCallCount = 0 + /** Tool calls in the most recent budgeted batch (0 when none). */ + private lastTurnToolCallCount = 0 + /** Tool call IDs already counted toward `toolCallCount` (avoids double-count on resume). */ + private readonly countedToolCallIds = new Set() private lastFinishReason: string | null = null private streamStartTime = 0 private totalChunkCount = 0 @@ -534,6 +562,7 @@ class TextEngine< private earlyTermination = false private toolPhase: ToolPhaseResult = 'continue' private cyclePhase: CyclePhase = 'processText' + private readonly maxToolCallsPerTurn: number | undefined // Client state extracted from initial messages (before conversion to ModelMessage) private readonly initialApprovals: Map private readonly initialClientToolResults: Map @@ -599,6 +628,9 @@ class TextEngine< this.systemPrompts = config.params.systemPrompts || [] this.loopStrategy = config.params.agentLoopStrategy || maxIterationsStrategy(5) + this.maxToolCallsPerTurn = resolveMaxToolCallsPerTurn( + config.params.maxToolCallsPerTurn, + ) this.initialMessageCount = config.params.messages.length // Extract client state (approvals, client tool results) from original messages BEFORE conversion @@ -1302,9 +1334,14 @@ class TextEngine< const finishEvent = this.createSyntheticFinishedEvent() + // Same fan-out budget as live model turns (seeded history / resume). + // Count is deduped so wait→resume after a live turn does not double-count. + const { toExecute: budgetedToolCalls, skippedResults } = + this.applyToolCallBudget(pendingToolCalls) + // Handle undiscovered lazy tool calls with self-correcting error messages const undiscoveredLazyResults: Array = [] - const executablePendingCalls = pendingToolCalls.filter((tc) => { + const executablePendingCalls = budgetedToolCalls.filter((tc) => { if (this.lazyToolManager.isUndiscoveredLazyTool(tc.function.name)) { undiscoveredLazyResults.push({ toolCallId: tc.id, @@ -1321,16 +1358,27 @@ class TextEngine< return true }) - if (undiscoveredLazyResults.length > 0) { - for (const chunk of this.buildToolResultChunks( - undiscoveredLazyResults, - finishEvent, - )) { - yield* this.pipeThroughMiddleware(chunk) - } + // Non-executed outcomes (undiscovered lazy + per-turn fan-out skips). + // Emitted after executed results so the stream prefers real results first. + const deferredErrorResults = [...undiscoveredLazyResults, ...skippedResults] + + // Build args lookup so buildToolResultChunks can emit TOOL_CALL_START + + // TOOL_CALL_ARGS before TOOL_CALL_END during continuation re-executions. + const argsMap = new Map() + for (const tc of pendingToolCalls) { + argsMap.set(tc.id, tc.function.arguments) } if (executablePendingCalls.length === 0) { + if (deferredErrorResults.length > 0) { + for (const chunk of this.buildToolResultChunks( + deferredErrorResults, + finishEvent, + argsMap, + )) { + yield* this.pipeThroughMiddleware(chunk) + } + } return 'continue' } @@ -1384,28 +1432,23 @@ class TextEngine< return 'stop' } + const allResults = [...executionResult.results, ...deferredErrorResults] + // Notify middleware of tool phase completion (devtools emits aggregate events here) await this.middlewareRunner.runOnToolPhaseComplete(this.middlewareCtx, { toolCalls: pendingToolCalls, - results: executionResult.results, + results: allResults, needsApproval: executionResult.needsApproval, needsClientExecution: executionResult.needsClientExecution, }) - // Build args lookup so buildToolResultChunks can emit TOOL_CALL_START + - // TOOL_CALL_ARGS before TOOL_CALL_END during continuation re-executions. - const argsMap = new Map() - for (const tc of pendingToolCalls) { - argsMap.set(tc.id, tc.function.arguments) - } - if ( executionResult.needsApproval.length > 0 || executionResult.needsClientExecution.length > 0 ) { - if (executionResult.results.length > 0) { + if (allResults.length > 0) { for (const chunk of this.buildToolResultChunks( - executionResult.results, + allResults, finishEvent, argsMap, )) { @@ -1432,7 +1475,7 @@ class TextEngine< } const toolResultChunks = this.buildToolResultChunks( - executionResult.results, + allResults, finishEvent, argsMap, ) @@ -1446,6 +1489,8 @@ class TextEngine< private async *processToolCalls(): AsyncGenerator { if (!this.shouldExecuteToolPhase()) { + // Text-only turn — clear per-turn count so strategies see 0 tools. + this.lastTurnToolCallCount = 0 this.setToolPhase('stop') return } @@ -1454,15 +1499,20 @@ class TextEngine< const finishEvent = this.finishedEvent if (!finishEvent || toolCalls.length === 0) { + this.lastTurnToolCallCount = 0 this.setToolPhase('stop') return } + // Count every model-emitted tool call (including ones we may skip). + const { toExecute: budgetedToolCalls, skippedResults } = + this.applyToolCallBudget(toolCalls) + this.addAssistantToolCallMessage(toolCalls) // Handle undiscovered lazy tool calls with self-correcting error messages const undiscoveredLazyResults: Array = [] - const executableToolCalls = toolCalls.filter((tc) => { + const executableToolCalls = budgetedToolCalls.filter((tc) => { if (this.lazyToolManager.isUndiscoveredLazyTool(tc.function.name)) { undiscoveredLazyResults.push({ toolCallId: tc.id, @@ -1479,17 +1529,21 @@ class TextEngine< return true }) - if (undiscoveredLazyResults.length > 0 && this.finishedEvent) { - for (const chunk of this.buildToolResultChunks( - undiscoveredLazyResults, - this.finishedEvent, - )) { - yield* this.pipeThroughMiddleware(chunk) - } - } + // Non-executed outcomes (undiscovered lazy + per-turn fan-out skips). + // Emitted after executed results so the stream prefers real results first. + const deferredErrorResults = [...undiscoveredLazyResults, ...skippedResults] if (executableToolCalls.length === 0) { - // All tool calls were undiscovered lazy tools — errors emitted, continue loop + // All tool calls were undiscovered lazy tools and/or skipped by the + // per-turn fan-out cap — errors emitted, continue loop (strategy may stop). + if (deferredErrorResults.length > 0) { + for (const chunk of this.buildToolResultChunks( + deferredErrorResults, + finishEvent, + )) { + yield* this.pipeThroughMiddleware(chunk) + } + } this.toolCallManager.clear() this.setToolPhase('continue') return @@ -1548,10 +1602,13 @@ class TextEngine< return } + // Executed results first, then deferred errors (fan-out skips / undiscovered) + const allResults = [...executionResult.results, ...deferredErrorResults] + // Notify middleware of tool phase completion (devtools emits aggregate events here) await this.middlewareRunner.runOnToolPhaseComplete(this.middlewareCtx, { toolCalls, - results: executionResult.results, + results: allResults, needsApproval: executionResult.needsApproval, needsClientExecution: executionResult.needsClientExecution, }) @@ -1560,9 +1617,9 @@ class TextEngine< executionResult.needsApproval.length > 0 || executionResult.needsClientExecution.length > 0 ) { - if (executionResult.results.length > 0) { + if (allResults.length > 0) { for (const chunk of this.buildToolResultChunks( - executionResult.results, + allResults, finishEvent, )) { yield* this.pipeThroughMiddleware(chunk) @@ -1587,10 +1644,7 @@ class TextEngine< return } - const toolResultChunks = this.buildToolResultChunks( - executionResult.results, - finishEvent, - ) + const toolResultChunks = this.buildToolResultChunks(allResults, finishEvent) for (const chunk of toolResultChunks) { yield* this.pipeThroughMiddleware(chunk) @@ -1943,10 +1997,64 @@ class TextEngine< iterationCount: this.iterationCount, messages: this.messages, finishReason: this.lastFinishReason, + toolCallCount: this.toolCallCount, + lastTurnToolCallCount: this.lastTurnToolCallCount, }) && this.toolPhase === 'continue' ) } + /** + * Record tool calls (deduped by id) and return the subset that should be + * executed after applying `maxToolCallsPerTurn`. Excess calls get synthetic + * error results so every tool_call still has a matching result. + * + * Used for both live model turns and pending/resume batches. IDs already + * counted in this run (e.g. wait→resume after a live turn) are not + * re-added to `toolCallCount`. + */ + private applyToolCallBudget(toolCalls: Array): { + toExecute: Array + skippedResults: Array + } { + this.lastTurnToolCallCount = toolCalls.length + let newlyCounted = 0 + for (const tc of toolCalls) { + if (!this.countedToolCallIds.has(tc.id)) { + this.countedToolCallIds.add(tc.id) + newlyCounted++ + } + } + this.toolCallCount += newlyCounted + + const cap = this.maxToolCallsPerTurn + if (cap == null || toolCalls.length <= cap) { + return { toExecute: toolCalls, skippedResults: [] } + } + + this.logger.agentLoop( + `maxToolCallsPerTurn=${cap} skipped=${toolCalls.length - cap}`, + { + maxToolCallsPerTurn: cap, + emitted: toolCalls.length, + skipped: toolCalls.length - cap, + }, + ) + + const toExecute = toolCalls.slice(0, cap) + const skippedResults: Array = toolCalls + .slice(cap) + .map((tc) => ({ + toolCallId: tc.id, + toolName: tc.function.name, + result: { + error: `Skipped: exceeded maxToolCallsPerTurn (${cap})`, + }, + state: 'output-error' as const, + })) + + return { toExecute, skippedResults } + } + private isAborted(): boolean { return !!this.effectiveSignal?.aborted } diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 102987c02..08a708ef2 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -93,6 +93,7 @@ export { brandProviderTool } from './tools/provider-tool' // Agent loop strategies export { maxIterations, + maxToolCalls, untilFinishReason, combineStrategies, } from './activities/chat/agent-loop-strategies' diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 983e220e8..e4cf9721b 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -831,12 +831,24 @@ export interface ResponseFormat { * State passed to agent loop strategy for determining whether to continue */ export interface AgentLoopState { - /** Current iteration count (0-indexed) */ + /** Current iteration count (0-indexed). One iteration = one model turn. */ iterationCount: number /** Current messages array */ messages: Array /** Finish reason from the last response */ finishReason: string | null + /** + * Cumulative tool calls counted so far in this run (model-emitted during the + * agent loop, including ones skipped by `maxToolCallsPerTurn`, and pending + * tools from the inbound message list when resumed). Not a recount of full + * message history; not model turns. + */ + toolCallCount: number + /** + * Tool calls in the most recent budgeted batch — a live model turn or a + * pending/resume batch (0 when the last phase produced no tool calls). + */ + lastTurnToolCallCount: number } /** @@ -847,8 +859,10 @@ export interface AgentLoopState { * * @example * ```typescript - * // Continue for up to 5 iterations + * // Continue for up to 5 iterations (model turns, not tool calls) * const strategy: AgentLoopStrategy = ({ iterationCount }) => iterationCount < 5; + * // Cap total tool calls across the run + * const byTools: AgentLoopStrategy = ({ toolCallCount }) => toolCallCount < 20; * ``` */ export type AgentLoopStrategy = (state: AgentLoopState) => boolean @@ -885,6 +899,24 @@ export interface TextOptions< */ systemPrompts?: Array agentLoopStrategy?: AgentLoopStrategy + /** + * Maximum number of tool calls to **execute** from a single model turn (or + * pending/resume batch). `0` skips all execution for that batch. + * + * Models can emit many parallel tool calls in one turn. `agentLoopStrategy` + * (including `maxIterations` / `maxToolCalls`) is only evaluated between + * turns, so without this cap a single runaway turn can still execute an + * unbounded fan-out. + * + * When set, only the first `maxToolCallsPerTurn` calls are executed; the + * remainder receive error tool results so the message history stays + * consistent. Unset means no per-turn execution cap. Must be a non-negative + * finite number when set. + * + * Pair with the `maxToolCalls(n)` strategy for a cumulative **emitted**-call + * budget across the run (skipped calls still count toward that budget). + */ + maxToolCallsPerTurn?: number /** * Optional configuration for lazy-tool discovery (tools marked `lazy: true`). * Tunes how much of each lazy tool's description appears in the discovery diff --git a/packages/ai/tests/agent-loop-strategies.test.ts b/packages/ai/tests/agent-loop-strategies.test.ts index 9970c1f1a..baea1c0fe 100644 --- a/packages/ai/tests/agent-loop-strategies.test.ts +++ b/packages/ai/tests/agent-loop-strategies.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import { maxIterations, + maxToolCalls, untilFinishReason, combineStrategies, } from '../src/activities/chat/agent-loop-strategies' @@ -13,6 +14,8 @@ describe('Agent Loop Strategies', () => { iterationCount: 0, messages: [], finishReason: null, + toolCallCount: 0, + lastTurnToolCallCount: 0, ...overrides, }) @@ -46,6 +49,42 @@ describe('Agent Loop Strategies', () => { }) }) + describe('maxToolCalls', () => { + it('should continue when below max tool calls', () => { + const strategy = maxToolCalls(20) + + expect(strategy(createState({ toolCallCount: 0 }))).toBe(true) + expect(strategy(createState({ toolCallCount: 10 }))).toBe(true) + expect(strategy(createState({ toolCallCount: 19 }))).toBe(true) + }) + + it('should stop when reaching max tool calls', () => { + const strategy = maxToolCalls(20) + + expect(strategy(createState({ toolCallCount: 20 }))).toBe(false) + expect(strategy(createState({ toolCallCount: 21 }))).toBe(false) + }) + + it('should ignore iterationCount (counts tools, not turns)', () => { + const strategy = maxToolCalls(5) + + // Many iterations but few tools — still continue + expect( + strategy(createState({ iterationCount: 100, toolCallCount: 4 })), + ).toBe(true) + // Few iterations but many tools — stop + expect( + strategy(createState({ iterationCount: 1, toolCallCount: 5 })), + ).toBe(false) + }) + + it('should work with max = 0 (never allow tool calls)', () => { + const strategy = maxToolCalls(0) + + expect(strategy(createState({ toolCallCount: 0 }))).toBe(false) + }) + }) + describe('untilFinishReason', () => { it('should continue on first iteration even with matching finish reason', () => { const strategy = untilFinishReason(['stop']) diff --git a/packages/ai/tests/chat.test.ts b/packages/ai/tests/chat.test.ts index a9ae8c97c..6214e2e2f 100644 --- a/packages/ai/tests/chat.test.ts +++ b/packages/ai/tests/chat.test.ts @@ -1120,6 +1120,321 @@ describe('chat()', () => { // Each iteration has processText + executeToolCalls phases expect(calls.length).toBe(2) }) + + it('should respect maxToolCalls strategy (counts tools, not turns)', async () => { + const executeSpy = vi.fn().mockReturnValue({ data: 'result' }) + + let callCount = 0 + const { adapter, calls } = createMockAdapter({ + chatStreamFn: () => { + callCount++ + // Each turn emits 2 parallel tool calls → 3 turns would be 6 tools + return (async function* () { + yield ev.runStarted() + yield ev.toolStart(`call_${callCount}_a`, 'repeater', 0) + yield ev.toolArgs(`call_${callCount}_a`, '{}') + yield ev.toolStart(`call_${callCount}_b`, 'repeater', 1) + yield ev.toolArgs(`call_${callCount}_b`, '{}') + yield ev.runFinished('tool_calls') + })() + }, + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'Repeat' }], + tools: [serverTool('repeater', executeSpy)], + // Allow only 3 cumulative tool calls — stops after turn 2 (4 tools emitted) + agentLoopStrategy: (state) => state.toolCallCount < 3, + }) + + await collectChunks(stream as AsyncIterable) + + // Turn 0: 2 tools (count=2 < 3) → continue + // Turn 1: 2 tools (count=4 >= 3) → stop further turns + expect(calls.length).toBe(2) + expect(executeSpy).toHaveBeenCalledTimes(4) + }) + + it('should expose lastTurnToolCallCount on strategy state', async () => { + const executeSpy = vi.fn().mockReturnValue({ data: 'result' }) + const seenLastTurn: Array = [] + + let callCount = 0 + const { adapter } = createMockAdapter({ + chatStreamFn: () => { + callCount++ + if (callCount === 1) { + return (async function* () { + yield ev.runStarted() + yield ev.toolStart('call_a', 'repeater', 0) + yield ev.toolArgs('call_a', '{}') + yield ev.toolStart('call_b', 'repeater', 1) + yield ev.toolArgs('call_b', '{}') + yield ev.toolStart('call_c', 'repeater', 2) + yield ev.toolArgs('call_c', '{}') + yield ev.runFinished('tool_calls') + })() + } + return (async function* () { + yield ev.runStarted() + yield ev.textContent('done') + yield ev.runFinished('stop') + })() + }, + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'Go' }], + tools: [serverTool('repeater', executeSpy)], + agentLoopStrategy: (state) => { + seenLastTurn.push(state.lastTurnToolCallCount) + return state.iterationCount < 2 + }, + }) + + await collectChunks(stream as AsyncIterable) + + // After tool turn: lastTurnToolCallCount = 3; after text turn: 0 + expect(seenLastTurn).toContain(3) + expect(seenLastTurn[seenLastTurn.length - 1]).toBe(0) + }) + + it('should cap parallel fan-out with maxToolCallsPerTurn', async () => { + const executeSpy = vi.fn().mockReturnValue({ data: 'result' }) + + const { adapter, calls } = createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.toolStart('call_0', 'repeater', 0), + ev.toolArgs('call_0', '{}'), + ev.toolStart('call_1', 'repeater', 1), + ev.toolArgs('call_1', '{}'), + ev.toolStart('call_2', 'repeater', 2), + ev.toolArgs('call_2', '{}'), + ev.toolStart('call_3', 'repeater', 3), + ev.toolArgs('call_3', '{}'), + ev.toolStart('call_4', 'repeater', 4), + ev.toolArgs('call_4', '{}'), + ev.runFinished('tool_calls'), + ], + [ev.runStarted(), ev.textContent('done'), ev.runFinished('stop')], + ], + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'Fan out' }], + tools: [serverTool('repeater', executeSpy)], + maxToolCallsPerTurn: 2, + // Allow a follow-up turn so we can inspect tool results on the adapter + agentLoopStrategy: (state) => state.iterationCount < 2, + }) + + const chunks = await collectChunks(stream as AsyncIterable) + + // Only first 2 of 5 tool calls execute + expect(executeSpy).toHaveBeenCalledTimes(2) + + // Skipped calls still get error tool results in the stream + // (TOOL_CALL_RESULT.content is the wire form; END.result is stripped by middleware) + const toolResults = chunks.filter((c) => c.type === 'TOOL_CALL_RESULT') + const skipped = toolResults.filter((c) => { + const content = (c as { content?: unknown }).content + return ( + typeof content === 'string' && + content.includes('exceeded maxToolCallsPerTurn') + ) + }) + expect(skipped.length).toBe(3) + + // Follow-up model call sees all 5 tool results (2 real + 3 skipped) + expect(calls.length).toBe(2) + const followUpMessages = calls[1]!.messages as Array<{ + role: string + toolCallId?: string + }> + const toolMessages = followUpMessages.filter((m) => m.role === 'tool') + expect(toolMessages).toHaveLength(5) + }) + + it('counts skipped emissions toward maxToolCalls and stops further turns', async () => { + const executeSpy = vi.fn().mockReturnValue({ data: 'result' }) + const seenCounts: Array = [] + + const { adapter, calls } = createMockAdapter({ + chatStreamFn: () => { + // Fat turn: 8 parallel tools. If we only counted executed (3), + // maxToolCalls(5) would allow another model turn. + return (async function* () { + yield ev.runStarted() + for (let i = 0; i < 8; i++) { + yield ev.toolStart(`call_${i}`, 'repeater', i) + yield ev.toolArgs(`call_${i}`, '{}') + } + yield ev.runFinished('tool_calls') + })() + }, + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'Fan out' }], + tools: [serverTool('repeater', executeSpy)], + maxToolCallsPerTurn: 3, + agentLoopStrategy: (state) => { + seenCounts.push(state.toolCallCount) + return state.toolCallCount < 5 + }, + }) + + await collectChunks(stream as AsyncIterable) + + expect(executeSpy).toHaveBeenCalledTimes(3) + // All 8 emissions counted (not just 3 executed) + expect(seenCounts).toContain(8) + // Strategy stops further model turns once count >= 5 + expect(calls.length).toBe(1) + }) + + it('maxToolCallsPerTurn: 0 skips all tool execution', async () => { + const executeSpy = vi.fn().mockReturnValue({ data: 'result' }) + + const { adapter, calls } = createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.toolStart('call_0', 'repeater', 0), + ev.toolArgs('call_0', '{}'), + ev.toolStart('call_1', 'repeater', 1), + ev.toolArgs('call_1', '{}'), + ev.runFinished('tool_calls'), + ], + [ev.runStarted(), ev.textContent('done'), ev.runFinished('stop')], + ], + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'None' }], + tools: [serverTool('repeater', executeSpy)], + maxToolCallsPerTurn: 0, + agentLoopStrategy: (state) => state.iterationCount < 2, + }) + + const chunks = await collectChunks(stream as AsyncIterable) + + expect(executeSpy).toHaveBeenCalledTimes(0) + const skipped = chunks.filter((c) => { + if (c.type !== 'TOOL_CALL_RESULT') return false + const content = (c as { content?: unknown }).content + return ( + typeof content === 'string' && + content.includes('exceeded maxToolCallsPerTurn') + ) + }) + expect(skipped.length).toBe(2) + // Follow-up still sees tool results for both calls + expect(calls.length).toBe(2) + }) + + it('rejects negative maxToolCallsPerTurn', async () => { + const { adapter } = createMockAdapter({ + iterations: [[ev.runStarted(), ev.textContent('x'), ev.runFinished()]], + }) + + // TextEngine is constructed when the stream is consumed, not at chat() call. + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'Hi' }], + maxToolCallsPerTurn: -1, + }) + + await expect( + collectChunks(stream as AsyncIterable), + ).rejects.toThrow( + /maxToolCallsPerTurn must be a non-negative finite number/, + ) + }) + + it('applies maxToolCallsPerTurn to pending tool calls on resume', async () => { + const executeSpy = vi.fn().mockReturnValue({ ok: true }) + let strategyToolCount = 0 + + const { adapter, calls } = createMockAdapter({ + iterations: [ + // After pending tools are budgeted, loop may continue once if strategy allows + [ev.runStarted(), ev.textContent('done'), ev.runFinished('stop')], + ], + }) + + const stream = chat({ + adapter, + messages: [ + { role: 'user', content: 'Resume with many pending tools' }, + { + role: 'assistant', + content: '', + toolCalls: [ + { + id: 'pending_0', + type: 'function' as const, + function: { name: 'repeater', arguments: '{}' }, + }, + { + id: 'pending_1', + type: 'function' as const, + function: { name: 'repeater', arguments: '{}' }, + }, + { + id: 'pending_2', + type: 'function' as const, + function: { name: 'repeater', arguments: '{}' }, + }, + { + id: 'pending_3', + type: 'function' as const, + function: { name: 'repeater', arguments: '{}' }, + }, + { + id: 'pending_4', + type: 'function' as const, + function: { name: 'repeater', arguments: '{}' }, + }, + ], + }, + ], + tools: [serverTool('repeater', executeSpy)], + maxToolCallsPerTurn: 2, + agentLoopStrategy: (state) => { + strategyToolCount = state.toolCallCount + return state.iterationCount < 1 + }, + }) + + const chunks = await collectChunks(stream as AsyncIterable) + + // Only first 2 of 5 pending tools execute + expect(executeSpy).toHaveBeenCalledTimes(2) + + const skipped = chunks.filter((c) => { + if (c.type !== 'TOOL_CALL_RESULT') return false + const content = (c as { content?: unknown }).content + return ( + typeof content === 'string' && + content.includes('exceeded maxToolCallsPerTurn') + ) + }) + expect(skipped.length).toBe(3) + + // Seeded pending tools are counted toward the strategy budget + expect(strategyToolCount).toBe(5) + + // Strategy may allow one model turn after pending tools complete + expect(calls.length).toBeLessThanOrEqual(1) + }) }) // ========================================================================== diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 14ffbf330..bd0c0f76d 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -43,6 +43,7 @@ import { Route as ApiMcpLifecycleTestRouteImport } from './routes/api.mcp-lifecy import { Route as ApiMcpAppsServerRouteImport } from './routes/api.mcp-apps-server' import { Route as ApiMcpAppsChatRouteImport } from './routes/api.mcp-apps-chat' import { Route as ApiMcpAppsCallRouteImport } from './routes/api.mcp-apps-call' +import { Route as ApiMaxToolCallsWireRouteImport } from './routes/api.max-tool-calls-wire' import { Route as ApiLazyToolsWireRouteImport } from './routes/api.lazy-tools-wire' import { Route as ApiImageRouteImport } from './routes/api.image' import { Route as ApiChatRouteImport } from './routes/api.chat' @@ -232,6 +233,11 @@ const ApiMcpAppsCallRoute = ApiMcpAppsCallRouteImport.update({ path: '/api/mcp-apps-call', getParentRoute: () => rootRouteImport, } as any) +const ApiMaxToolCallsWireRoute = ApiMaxToolCallsWireRouteImport.update({ + id: '/api/max-tool-calls-wire', + path: '/api/max-tool-calls-wire', + getParentRoute: () => rootRouteImport, +} as any) const ApiLazyToolsWireRoute = ApiLazyToolsWireRouteImport.update({ id: '/api/lazy-tools-wire', path: '/api/lazy-tools-wire', @@ -325,6 +331,7 @@ export interface FileRoutesByFullPath { '/api/chat': typeof ApiChatRoute '/api/image': typeof ApiImageRouteWithChildren '/api/lazy-tools-wire': typeof ApiLazyToolsWireRoute + '/api/max-tool-calls-wire': typeof ApiMaxToolCallsWireRoute '/api/mcp-apps-call': typeof ApiMcpAppsCallRoute '/api/mcp-apps-chat': typeof ApiMcpAppsChatRoute '/api/mcp-apps-server': typeof ApiMcpAppsServerRoute @@ -375,6 +382,7 @@ export interface FileRoutesByTo { '/api/chat': typeof ApiChatRoute '/api/image': typeof ApiImageRouteWithChildren '/api/lazy-tools-wire': typeof ApiLazyToolsWireRoute + '/api/max-tool-calls-wire': typeof ApiMaxToolCallsWireRoute '/api/mcp-apps-call': typeof ApiMcpAppsCallRoute '/api/mcp-apps-chat': typeof ApiMcpAppsChatRoute '/api/mcp-apps-server': typeof ApiMcpAppsServerRoute @@ -426,6 +434,7 @@ export interface FileRoutesById { '/api/chat': typeof ApiChatRoute '/api/image': typeof ApiImageRouteWithChildren '/api/lazy-tools-wire': typeof ApiLazyToolsWireRoute + '/api/max-tool-calls-wire': typeof ApiMaxToolCallsWireRoute '/api/mcp-apps-call': typeof ApiMcpAppsCallRoute '/api/mcp-apps-chat': typeof ApiMcpAppsChatRoute '/api/mcp-apps-server': typeof ApiMcpAppsServerRoute @@ -478,6 +487,7 @@ export interface FileRouteTypes { | '/api/chat' | '/api/image' | '/api/lazy-tools-wire' + | '/api/max-tool-calls-wire' | '/api/mcp-apps-call' | '/api/mcp-apps-chat' | '/api/mcp-apps-server' @@ -528,6 +538,7 @@ export interface FileRouteTypes { | '/api/chat' | '/api/image' | '/api/lazy-tools-wire' + | '/api/max-tool-calls-wire' | '/api/mcp-apps-call' | '/api/mcp-apps-chat' | '/api/mcp-apps-server' @@ -578,6 +589,7 @@ export interface FileRouteTypes { | '/api/chat' | '/api/image' | '/api/lazy-tools-wire' + | '/api/max-tool-calls-wire' | '/api/mcp-apps-call' | '/api/mcp-apps-chat' | '/api/mcp-apps-server' @@ -629,6 +641,7 @@ export interface RootRouteChildren { ApiChatRoute: typeof ApiChatRoute ApiImageRoute: typeof ApiImageRouteWithChildren ApiLazyToolsWireRoute: typeof ApiLazyToolsWireRoute + ApiMaxToolCallsWireRoute: typeof ApiMaxToolCallsWireRoute ApiMcpAppsCallRoute: typeof ApiMcpAppsCallRoute ApiMcpAppsChatRoute: typeof ApiMcpAppsChatRoute ApiMcpAppsServerRoute: typeof ApiMcpAppsServerRoute @@ -894,6 +907,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiMcpAppsCallRouteImport parentRoute: typeof rootRouteImport } + '/api/max-tool-calls-wire': { + id: '/api/max-tool-calls-wire' + path: '/api/max-tool-calls-wire' + fullPath: '/api/max-tool-calls-wire' + preLoaderRoute: typeof ApiMaxToolCallsWireRouteImport + parentRoute: typeof rootRouteImport + } '/api/lazy-tools-wire': { id: '/api/lazy-tools-wire' path: '/api/lazy-tools-wire' @@ -1074,6 +1094,7 @@ const rootRouteChildren: RootRouteChildren = { ApiChatRoute: ApiChatRoute, ApiImageRoute: ApiImageRouteWithChildren, ApiLazyToolsWireRoute: ApiLazyToolsWireRoute, + ApiMaxToolCallsWireRoute: ApiMaxToolCallsWireRoute, ApiMcpAppsCallRoute: ApiMcpAppsCallRoute, ApiMcpAppsChatRoute: ApiMcpAppsChatRoute, ApiMcpAppsServerRoute: ApiMcpAppsServerRoute, diff --git a/testing/e2e/src/routes/api.max-tool-calls-wire.ts b/testing/e2e/src/routes/api.max-tool-calls-wire.ts new file mode 100644 index 000000000..d2354dd4d --- /dev/null +++ b/testing/e2e/src/routes/api.max-tool-calls-wire.ts @@ -0,0 +1,179 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + EventType, + chat, + combineStrategies, + createChatOptions, + maxIterations, + maxToolCalls, + toolDefinition, +} from '@tanstack/ai' +import { z } from 'zod' +import type { AnyTextAdapter, StreamChunk } from '@tanstack/ai' + +/** + * Wire-format regression for issue #964. + * + * `maxIterations` counts model turns, not tool calls. A single turn can emit + * unbounded parallel tool calls. This route emits a fat parallel turn (8 + * calls) and asserts: + * 1. `maxToolCallsPerTurn` caps how many execute + * 2. `maxToolCalls` stops further model turns once the cumulative count hits + * 3. Skipped calls still get error tool results (message history stays consistent) + */ +function createFatParallelAdapter(callCount: number): AnyTextAdapter { + return { + kind: 'text', + name: 'max-tool-calls-test', + model: 'max-tool-calls-test', + '~types': { + providerOptions: {}, + inputModalities: ['text'], + messageMetadataByModality: {}, + toolCapabilities: [], + toolCallMetadata: undefined, + systemPromptMetadata: undefined, + }, + async *chatStream(options): AsyncGenerator { + const model = 'max-tool-calls-test' + const runId = options.runId ?? 'max-tool-calls-run' + const threadId = options.threadId ?? 'max-tool-calls-thread' + const messageId = `${runId}-message` + const hasToolResult = options.messages.some((m) => m.role === 'tool') + + yield { + type: EventType.RUN_STARTED, + runId, + threadId, + model, + timestamp: Date.now(), + } + + // Later iterations: answer with text once any tool results are present. + if (hasToolResult) { + yield { + type: EventType.TEXT_MESSAGE_START, + messageId, + role: 'assistant', + model, + timestamp: Date.now(), + } + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta: 'done', + model, + timestamp: Date.now(), + } + yield { + type: EventType.TEXT_MESSAGE_END, + messageId, + model, + timestamp: Date.now(), + } + yield { + type: EventType.RUN_FINISHED, + runId, + threadId, + model, + finishReason: 'stop', + timestamp: Date.now(), + } + return + } + + // First iteration: emit many parallel tool calls in one turn. + for (let i = 0; i < callCount; i++) { + const toolCallId = `call_${i}` + yield { + type: EventType.TOOL_CALL_START, + toolCallId, + toolCallName: 'ping', + toolName: 'ping', + index: i, + model, + timestamp: Date.now(), + } + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId, + delta: `{"n":${i}}`, + model, + timestamp: Date.now(), + } + yield { + type: EventType.TOOL_CALL_END, + toolCallId, + toolCallName: 'ping', + toolName: 'ping', + input: { n: i }, + model, + timestamp: Date.now(), + } + } + + yield { + type: EventType.RUN_FINISHED, + runId, + threadId, + model, + finishReason: 'tool_calls', + timestamp: Date.now(), + } + }, + structuredOutput: async () => ({ data: {}, rawText: '{}' }), + } +} + +export const Route = createFileRoute('/api/max-tool-calls-wire')({ + server: { + handlers: { + POST: async () => { + let executeCount = 0 + const ping = toolDefinition({ + name: 'ping', + description: 'Ping tool for fan-out budget tests', + inputSchema: z.object({ n: z.number() }), + }).server(async ({ n }) => { + executeCount++ + return { n, ok: true } + }) + + const chunks: Array = [] + let error: string | null = null + try { + for await (const chunk of chat({ + ...createChatOptions({ + adapter: createFatParallelAdapter(8), + }), + tools: [ping], + messages: [{ role: 'user', content: 'Call ping many times' }], + // Cap execution fan-out inside the fat turn + maxToolCallsPerTurn: 3, + // Cumulative tool-call budget: after the first turn (8 emitted), + // toolCallCount >= 5 so the strategy must not request another model + // turn that would keep looping. We also allow maxIterations high + // enough that only maxToolCalls can be the stopping reason. + agentLoopStrategy: combineStrategies([ + maxIterations(10), + maxToolCalls(5), + ]), + })) { + chunks.push(chunk) + } + } catch (err) { + error = err instanceof Error ? err.message : String(err) + } + + return new Response( + JSON.stringify({ + chunks, + error, + executeCount, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) + }, + }, + }, +}) diff --git a/testing/e2e/tests/max-tool-calls.spec.ts b/testing/e2e/tests/max-tool-calls.spec.ts new file mode 100644 index 000000000..947d8b001 --- /dev/null +++ b/testing/e2e/tests/max-tool-calls.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test' + +/** + * Regression for issue #964: maxIterations counts model turns, not tool calls. + * maxToolCalls + maxToolCallsPerTurn must bound fan-out from a single fat turn. + */ +test.describe('chat() maxToolCalls / maxToolCallsPerTurn (#964)', () => { + test('caps parallel fan-out and cumulative tool calls', async ({ + request, + }) => { + const res = await request.post('/api/max-tool-calls-wire') + expect(res.ok()).toBe(true) + + const body = (await res.json()) as { + chunks: Array> + error: string | null + executeCount: number + } + + expect(body.error).toBeNull() + + // 8 parallel calls emitted; maxToolCallsPerTurn=3 → only 3 execute + expect(body.executeCount).toBe(3) + + const toolResults = body.chunks.filter((c) => c.type === 'TOOL_CALL_RESULT') + // Every model-emitted call still gets a tool result (3 real + 5 skipped) + expect(toolResults.length).toBe(8) + + const skipped = toolResults.filter((c) => { + const content = c.content + return ( + typeof content === 'string' && + content.includes('exceeded maxToolCallsPerTurn') + ) + }) + expect(skipped.length).toBe(5) + + // maxToolCalls(5): after the fat turn toolCallCount=8 (>= 5), so no further + // model turn runs. executeCount stays at 3 either way. + const runFinished = body.chunks.filter((c) => c.type === 'RUN_FINISHED') + expect(runFinished.length).toBe(1) + }) +})