@tanstack/ai 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,7 +16,7 @@ export { ToolCallManager } from './activities/chat/tools/tool-calls.js';
16
16
  export { DISCOVERY_TOOL_NAME } from './activities/chat/tools/lazy-tool-manager.js';
17
17
  export type { ProviderTool } from './tools/provider-tool.js';
18
18
  export { brandProviderTool } from './tools/provider-tool.js';
19
- export { maxIterations, untilFinishReason, combineStrategies, } from './activities/chat/agent-loop-strategies.js';
19
+ export { maxIterations, maxToolCalls, untilFinishReason, combineStrategies, } from './activities/chat/agent-loop-strategies.js';
20
20
  export { createToolRegistry, createFrozenRegistry, type ToolRegistry, } from './tool-registry.js';
21
21
  export type { ChatMiddleware, ChatMiddlewareContext, ChatMiddlewarePhase, ChatMiddlewareConfig, StructuredOutputMiddlewareConfig, ToolCallHookContext, BeforeToolCallDecision, AfterToolCallInfo, IterationInfo, ToolPhaseCompleteInfo, UsageInfo, FinishInfo, AbortInfo, ErrorInfo, SandboxFileEvent, SandboxFileHookEvent, ChatSandboxHooks, } from './activities/chat/middleware/index.js';
22
22
  export type { GenerationMiddleware, GenerationMiddlewareContext, GenerationActivity, GenerationUsageInfo, GenerationFinishInfo, GenerationAbortInfo, GenerationErrorInfo, AnyGenerationMiddleware, } from './activities/middleware/index.js';
