@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.
@@ -1,8 +1,13 @@
1
1
  import { AgentLoopStrategy } from '../../types.js';
2
2
  /**
3
- * Creates a strategy that continues for a maximum number of iterations
3
+ * Creates a strategy that continues for a maximum number of **model turns**
4
+ * (iterations), not tool calls.
4
5
  *
5
- * @param max - Maximum number of iterations to allow
6
+ * One iteration can still emit many parallel tool calls. Prefer
7
+ * {@link maxToolCalls} (and optionally `maxToolCallsPerTurn` on `chat()`)
8
+ * when you need a tool-call budget.
9
+ *
10
+ * @param max - Maximum number of model turns to allow
6
11
  * @returns AgentLoopStrategy that stops after max iterations
7
12
  *
8
13
  * @example
@@ -12,11 +17,42 @@ import { AgentLoopStrategy } from '../../types.js';
12
17
  * model: "gpt-4o",
13
18
  * messages: [...],
14
19
  * tools: [weatherTool],
15
- * agentLoopStrategy: maxIterations(3), // Max 3 iterations
20
+ * agentLoopStrategy: maxIterations(3), // Max 3 model turns
16
21
  * });
17
22
  * ```
18
23
  */
19
24
  export declare function maxIterations(max: number): AgentLoopStrategy;
