@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.
- package/dist/esm/activities/chat/agent-loop-strategies.d.ts +40 -3
- package/dist/esm/activities/chat/agent-loop-strategies.js +4 -0
- package/dist/esm/activities/chat/agent-loop-strategies.js.map +1 -1
- package/dist/esm/activities/chat/index.d.ts +5 -0
- package/dist/esm/activities/chat/index.js +102 -33
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.js +2 -1
- package/dist/esm/types.d.ts +34 -2
- package/package.json +1 -1
- package/skills/ai-core/chat-experience/SKILL.md +58 -0
- package/skills/ai-core/tool-calling/SKILL.md +2 -0
- package/src/activities/chat/agent-loop-strategies.ts +43 -3
- package/src/activities/chat/index.ts +144 -36
- package/src/index.ts +1 -0
- package/src/types.ts +34 -2
package/dist/esm/index.d.ts
CHANGED
|
@@ -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,
|
package/dist/esm/types.d.ts
CHANGED
|
@@ -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
|
@@ -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
|
|
4
|
+
* Creates a strategy that continues for a maximum number of **model turns**
|
|
5
|
+
* (iterations), not tool calls.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
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
|
|
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 =
|
|
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
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
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:
|
|
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 (
|
|
1449
|
+
if (allResults.length > 0) {
|
|
1407
1450
|
for (const chunk of this.buildToolResultChunks(
|
|
1408
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
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
|
|
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:
|
|
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 (
|
|
1620
|
+
if (allResults.length > 0) {
|
|
1564
1621
|
for (const chunk of this.buildToolResultChunks(
|
|
1565
|
-
|
|
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
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
|