package/dist/esm/index.js CHANGED
@@ -12,7 +12,7 @@ import { streamToText, toHttpResponse, toHttpStream, toServerSentEventsResponse,
12
12
  import { ToolCallManager } from "./activities/chat/tools/tool-calls.js";
13
13
  import { DISCOVERY_TOOL_NAME } from "./activities/chat/tools/lazy-tool-manager.js";
14
14
  import { brandProviderTool } from "./tools/provider-tool.js";
15
- import { combineStrategies, maxIterations, untilFinishReason } from "./activities/chat/agent-loop-strategies.js";
15
+ import { combineStrategies, maxIterations, maxToolCalls, untilFinishReason } from "./activities/chat/agent-loop-strategies.js";
16
16
  import { createFrozenRegistry, createToolRegistry } from "./tool-registry.js";
17
17
  import { firstSentence, renderLazyCatalogEntry } from "./activities/chat/tools/lazy-tools.js";
18
18
  import { buildBaseUsage } from "./utilities/usage.js";
@@ -89,6 +89,7 @@ export {
89
89
  isProviderExecutedToolCall,
90
90
  isStandardSchema,
91
91
  maxIterations,
92
+ maxToolCalls,
92
93
  mergeAgentTools,
93
94
  modelMessageToUIMessage,
94
95
  modelMessagesToUIMessages,
@@ -654,12 +654,24 @@ export interface ResponseFormat<TData = any> {
654
654
  * State passed to agent loop strategy for determining whether to continue
655
655
  */
656
656
  export interface AgentLoopState {
657
- /** Current iteration count (0-indexed) */
657
+ /** Current iteration count (0-indexed). One iteration = one model turn. */
658
658
  iterationCount: number;
659
659
  /** Current messages array */
660
660
  messages: Array<ModelMessage>;
661
661
  /** Finish reason from the last response */
662
662
  finishReason: string | null;
663
+ /**
664
+ * Cumulative tool calls counted so far in this run (model-emitted during the
665
+ * agent loop, including ones skipped by `maxToolCallsPerTurn`, and pending
666
+ * tools from the inbound message list when resumed). Not a recount of full
667
+ * message history; not model turns.
668
+ */
669
+ toolCallCount: number;
670
+ /**
671
+ * Tool calls in the most recent budgeted batch — a live model turn or a
672
+ * pending/resume batch (0 when the last phase produced no tool calls).
673
+ */
674
+ lastTurnToolCallCount: number;
663
675
  }
664
676
  /**
665
677
  * Strategy function that determines whether the agent loop should continue
@@ -669,8 +681,10 @@ export interface AgentLoopState {
669
681
  *
670
682
  * @example
671
683
  * ```typescript
672
- * // Continue for up to 5 iterations
684
+ * // Continue for up to 5 iterations (model turns, not tool calls)
673
685
  * const strategy: AgentLoopStrategy = ({ iterationCount }) => iterationCount < 5;
686
+ * // Cap total tool calls across the run
687
+ * const byTools: AgentLoopStrategy = ({ toolCallCount }) => toolCallCount < 20;
674
688
  * ```
675
689
  */
676
690
  export type AgentLoopStrategy = (state: AgentLoopState) => boolean;
@@ -702,6 +716,24 @@ export interface TextOptions<TProviderOptionsSuperset extends Record<string, any
702
716
  */
703
717
  systemPrompts?: Array<SystemPrompt>;
704
718
  agentLoopStrategy?: AgentLoopStrategy;
719
+ /**
720
+ * Maximum number of tool calls to **execute** from a single model turn (or
721
+ * pending/resume batch). `0` skips all execution for that batch.
722
+ *
723
+ * Models can emit many parallel tool calls in one turn. `agentLoopStrategy`
724
+ * (including `maxIterations` / `maxToolCalls`) is only evaluated between
725
+ * turns, so without this cap a single runaway turn can still execute an
726
+ * unbounded fan-out.
727
+ *
728
+ * When set, only the first `maxToolCallsPerTurn` calls are executed; the
729
+ * remainder receive error tool results so the message history stays
730
+ * consistent. Unset means no per-turn execution cap. Must be a non-negative
731
+ * finite number when set.
732
+ *
733
+ * Pair with the `maxToolCalls(n)` strategy for a cumulative **emitted**-call
734
+ * budget across the run (skipped calls still count toward that budget).
735
+ */
736
+ maxToolCallsPerTurn?: number;
705
737
  /**
706
738
  * Optional configuration for lazy-tool discovery (tools marked `lazy: true`).
707
739
  * Tunes how much of each lazy tool's description appears in the discovery
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -409,6 +409,64 @@ export const Route = createFileRoute('/api/chat')({
409
409
  })
410
410
  ```
411
411
 
412
+ ### 7. Queueing Messages Sent While Streaming
413
+
414
+ By default, a `sendMessage` call that arrives while a stream is in flight is
415
+ **queued** and sent automatically once the run settles **successfully** —
416
+ this is a behavior change: such sends used to be silently dropped. Configure
417
+ it with the `queue` option on `useChat`:
418
+
419
+ ```typescript
420
+ import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
421
+
422
+ const { messages, queue, sendMessage, cancelQueued, isLoading } = useChat({
423
+ connection: fetchServerSentEvents('/api/chat'),
424
+ queue: { whenBusy: 'queue', drain: 'fifo', maxSize: 5, onOverflow: 'reject' },
425
+ })
426
+ ```
427
+
428
+ - **`whenBusy`** — `'queue'` (default) holds the message until a successful
429
+ settle; `'drop'` ignores the send (never appears in `queue`/`messages`);
430
+ `'interrupt'` aborts the current stream and sends immediately (unlike
431
+ `stop()`, does **not** flush already-queued items — they drain after the
432
+ interrupting send **succeeds**).
433
+ - **`drain`** — `'fifo'` (default) sends queued items one at a time in
434
+ order; `'batch'` merges everything queued into a single send once the
435
+ run settles successfully.
436
+ - **`maxSize`** / **`onOverflow`** — cap the queue length; `'reject'`
437
+ (default) silently ignores overflow sends (does not throw),
438
+ `'drop-oldest'` evicts the oldest queued item to make room.
439
+
440
+ The top-level `queue` option also accepts a plain `WhenBusy` string
441
+ shorthand (e.g. `queue: 'interrupt'`) or a `QueueStrategy` function for
442
+ per-send action control. Strategy form always drains FIFO; actions are
443
+ `'queue' | 'drop' | 'interrupt'`.
444
+
445
+ **Drain vs flush:** queued messages auto-send only after a **successful**
446
+ settle. They are **discarded** on stream error/abort of the active
447
+ generation, `stop()`, `clear()`, `unsubscribe()`, and `reload()`.
448
+ `interrupt` does not flush.
449
+
450
+ `queue: Array<QueuedMessage>` (`{ id, content, createdAt }`) is separate
451
+ from `messages` — render pending sends distinctly and cancel with
452
+ `cancelQueued(id)`:
453
+
454
+ ```typescript
455
+ {queue.map((q) => (
456
+ <div key={q.id}>
457
+ {typeof q.content === 'string' ? q.content : '[attachment]'}
458
+ <button onClick={() => cancelQueued(q.id)}>Cancel</button>
459
+ </div>
460
+ ))}
461
+ ```
462
+
463
+ Override the configured policy for a single send with the second argument
464
+ to `sendMessage`:
465
+
466
+ ```typescript
467
+ sendMessage('Never mind, do this instead', { whenBusy: 'interrupt' })
468
+ ```
469
+
412
470
  ## Common Mistakes
413
471
 
414
472
  ### a. CRITICAL: Using Vercel AI SDK patterns (streamText, generateText)
@@ -375,6 +375,8 @@ export async function POST(request: Request) {
375
375
  adapter: openaiText('gpt-5.5'),
376
376
  messages,
377
377
  tools: [getProducts, compareProducts],
378
+ // maxIterations bounds model turns, not tool calls. Prefer maxToolCalls
379
+ // (and maxToolCallsPerTurn) when you need a tool-call budget.
378
380
  agentLoopStrategy: maxIterations(20),
379
381
  })
380
382
  return toServerSentEventsResponse(stream)
@@ -1,9 +1,14 @@
1
1
  import type { AgentLoopStrategy } from '../../types'
2
2
 
3
3
  /**
4
- * Creates a strategy that continues for a maximum number of iterations
4
+ * Creates a strategy that continues for a maximum number of **model turns**
5
+ * (iterations), not tool calls.
5
6
  *
6
- * @param max - Maximum number of iterations to allow
7
+ * One iteration can still emit many parallel tool calls. Prefer
8
+ * {@link maxToolCalls} (and optionally `maxToolCallsPerTurn` on `chat()`)
9
+ * when you need a tool-call budget.
10
+ *
11
+ * @param max - Maximum number of model turns to allow
7
12
  * @returns AgentLoopStrategy that stops after max iterations
8
13
  *
9
14
  * @example
@@ -13,7 +18,7 @@ import type { AgentLoopStrategy } from '../../types'
13
18
  * model: "gpt-4o",
14
19
  * messages: [...],
15
20
  * tools: [weatherTool],
16
- * agentLoopStrategy: maxIterations(3), // Max 3 iterations
21
+ * agentLoopStrategy: maxIterations(3), // Max 3 model turns
17
22
  * });
18
23
  * ```
19
24
  */
@@ -21,6 +26,40 @@ export function maxIterations(max: number): AgentLoopStrategy {
21
26
  return ({ iterationCount }) => iterationCount < max
22
27
  }
23
28
 
29
+ /**
30
+ * Creates a strategy that continues while `toolCallCount < max`.
31
+ *
32
+ * Unlike {@link maxIterations} (which counts model turns), this bounds
33
+ * **emitted** tool calls counted during the run (including ones skipped by
34
+ * `maxToolCallsPerTurn`). Strategies only run between turns, so the turn that
35
+ * crosses `max` is not truncated — the final count (and executions, unless
36
+ * `maxToolCallsPerTurn` is set) may exceed `max`. Pair with
37
+ * `chat({ maxToolCallsPerTurn })` to also cap parallel fan-out inside a single
38
+ * turn.
39
+ *
40
+ * @param max - Maximum cumulative emitted tool calls before stopping further turns
41
+ * @returns AgentLoopStrategy that returns true while `toolCallCount < max`
42
+ *
43
+ * @example
44
+ * ```typescript
45
+ * import { chat, combineStrategies, maxIterations, maxToolCalls } from '@tanstack/ai'
46
+ *
47
+ * const stream = chat({
48
+ * adapter: openaiText('gpt-4o'),
49
+ * messages: [...],
50
+ * tools: [weatherTool],
51
+ * maxToolCallsPerTurn: 10,
52
+ * agentLoopStrategy: combineStrategies([
53
+ * maxIterations(20),
54
+ * maxToolCalls(20),
55
+ * ]),
56
+ * })
57
+ * ```
58
+ */
59
+ export function maxToolCalls(max: number): AgentLoopStrategy {
60
+ return ({ toolCallCount }) => toolCallCount < max
61
+ }
62
+
24
63
  /**
25
64
  * Creates a strategy that continues until a specific finish reason is encountered
26
65
  *
@@ -71,6 +110,7 @@ export function untilFinishReason(
71
110
  * tools: [weatherTool],
72
111
  * agentLoopStrategy: combineStrategies([
73
112
  * maxIterations(10),
113
+ * maxToolCalls(20),
74
114
  * ({ messages }) => messages.length < 100,
75
115
  * ]),
76
116
  * });
@@ -240,6 +240,11 @@ export interface TextActivityOptions<
240
240
  abortController?: TextOptions['abortController']
241
241
  /** Strategy for controlling the agent loop */
242
242
  agentLoopStrategy?: TextOptions['agentLoopStrategy']
243
+ /**
244
+ * Cap how many tool calls from a single model turn are executed.
245
+ * Excess calls receive error results. See {@link TextOptions.maxToolCallsPerTurn}.
246
+ */
247
+ maxToolCallsPerTurn?: TextOptions['maxToolCallsPerTurn']
243
248
  /**
244
249
  * Optional configuration for lazy-tool discovery (tools marked `lazy: true`).
245
250
  * Tunes how much of each lazy tool's description appears in the discovery
@@ -473,6 +478,23 @@ interface TextEngineConfig<
473
478
  type ToolPhaseResult = 'continue' | 'stop' | 'wait'
474
479
  type CyclePhase = 'processText' | 'executeToolCalls'
475
480
 
481
+ /**
482
+ * Validate and normalize `maxToolCallsPerTurn`.
483
+ * Unset → unlimited. `0` → execute none. Negatives / non-finite → throw
484
+ * (Array#slice treats negatives as "from end", which is not a useful cap).
485
+ */
486
+ function resolveMaxToolCallsPerTurn(
487
+ cap: number | undefined,
488
+ ): number | undefined {
489
+ if (cap == null) return undefined
490
+ if (!Number.isFinite(cap) || cap < 0) {
491
+ throw new Error(
492
+ `maxToolCallsPerTurn must be a non-negative finite number, got ${cap}`,
493
+ )
494
+ }
495
+ return Math.floor(cap)
496
+ }
497
+
476
498
  /**
477
499
  * Combine two optional AbortSignals into one that aborts when either does.
478
500
  * Returns the other signal directly when one is absent or already aborted.
@@ -519,6 +541,12 @@ class TextEngine<
519
541
 
520
542
  private messages: Array<ModelMessage>
521
543
  private iterationCount = 0
544
+ /** Cumulative tool calls counted in this run (emitted + pending resume). */
545
+ private toolCallCount = 0
546
+ /** Tool calls in the most recent budgeted batch (0 when none). */
547
+ private lastTurnToolCallCount = 0
548
+ /** Tool call IDs already counted toward `toolCallCount` (avoids double-count on resume). */
549
+ private readonly countedToolCallIds = new Set<string>()
522
550
  private lastFinishReason: string | null = null
523
551
  private streamStartTime = 0
524
552
  private totalChunkCount = 0
@@ -534,6 +562,7 @@ class TextEngine<
534
562
  private earlyTermination = false
535
563
  private toolPhase: ToolPhaseResult = 'continue'
536
564
  private cyclePhase: CyclePhase = 'processText'
565
+ private readonly maxToolCallsPerTurn: number | undefined
537
566
  // Client state extracted from initial messages (before conversion to ModelMessage)
538
567
  private readonly initialApprovals: Map<string, boolean>
539
568
  private readonly initialClientToolResults: Map<string, any>
@@ -599,6 +628,9 @@ class TextEngine<
599
628
  this.systemPrompts = config.params.systemPrompts || []
600
629
  this.loopStrategy =
601
630
  config.params.agentLoopStrategy || maxIterationsStrategy(5)
631
+ this.maxToolCallsPerTurn = resolveMaxToolCallsPerTurn(
632
+ config.params.maxToolCallsPerTurn,
633
+ )
602
634
  this.initialMessageCount = config.params.messages.length
603
635
 
604
636
  // Extract client state (approvals, client tool results) from original messages BEFORE conversion
@@ -1302,9 +1334,14 @@ class TextEngine<
1302
1334
 
1303
1335
  const finishEvent = this.createSyntheticFinishedEvent()
1304
1336
 
1337
+ // Same fan-out budget as live model turns (seeded history / resume).
1338
+ // Count is deduped so wait→resume after a live turn does not double-count.
1339
+ const { toExecute: budgetedToolCalls, skippedResults } =
1340
+ this.applyToolCallBudget(pendingToolCalls)
1341
+
1305
1342
  // Handle undiscovered lazy tool calls with self-correcting error messages
1306
1343
  const undiscoveredLazyResults: Array<ToolResult> = []
1307
- const executablePendingCalls = pendingToolCalls.filter((tc) => {
1344
+ const executablePendingCalls = budgetedToolCalls.filter((tc) => {
1308
1345
  if (this.lazyToolManager.isUndiscoveredLazyTool(tc.function.name)) {
1309
1346
  undiscoveredLazyResults.push({
1310
1347
  toolCallId: tc.id,
@@ -1321,16 +1358,27 @@ class TextEngine<
1321
1358
  return true
1322
1359
  })
1323
1360
 
1324
- if (undiscoveredLazyResults.length > 0) {
1325
- for (const chunk of this.buildToolResultChunks(
1326
- undiscoveredLazyResults,
1327
- finishEvent,
1328
- )) {
1329
- yield* this.pipeThroughMiddleware(chunk)
1330
- }
1361
+ // Non-executed outcomes (undiscovered lazy + per-turn fan-out skips).
1362
+ // Emitted after executed results so the stream prefers real results first.
1363
+ const deferredErrorResults = [...undiscoveredLazyResults, ...skippedResults]
1364
+
1365
+ // Build args lookup so buildToolResultChunks can emit TOOL_CALL_START +
1366
+ // TOOL_CALL_ARGS before TOOL_CALL_END during continuation re-executions.
1367
+ const argsMap = new Map<string, string>()
1368
+ for (const tc of pendingToolCalls) {
1369
+ argsMap.set(tc.id, tc.function.arguments)
1331
1370
  }
1332
1371
 
1333
1372
  if (executablePendingCalls.length === 0) {
1373
+ if (deferredErrorResults.length > 0) {
1374
+ for (const chunk of this.buildToolResultChunks(
1375
+ deferredErrorResults,
1376
+ finishEvent,
1377
+ argsMap,
1378
+ )) {
1379
+ yield* this.pipeThroughMiddleware(chunk)
1380
+ }
1381
+ }
1334
1382
  return 'continue'
1335
1383
  }
1336
1384
 
@@ -1384,28 +1432,23 @@ class TextEngine<
1384
1432
  return 'stop'
1385
1433
  }
1386
1434
 
1435
+ const allResults = [...executionResult.results, ...deferredErrorResults]
1436
+
1387
1437
  // Notify middleware of tool phase completion (devtools emits aggregate events here)
1388
1438
  await this.middlewareRunner.runOnToolPhaseComplete(this.middlewareCtx, {
1389
1439
  toolCalls: pendingToolCalls,
1390
- results: executionResult.results,
1440
+ results: allResults,
1391
1441
  needsApproval: executionResult.needsApproval,
1392
1442
  needsClientExecution: executionResult.needsClientExecution,
1393
1443
  })
1394
1444
 
1395
- // Build args lookup so buildToolResultChunks can emit TOOL_CALL_START +
1396
- // TOOL_CALL_ARGS before TOOL_CALL_END during continuation re-executions.
1397
- const argsMap = new Map<string, string>()
1398
- for (const tc of pendingToolCalls) {
1399
- argsMap.set(tc.id, tc.function.arguments)
1400
- }
1401
-
1402
1445
  if (
1403
1446
  executionResult.needsApproval.length > 0 ||
1404
1447
  executionResult.needsClientExecution.length > 0
1405
1448
  ) {
1406
- if (executionResult.results.length > 0) {
1449
+ if (allResults.length > 0) {
1407
1450
  for (const chunk of this.buildToolResultChunks(
1408
- executionResult.results,
1451
+ allResults,
1409
1452
  finishEvent,
1410
1453
  argsMap,
1411
1454
  )) {
@@ -1432,7 +1475,7 @@ class TextEngine<
1432
1475
  }
1433
1476
 
1434
1477
  const toolResultChunks = this.buildToolResultChunks(
1435
- executionResult.results,
1478
+ allResults,
1436
1479
  finishEvent,
1437
1480
  argsMap,
1438
1481
  )
@@ -1446,6 +1489,8 @@ class TextEngine<
1446
1489
 
1447
1490
  private async *processToolCalls(): AsyncGenerator<StreamChunk, void, void> {
1448
1491
  if (!this.shouldExecuteToolPhase()) {
1492
+ // Text-only turn — clear per-turn count so strategies see 0 tools.
1493
+ this.lastTurnToolCallCount = 0
1449
1494
  this.setToolPhase('stop')
1450
1495
  return
1451
1496
  }
@@ -1454,15 +1499,20 @@ class TextEngine<
1454
1499
  const finishEvent = this.finishedEvent
1455
1500
 
1456
1501
  if (!finishEvent || toolCalls.length === 0) {
1502
+ this.lastTurnToolCallCount = 0
1457
1503
  this.setToolPhase('stop')
1458
1504
  return
1459
1505
  }
1460
1506
 
1507
+ // Count every model-emitted tool call (including ones we may skip).
1508
+ const { toExecute: budgetedToolCalls, skippedResults } =
1509
+ this.applyToolCallBudget(toolCalls)
1510
+
1461
1511
  this.addAssistantToolCallMessage(toolCalls)
1462
1512
 
1463
1513
  // Handle undiscovered lazy tool calls with self-correcting error messages
1464
1514
  const undiscoveredLazyResults: Array<ToolResult> = []
1465
- const executableToolCalls = toolCalls.filter((tc) => {
1515
+ const executableToolCalls = budgetedToolCalls.filter((tc) => {
1466
1516
  if (this.lazyToolManager.isUndiscoveredLazyTool(tc.function.name)) {
1467
1517
  undiscoveredLazyResults.push({
1468
1518
  toolCallId: tc.id,
@@ -1479,17 +1529,21 @@ class TextEngine<
1479
1529
  return true
1480
1530
  })
1481
1531
 
1482
- if (undiscoveredLazyResults.length > 0 && this.finishedEvent) {
1483
- for (const chunk of this.buildToolResultChunks(
1484
- undiscoveredLazyResults,
1485
- this.finishedEvent,
1486
- )) {
1487
- yield* this.pipeThroughMiddleware(chunk)
1488
- }
1489
- }
1532
+ // Non-executed outcomes (undiscovered lazy + per-turn fan-out skips).
1533
+ // Emitted after executed results so the stream prefers real results first.
1534
+ const deferredErrorResults = [...undiscoveredLazyResults, ...skippedResults]
1490
1535
 
1491
1536
  if (executableToolCalls.length === 0) {
1492
- // All tool calls were undiscovered lazy tools — errors emitted, continue loop
1537
+ // All tool calls were undiscovered lazy tools and/or skipped by the
1538
+ // per-turn fan-out cap — errors emitted, continue loop (strategy may stop).
1539
+ if (deferredErrorResults.length > 0) {
1540
+ for (const chunk of this.buildToolResultChunks(
1541
+ deferredErrorResults,
1542
+ finishEvent,
1543
+ )) {
1544
+ yield* this.pipeThroughMiddleware(chunk)
1545
+ }
1546
+ }
1493
1547
  this.toolCallManager.clear()
1494
1548
  this.setToolPhase('continue')
1495
1549
  return
@@ -1548,10 +1602,13 @@ class TextEngine<
1548
1602
  return
1549
1603
  }
1550
1604
 
1605
+ // Executed results first, then deferred errors (fan-out skips / undiscovered)
1606
+ const allResults = [...executionResult.results, ...deferredErrorResults]
1607
+
1551
1608
  // Notify middleware of tool phase completion (devtools emits aggregate events here)
1552
1609
  await this.middlewareRunner.runOnToolPhaseComplete(this.middlewareCtx, {
1553
1610
  toolCalls,
1554
- results: executionResult.results,
1611
+ results: allResults,
1555
1612
  needsApproval: executionResult.needsApproval,
1556
1613
  needsClientExecution: executionResult.needsClientExecution,
1557
1614
  })
@@ -1560,9 +1617,9 @@ class TextEngine<
1560
1617
  executionResult.needsApproval.length > 0 ||
1561
1618
  executionResult.needsClientExecution.length > 0
1562
1619
  ) {
1563
- if (executionResult.results.length > 0) {
1620
+ if (allResults.length > 0) {
1564
1621
  for (const chunk of this.buildToolResultChunks(
1565
- executionResult.results,
1622
+ allResults,
1566
1623
  finishEvent,
1567
1624
  )) {
1568
1625
  yield* this.pipeThroughMiddleware(chunk)
@@ -1587,10 +1644,7 @@ class TextEngine<
1587
1644
  return
1588
1645
  }
1589
1646
 
1590
- const toolResultChunks = this.buildToolResultChunks(
1591
- executionResult.results,
1592
- finishEvent,
1593
- )
1647
+ const toolResultChunks = this.buildToolResultChunks(allResults, finishEvent)
1594
1648
 
1595
1649
  for (const chunk of toolResultChunks) {
1596
1650
  yield* this.pipeThroughMiddleware(chunk)
@@ -1943,10 +1997,64 @@ class TextEngine<
1943
1997
  iterationCount: this.iterationCount,
1944
1998
  messages: this.messages,
1945
1999
  finishReason: this.lastFinishReason,
2000
+ toolCallCount: this.toolCallCount,
2001
+ lastTurnToolCallCount: this.lastTurnToolCallCount,
1946
2002
  }) && this.toolPhase === 'continue'
1947
2003
  )
1948
2004
  }
1949
2005
 
2006
+ /**
2007
+ * Record tool calls (deduped by id) and return the subset that should be
2008
+ * executed after applying `maxToolCallsPerTurn`. Excess calls get synthetic
2009
+ * error results so every tool_call still has a matching result.
2010
+ *
2011
+ * Used for both live model turns and pending/resume batches. IDs already
2012
+ * counted in this run (e.g. wait→resume after a live turn) are not
2013
+ * re-added to `toolCallCount`.
2014
+ */
2015
+ private applyToolCallBudget(toolCalls: Array<ToolCall>): {
2016
+ toExecute: Array<ToolCall>
2017
+ skippedResults: Array<ToolResult>
2018
+ } {
2019
+ this.lastTurnToolCallCount = toolCalls.length
2020
+ let newlyCounted = 0
2021
+ for (const tc of toolCalls) {
2022
+ if (!this.countedToolCallIds.has(tc.id)) {
2023
+ this.countedToolCallIds.add(tc.id)
2024
+ newlyCounted++
2025
+ }
2026
+ }
2027
+ this.toolCallCount += newlyCounted
2028
+
2029
+ const cap = this.maxToolCallsPerTurn
2030
+ if (cap == null || toolCalls.length <= cap) {
2031
+ return { toExecute: toolCalls, skippedResults: [] }
2032
+ }
2033
+
2034
+ this.logger.agentLoop(
2035
+ `maxToolCallsPerTurn=${cap} skipped=${toolCalls.length - cap}`,
2036
+ {
2037
+ maxToolCallsPerTurn: cap,
2038
+ emitted: toolCalls.length,
2039
+ skipped: toolCalls.length - cap,
2040
+ },
2041
+ )
2042
+
2043
+ const toExecute = toolCalls.slice(0, cap)
2044
+ const skippedResults: Array<ToolResult> = toolCalls
2045
+ .slice(cap)
2046
+ .map((tc) => ({
2047
+ toolCallId: tc.id,
2048
+ toolName: tc.function.name,
2049
+ result: {
2050
+ error: `Skipped: exceeded maxToolCallsPerTurn (${cap})`,
2051
+ },
2052
+ state: 'output-error' as const,
2053
+ }))
2054
+
2055
+ return { toExecute, skippedResults }
2056
+ }
2057
+
1950
2058
  private isAborted(): boolean {
1951
2059
  return !!this.effectiveSignal?.aborted
1952
2060
  }
package/src/index.ts CHANGED
@@ -93,6 +93,7 @@ export { brandProviderTool } from './tools/provider-tool'
93
93
  // Agent loop strategies
94
94
  export {
95
95
  maxIterations,
96
+ maxToolCalls,
96
97
  untilFinishReason,
97
98
  combineStrategies,
98
99
  } from './activities/chat/agent-loop-strategies'
package/src/types.ts CHANGED
@@ -831,12 +831,24 @@ export interface ResponseFormat<TData = any> {
831
831
  * State passed to agent loop strategy for determining whether to continue
832
832
  */
833
833
  export interface AgentLoopState {
834
- /** Current iteration count (0-indexed) */
834
+ /** Current iteration count (0-indexed). One iteration = one model turn. */
835
835
  iterationCount: number
836
836
  /** Current messages array */
837
837
  messages: Array<ModelMessage>
838
838
  /** Finish reason from the last response */
839
839
  finishReason: string | null
840
+ /**
841
+ * Cumulative tool calls counted so far in this run (model-emitted during the
842
+ * agent loop, including ones skipped by `maxToolCallsPerTurn`, and pending
843
+ * tools from the inbound message list when resumed). Not a recount of full
844
+ * message history; not model turns.
845
+ */
846
+ toolCallCount: number
847
+ /**
848
+ * Tool calls in the most recent budgeted batch — a live model turn or a
849
+ * pending/resume batch (0 when the last phase produced no tool calls).
850
+ */
851
+ lastTurnToolCallCount: number
840
852
  }
841
853
 
842
854
  /**
@@ -847,8 +859,10 @@ export interface AgentLoopState {
847
859
  *
848
860
  * @example
849
861
  * ```typescript
850
- * // Continue for up to 5 iterations
862
+ * // Continue for up to 5 iterations (model turns, not tool calls)
851
863
  * const strategy: AgentLoopStrategy = ({ iterationCount }) => iterationCount < 5;
864
+ * // Cap total tool calls across the run
865
+ * const byTools: AgentLoopStrategy = ({ toolCallCount }) => toolCallCount < 20;
852
866
  * ```
853
867
  */
854
868
  export type AgentLoopStrategy = (state: AgentLoopState) => boolean
@@ -885,6 +899,24 @@ export interface TextOptions<
885
899
  */
886
900
  systemPrompts?: Array<SystemPrompt>
887
901
  agentLoopStrategy?: AgentLoopStrategy
902
+ /**
903
+ * Maximum number of tool calls to **execute** from a single model turn (or
904
+ * pending/resume batch). `0` skips all execution for that batch.
905
+ *
906
+ * Models can emit many parallel tool calls in one turn. `agentLoopStrategy`
907
+ * (including `maxIterations` / `maxToolCalls`) is only evaluated between
908
+ * turns, so without this cap a single runaway turn can still execute an
909
+ * unbounded fan-out.
910
+ *
911
+ * When set, only the first `maxToolCallsPerTurn` calls are executed; the
912
+ * remainder receive error tool results so the message history stays
913
+ * consistent. Unset means no per-turn execution cap. Must be a non-negative
914
+ * finite number when set.
915
+ *
916
+ * Pair with the `maxToolCalls(n)` strategy for a cumulative **emitted**-call
917
+ * budget across the run (skipped calls still count toward that budget).
918
+ */
919
+ maxToolCallsPerTurn?: number
888
920
  /**
889
921
  * Optional configuration for lazy-tool discovery (tools marked `lazy: true`).
890
922
  * Tunes how much of each lazy tool's description appears in the discovery