25
+ /**
26
+ * Creates a strategy that continues while `toolCallCount < max`.
27
+ *
28
+ * Unlike {@link maxIterations} (which counts model turns), this bounds
29
+ * **emitted** tool calls counted during the run (including ones skipped by
30
+ * `maxToolCallsPerTurn`). Strategies only run between turns, so the turn that
31
+ * crosses `max` is not truncated — the final count (and executions, unless
32
+ * `maxToolCallsPerTurn` is set) may exceed `max`. Pair with
33
+ * `chat({ maxToolCallsPerTurn })` to also cap parallel fan-out inside a single
34
+ * turn.
35
+ *
36
+ * @param max - Maximum cumulative emitted tool calls before stopping further turns
37
+ * @returns AgentLoopStrategy that returns true while `toolCallCount < max`
38
+ *
39
+ * @example
40
+ * ```typescript
41
+ * import { chat, combineStrategies, maxIterations, maxToolCalls } from '@tanstack/ai'
42
+ *
43
+ * const stream = chat({
44
+ * adapter: openaiText('gpt-4o'),
45
+ * messages: [...],
46
+ * tools: [weatherTool],
47
+ * maxToolCallsPerTurn: 10,
48
+ * agentLoopStrategy: combineStrategies([
49
+ * maxIterations(20),
50
+ * maxToolCalls(20),
51
+ * ]),
52
+ * })
53
+ * ```
54
+ */
55
+ export declare function maxToolCalls(max: number): AgentLoopStrategy;
20
56
  /**
21
57
  * Creates a strategy that continues until a specific finish reason is encountered
22
58
  *
@@ -51,6 +87,7 @@ export declare function untilFinishReason(stopReasons: Array<string>): AgentLoop
51
87
  * tools: [weatherTool],
52
88
  * agentLoopStrategy: combineStrategies([
53
89
  * maxIterations(10),
90
+ * maxToolCalls(20),
54
91
  * ({ messages }) => messages.length < 100,
55
92
  * ]),
56
93
  * });
@@ -1,6 +1,9 @@
1
1
  function maxIterations(max) {
2
2
  return ({ iterationCount }) => iterationCount < max;
3
3
  }
4
+ function maxToolCalls(max) {
5
+ return ({ toolCallCount }) => toolCallCount < max;
6
+ }
4
7
  function untilFinishReason(stopReasons) {
5
8
  return ({ finishReason, iterationCount }) => {
6
9
  if (iterationCount === 0) return true;
@@ -18,6 +21,7 @@ function combineStrategies(strategies) {
18
21
  export {
19
22
  combineStrategies,
20
23
  maxIterations,
24
+ maxToolCalls,
21
25
  untilFinishReason
22
26
  };
23
27
  //# sourceMappingURL=agent-loop-strategies.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"agent-loop-strategies.js","sources":["../../../../src/activities/chat/agent-loop-strategies.ts"],"sourcesContent":["import type { AgentLoopStrategy } from '../../types'\n\n/**\n * Creates a strategy that continues for a maximum number of iterations\n *\n * @param max - Maximum number of iterations to allow\n * @returns AgentLoopStrategy that stops after max iterations\n *\n * @example\n * ```typescript\n * const stream = chat({\n * adapter: openaiText(),\n * model: \"gpt-4o\",\n * messages: [...],\n * tools: [weatherTool],\n * agentLoopStrategy: maxIterations(3), // Max 3 iterations\n * });\n * ```\n */\nexport function maxIterations(max: number): AgentLoopStrategy {\n return ({ iterationCount }) => iterationCount < max\n}\n\n/**\n * Creates a strategy that continues until a specific finish reason is encountered\n *\n * @param stopReasons - Finish reasons that should stop the loop\n * @returns AgentLoopStrategy that stops on specific finish reasons\n *\n * @example\n * ```typescript\n * const stream = chat({\n * adapter: openaiText(),\n * model: \"gpt-4o\",\n * messages: [...],\n * tools: [weatherTool],\n * agentLoopStrategy: untilFinishReason([\"stop\", \"length\"]),\n * });\n * ```\n */\nexport function untilFinishReason(\n stopReasons: Array<string>,\n): AgentLoopStrategy {\n return ({ finishReason, iterationCount }) => {\n // Always allow at least one iteration\n if (iterationCount === 0) return true\n\n // Stop if we hit a stop reason\n if (finishReason && stopReasons.includes(finishReason)) {\n return false\n }\n\n // Otherwise continue\n return true\n }\n}\n\n/**\n * Creates a strategy that combines multiple strategies with AND logic\n * All strategies must return true to continue\n *\n * @param strategies - Array of strategies to combine\n * @returns AgentLoopStrategy that continues only if all strategies return true\n *\n * @example\n * ```typescript\n * const stream = chat({\n * adapter: openaiText(),\n * model: \"gpt-4o\",\n * messages: [...],\n * tools: [weatherTool],\n * agentLoopStrategy: combineStrategies([\n * maxIterations(10),\n * ({ messages }) => messages.length < 100,\n * ]),\n * });\n * ```\n */\nexport function combineStrategies(\n strategies: Array<AgentLoopStrategy>,\n): AgentLoopStrategy {\n return (state) => {\n return strategies.every((strategy) => strategy(state))\n }\n}\n"],"names":[],"mappings":"AAmBO,SAAS,cAAc,KAAgC;AAC5D,SAAO,CAAC,EAAE,qBAAqB,iBAAiB;AAClD;AAmBO,SAAS,kBACd,aACmB;AACnB,SAAO,CAAC,EAAE,cAAc,qBAAqB;AAE3C,QAAI,mBAAmB,EAAG,QAAO;AAGjC,QAAI,gBAAgB,YAAY,SAAS,YAAY,GAAG;AACtD,aAAO;AAAA,IACT;AAGA,WAAO;AAAA,EACT;AACF;AAuBO,SAAS,kBACd,YACmB;AACnB,SAAO,CAAC,UAAU;AAChB,WAAO,WAAW,MAAM,CAAC,aAAa,SAAS,KAAK,CAAC;AAAA,EACvD;AACF;"}
1
+ {"version":3,"file":"agent-loop-strategies.js","sources":["../../../../src/activities/chat/agent-loop-strategies.ts"],"sourcesContent":["import type { AgentLoopStrategy } from '../../types'\n\n/**\n * Creates a strategy that continues for a maximum number of **model turns**\n * (iterations), not tool calls.\n *\n * One iteration can still emit many parallel tool calls. Prefer\n * {@link maxToolCalls} (and optionally `maxToolCallsPerTurn` on `chat()`)\n * when you need a tool-call budget.\n *\n * @param max - Maximum number of model turns to allow\n * @returns AgentLoopStrategy that stops after max iterations\n *\n * @example\n * ```typescript\n * const stream = chat({\n * adapter: openaiText(),\n * model: \"gpt-4o\",\n * messages: [...],\n * tools: [weatherTool],\n * agentLoopStrategy: maxIterations(3), // Max 3 model turns\n * });\n * ```\n */\nexport function maxIterations(max: number): AgentLoopStrategy {\n return ({ iterationCount }) => iterationCount < max\n}\n\n/**\n * Creates a strategy that continues while `toolCallCount < max`.\n *\n * Unlike {@link maxIterations} (which counts model turns), this bounds\n * **emitted** tool calls counted during the run (including ones skipped by\n * `maxToolCallsPerTurn`). Strategies only run between turns, so the turn that\n * crosses `max` is not truncated — the final count (and executions, unless\n * `maxToolCallsPerTurn` is set) may exceed `max`. Pair with\n * `chat({ maxToolCallsPerTurn })` to also cap parallel fan-out inside a single\n * turn.\n *\n * @param max - Maximum cumulative emitted tool calls before stopping further turns\n * @returns AgentLoopStrategy that returns true while `toolCallCount < max`\n *\n * @example\n * ```typescript\n * import { chat, combineStrategies, maxIterations, maxToolCalls } from '@tanstack/ai'\n *\n * const stream = chat({\n * adapter: openaiText('gpt-4o'),\n * messages: [...],\n * tools: [weatherTool],\n * maxToolCallsPerTurn: 10,\n * agentLoopStrategy: combineStrategies([\n * maxIterations(20),\n * maxToolCalls(20),\n * ]),\n * })\n * ```\n */\nexport function maxToolCalls(max: number): AgentLoopStrategy {\n return ({ toolCallCount }) => toolCallCount < max\n}\n\n/**\n * Creates a strategy that continues until a specific finish reason is encountered\n *\n * @param stopReasons - Finish reasons that should stop the loop\n * @returns AgentLoopStrategy that stops on specific finish reasons\n *\n * @example\n * ```typescript\n * const stream = chat({\n * adapter: openaiText(),\n * model: \"gpt-4o\",\n * messages: [...],\n * tools: [weatherTool],\n * agentLoopStrategy: untilFinishReason([\"stop\", \"length\"]),\n * });\n * ```\n */\nexport function untilFinishReason(\n stopReasons: Array<string>,\n): AgentLoopStrategy {\n return ({ finishReason, iterationCount }) => {\n // Always allow at least one iteration\n if (iterationCount === 0) return true\n\n // Stop if we hit a stop reason\n if (finishReason && stopReasons.includes(finishReason)) {\n return false\n }\n\n // Otherwise continue\n return true\n }\n}\n\n/**\n * Creates a strategy that combines multiple strategies with AND logic\n * All strategies must return true to continue\n *\n * @param strategies - Array of strategies to combine\n * @returns AgentLoopStrategy that continues only if all strategies return true\n *\n * @example\n * ```typescript\n * const stream = chat({\n * adapter: openaiText(),\n * model: \"gpt-4o\",\n * messages: [...],\n * tools: [weatherTool],\n * agentLoopStrategy: combineStrategies([\n * maxIterations(10),\n * maxToolCalls(20),\n * ({ messages }) => messages.length < 100,\n * ]),\n * });\n * ```\n */\nexport function combineStrategies(\n strategies: Array<AgentLoopStrategy>,\n): AgentLoopStrategy {\n return (state) => {\n return strategies.every((strategy) => strategy(state))\n }\n}\n"],"names":[],"mappings":"AAwBO,SAAS,cAAc,KAAgC;AAC5D,SAAO,CAAC,EAAE,qBAAqB,iBAAiB;AAClD;AAgCO,SAAS,aAAa,KAAgC;AAC3D,SAAO,CAAC,EAAE,oBAAoB,gBAAgB;AAChD;AAmBO,SAAS,kBACd,aACmB;AACnB,SAAO,CAAC,EAAE,cAAc,qBAAqB;AAE3C,QAAI,mBAAmB,EAAG,QAAO;AAGjC,QAAI,gBAAgB,YAAY,SAAS,YAAY,GAAG;AACtD,aAAO;AAAA,IACT;AAGA,WAAO;AAAA,EACT;AACF;AAwBO,SAAS,kBACd,YACmB;AACnB,SAAO,CAAC,UAAU;AAChB,WAAO,WAAW,MAAM,CAAC,aAAa,SAAS,KAAK,CAAC;AAAA,EACvD;AACF;"}
@@ -93,6 +93,11 @@ export interface TextActivityOptions<TAdapter extends AnyTextAdapter, TSchema ex
93
93
  abortController?: TextOptions['abortController'];
94
94
  /** Strategy for controlling the agent loop */
95
95
  agentLoopStrategy?: TextOptions['agentLoopStrategy'];
96
+ /**
97
+ * Cap how many tool calls from a single model turn are executed.
98
+ * Excess calls receive error results. See {@link TextOptions.maxToolCallsPerTurn}.
99
+ */
100
+ maxToolCallsPerTurn?: TextOptions['maxToolCallsPerTurn'];
96
101
  /**
97
102
  * Optional configuration for lazy-tool discovery (tools marked `lazy: true`).
98
103
  * Tunes how much of each lazy tool's description appears in the discovery
@@ -20,6 +20,15 @@ const kind = "text";
20
20
  function createChatOptions(options) {
21
21
  return options;
22
22
  }
23
+ function resolveMaxToolCallsPerTurn(cap) {
24
+ if (cap == null) return void 0;
25
+ if (!Number.isFinite(cap) || cap < 0) {
26
+ throw new Error(
27
+ `maxToolCallsPerTurn must be a non-negative finite number, got ${cap}`
28
+ );
29
+ }
30
+ return Math.floor(cap);
31
+ }
23
32
  function combineAbortSignals(a, b) {
24
33
  if (!a) return b;
25
34
  if (!b) return a;
@@ -48,6 +57,12 @@ class TextEngine {
48
57
  effectiveSignal;
49
58
  messages;
50
59
  iterationCount = 0;
60
+ /** Cumulative tool calls counted in this run (emitted + pending resume). */
61
+ toolCallCount = 0;
62
+ /** Tool calls in the most recent budgeted batch (0 when none). */
63
+ lastTurnToolCallCount = 0;
64
+ /** Tool call IDs already counted toward `toolCallCount` (avoids double-count on resume). */
65
+ countedToolCallIds = /* @__PURE__ */ new Set();
51
66
  lastFinishReason = null;
52
67
  streamStartTime = 0;
53
68
  totalChunkCount = 0;
@@ -62,6 +77,7 @@ class TextEngine {
62
77
  earlyTermination = false;
63
78
  toolPhase = "continue";
64
79
  cyclePhase = "processText";
80
+ maxToolCallsPerTurn;
65
81
  // Client state extracted from initial messages (before conversion to ModelMessage)
66
82
  initialApprovals;
67
83
  initialClientToolResults;
@@ -107,6 +123,9 @@ class TextEngine {
107
123
  this.params = config.params;
108
124
  this.systemPrompts = config.params.systemPrompts || [];
109
125
  this.loopStrategy = config.params.agentLoopStrategy || maxIterations(5);
126
+ this.maxToolCallsPerTurn = resolveMaxToolCallsPerTurn(
127
+ config.params.maxToolCallsPerTurn
128
+ );
110
129
  this.initialMessageCount = config.params.messages.length;
111
130
  const { approvals, clientToolResults } = this.extractClientStateFromOriginalMessages(
112
131
  config.params.messages
@@ -584,8 +603,9 @@ class TextEngine {
584
603
  return "continue";
585
604
  }
586
605
  const finishEvent = this.createSyntheticFinishedEvent();
606
+ const { toExecute: budgetedToolCalls, skippedResults } = this.applyToolCallBudget(pendingToolCalls);
587
607
  const undiscoveredLazyResults = [];
588
- const executablePendingCalls = pendingToolCalls.filter((tc) => {
608
+ const executablePendingCalls = budgetedToolCalls.filter((tc) => {
589
609
  if (this.lazyToolManager.isUndiscoveredLazyTool(tc.function.name)) {
590
610
  undiscoveredLazyResults.push({
591
611
  toolCallId: tc.id,
@@ -601,15 +621,21 @@ class TextEngine {
601
621
  }
602
622
  return true;
603
623
  });
604
- if (undiscoveredLazyResults.length > 0) {
605
- for (const chunk of this.buildToolResultChunks(
606
- undiscoveredLazyResults,
607
- finishEvent
608
- )) {
609
- yield* this.pipeThroughMiddleware(chunk);
610
- }
624
+ const deferredErrorResults = [...undiscoveredLazyResults, ...skippedResults];
625
+ const argsMap = /* @__PURE__ */ new Map();
626
+ for (const tc of pendingToolCalls) {
627
+ argsMap.set(tc.id, tc.function.arguments);
611
628
  }
612
629
  if (executablePendingCalls.length === 0) {
630
+ if (deferredErrorResults.length > 0) {
631
+ for (const chunk of this.buildToolResultChunks(
632
+ deferredErrorResults,
633
+ finishEvent,
634
+ argsMap
635
+ )) {
636
+ yield* this.pipeThroughMiddleware(chunk);
637
+ }
638
+ }
613
639
  return "continue";
614
640
  }
615
641
  const { approvals, clientToolResults } = this.collectClientState();
@@ -656,20 +682,17 @@ class TextEngine {
656
682
  this.setToolPhase("stop");
657
683
  return "stop";
658
684
  }
685
+ const allResults = [...executionResult.results, ...deferredErrorResults];
659
686
  await this.middlewareRunner.runOnToolPhaseComplete(this.middlewareCtx, {
660
687
  toolCalls: pendingToolCalls,
661
- results: executionResult.results,
688
+ results: allResults,
662
689
  needsApproval: executionResult.needsApproval,
663
690
  needsClientExecution: executionResult.needsClientExecution
664
691
  });
665
- const argsMap = /* @__PURE__ */ new Map();
666
- for (const tc of pendingToolCalls) {
667
- argsMap.set(tc.id, tc.function.arguments);
668
- }
669
692
  if (executionResult.needsApproval.length > 0 || executionResult.needsClientExecution.length > 0) {
670
- if (executionResult.results.length > 0) {
693
+ if (allResults.length > 0) {
671
694
  for (const chunk of this.buildToolResultChunks(
672
- executionResult.results,
695
+ allResults,
673
696
  finishEvent,
674
697
  argsMap
675
698
  )) {
@@ -692,7 +715,7 @@ class TextEngine {
692
715
  return "wait";
693
716
  }
694
717
  const toolResultChunks = this.buildToolResultChunks(
695
- executionResult.results,
718
+ allResults,
696
719
  finishEvent,
697
720
  argsMap
698
721
  );
@@ -703,18 +726,21 @@ class TextEngine {
703
726
  }
704
727
  async *processToolCalls() {
705
728
  if (!this.shouldExecuteToolPhase()) {
729
+ this.lastTurnToolCallCount = 0;
706
730
  this.setToolPhase("stop");
707
731
  return;
708
732
  }
709
733
  const toolCalls = this.toolCallManager.getToolCalls();
710
734
  const finishEvent = this.finishedEvent;
711
735
  if (!finishEvent || toolCalls.length === 0) {
736
+ this.lastTurnToolCallCount = 0;
712
737
  this.setToolPhase("stop");
713
738
  return;
714
739
  }
740
+ const { toExecute: budgetedToolCalls, skippedResults } = this.applyToolCallBudget(toolCalls);
715
741
  this.addAssistantToolCallMessage(toolCalls);
716
742
  const undiscoveredLazyResults = [];
717
- const executableToolCalls = toolCalls.filter((tc) => {
743
+ const executableToolCalls = budgetedToolCalls.filter((tc) => {
718
744
  if (this.lazyToolManager.isUndiscoveredLazyTool(tc.function.name)) {
719
745
  undiscoveredLazyResults.push({
720
746
  toolCallId: tc.id,
@@ -730,15 +756,16 @@ class TextEngine {
730
756
  }
731
757
  return true;
732
758
  });
733
- if (undiscoveredLazyResults.length > 0 && this.finishedEvent) {
734
- for (const chunk of this.buildToolResultChunks(
735
- undiscoveredLazyResults,
736
- this.finishedEvent
737
- )) {
738
- yield* this.pipeThroughMiddleware(chunk);
739
- }
740
- }
759
+ const deferredErrorResults = [...undiscoveredLazyResults, ...skippedResults];
741
760
  if (executableToolCalls.length === 0) {
761
+ if (deferredErrorResults.length > 0) {
762
+ for (const chunk of this.buildToolResultChunks(
763
+ deferredErrorResults,
764
+ finishEvent
765
+ )) {
766
+ yield* this.pipeThroughMiddleware(chunk);
767
+ }
768
+ }
742
769
  this.toolCallManager.clear();
743
770
  this.setToolPhase("continue");
744
771
  return;
@@ -789,16 +816,17 @@ class TextEngine {
789
816
  this.setToolPhase("stop");
790
817
  return;
791
818
  }
819
+ const allResults = [...executionResult.results, ...deferredErrorResults];
792
820
  await this.middlewareRunner.runOnToolPhaseComplete(this.middlewareCtx, {
793
821
  toolCalls,
794
- results: executionResult.results,
822
+ results: allResults,
795
823
  needsApproval: executionResult.needsApproval,
796
824
  needsClientExecution: executionResult.needsClientExecution
797
825
  });
798
826
  if (executionResult.needsApproval.length > 0 || executionResult.needsClientExecution.length > 0) {
799
- if (executionResult.results.length > 0) {
827
+ if (allResults.length > 0) {
800
828
  for (const chunk of this.buildToolResultChunks(
801
- executionResult.results,
829
+ allResults,
802
830
  finishEvent
803
831
  )) {
804
832
  yield* this.pipeThroughMiddleware(chunk);
@@ -819,10 +847,7 @@ class TextEngine {
819
847
  this.setToolPhase("wait");
820
848
  return;
821
849
  }
822
- const toolResultChunks = this.buildToolResultChunks(
823
- executionResult.results,
824
- finishEvent
825
- );
850
+ const toolResultChunks = this.buildToolResultChunks(allResults, finishEvent);
826
851
  for (const chunk of toolResultChunks) {
827
852
  yield* this.pipeThroughMiddleware(chunk);
828
853
  }
@@ -1060,9 +1085,53 @@ class TextEngine {
1060
1085
  return this.loopStrategy({
1061
1086
  iterationCount: this.iterationCount,
1062
1087
  messages: this.messages,
1063
- finishReason: this.lastFinishReason
1088
+ finishReason: this.lastFinishReason,
1089
+ toolCallCount: this.toolCallCount,
1090
+ lastTurnToolCallCount: this.lastTurnToolCallCount
1064
1091
  }) && this.toolPhase === "continue";
1065
1092
  }
1093
+ /**
1094
+ * Record tool calls (deduped by id) and return the subset that should be
1095
+ * executed after applying `maxToolCallsPerTurn`. Excess calls get synthetic
1096
+ * error results so every tool_call still has a matching result.
1097
+ *
1098
+ * Used for both live model turns and pending/resume batches. IDs already
1099
+ * counted in this run (e.g. wait→resume after a live turn) are not
1100
+ * re-added to `toolCallCount`.
1101
+ */
1102
+ applyToolCallBudget(toolCalls) {
1103
+ this.lastTurnToolCallCount = toolCalls.length;
1104
+ let newlyCounted = 0;
1105
+ for (const tc of toolCalls) {
1106
+ if (!this.countedToolCallIds.has(tc.id)) {
1107
+ this.countedToolCallIds.add(tc.id);
1108
+ newlyCounted++;
1109
+ }
1110
+ }
1111
+ this.toolCallCount += newlyCounted;
1112
+ const cap = this.maxToolCallsPerTurn;
1113
+ if (cap == null || toolCalls.length <= cap) {
1114
+ return { toExecute: toolCalls, skippedResults: [] };
1115
+ }
1116
+ this.logger.agentLoop(
1117
+ `maxToolCallsPerTurn=${cap} skipped=${toolCalls.length - cap}`,
1118
+ {
1119
+ maxToolCallsPerTurn: cap,
1120
+ emitted: toolCalls.length,
1121
+ skipped: toolCalls.length - cap
1122
+ }
1123
+ );
1124
+ const toExecute = toolCalls.slice(0, cap);
1125
+ const skippedResults = toolCalls.slice(cap).map((tc) => ({
1126
+ toolCallId: tc.id,
1127
+ toolName: tc.function.name,
1128
+ result: {
1129
+ error: `Skipped: exceeded maxToolCallsPerTurn (${cap})`
1130
+ },
1131
+ state: "output-error"
1132
+ }));
1133
+ return { toExecute, skippedResults };
1134
+ }
1066
1135
  isAborted() {
1067
1136
  return !!this.effectiveSignal?.aborted;
1068
1137
  }