@tanstack/ai 0.40.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.
Files changed (57) hide show
  1. package/dist/esm/activities/chat/agent-loop-strategies.d.ts +40 -3
  2. package/dist/esm/activities/chat/agent-loop-strategies.js +4 -0
  3. package/dist/esm/activities/chat/agent-loop-strategies.js.map +1 -1
  4. package/dist/esm/activities/chat/index.d.ts +5 -0
  5. package/dist/esm/activities/chat/index.js +102 -33
  6. package/dist/esm/activities/chat/index.js.map +1 -1
  7. package/dist/esm/activities/chat/messages.js +7 -0
  8. package/dist/esm/activities/chat/messages.js.map +1 -1
  9. package/dist/esm/activities/chat/stream/message-updaters.d.ts +2 -0
  10. package/dist/esm/activities/chat/stream/message-updaters.js +3 -1
  11. package/dist/esm/activities/chat/stream/message-updaters.js.map +1 -1
  12. package/dist/esm/activities/chat/stream/processor.js +18 -1
  13. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  14. package/dist/esm/activities/chat/tools/tool-definition.d.ts +14 -11
  15. package/dist/esm/activities/chat/tools/tool-definition.js.map +1 -1
  16. package/dist/esm/activities/generateAudio/index.d.ts +1 -1
  17. package/dist/esm/activities/generateAudio/index.js.map +1 -1
  18. package/dist/esm/activities/generateImage/index.d.ts +1 -1
  19. package/dist/esm/activities/generateImage/index.js +1 -1
  20. package/dist/esm/activities/generateImage/index.js.map +1 -1
  21. package/dist/esm/activities/generateSpeech/index.d.ts +1 -1
  22. package/dist/esm/activities/generateSpeech/index.js.map +1 -1
  23. package/dist/esm/activities/generateTranscription/index.d.ts +1 -1
  24. package/dist/esm/activities/generateTranscription/index.js.map +1 -1
  25. package/dist/esm/activities/generateVideo/index.d.ts +12 -12
  26. package/dist/esm/activities/generateVideo/index.js.map +1 -1
  27. package/dist/esm/index.d.ts +3 -3
  28. package/dist/esm/index.js +4 -1
  29. package/dist/esm/index.js.map +1 -1
  30. package/dist/esm/realtime/event-emitter.d.ts +5 -0
  31. package/dist/esm/realtime/event-emitter.js +27 -0
  32. package/dist/esm/realtime/event-emitter.js.map +1 -0
  33. package/dist/esm/realtime/index.d.ts +1 -0
  34. package/dist/esm/realtime/index.js.map +1 -1
  35. package/dist/esm/realtime/types.d.ts +9 -1
  36. package/dist/esm/types.d.ts +43 -2
  37. package/package.json +1 -1
  38. package/skills/ai-core/chat-experience/SKILL.md +58 -0
  39. package/skills/ai-core/media-generation/SKILL.md +42 -2
  40. package/skills/ai-core/middleware/SKILL.md +18 -3
  41. package/skills/ai-core/tool-calling/SKILL.md +15 -1
  42. package/src/activities/chat/agent-loop-strategies.ts +43 -3
  43. package/src/activities/chat/index.ts +144 -36
  44. package/src/activities/chat/messages.ts +9 -0
  45. package/src/activities/chat/stream/message-updaters.ts +7 -1
  46. package/src/activities/chat/stream/processor.ts +30 -2
  47. package/src/activities/chat/tools/tool-definition.ts +33 -11
  48. package/src/activities/generateAudio/index.ts +2 -2
  49. package/src/activities/generateImage/index.ts +2 -2
  50. package/src/activities/generateSpeech/index.ts +2 -2
  51. package/src/activities/generateTranscription/index.ts +2 -2
  52. package/src/activities/generateVideo/index.ts +29 -15
  53. package/src/index.ts +3 -1
  54. package/src/realtime/event-emitter.ts +46 -0
  55. package/src/realtime/index.ts +2 -0
  56. package/src/realtime/types.ts +9 -0
  57. package/src/types.ts +43 -2
@@ -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';
@@ -30,8 +30,8 @@ export type { ResolvedMediaPrompt } from './utilities/media-prompt.js';
30
30
  export type { SystemPrompt, NormalizedSystemPrompt } from './system-prompts.js';
31
31
  export { normalizeSystemPrompts } from './system-prompts.js';
32
32
  export { detectImageMimeType } from './utils.js';
33
- export { realtimeToken } from './realtime/index.js';
34
- export type { RealtimeToken, RealtimeTokenAdapter, RealtimeTokenOptions, RealtimeSessionConfig, VADConfig, RealtimeMessage, RealtimeMessagePart, RealtimeTextPart, RealtimeAudioPart, RealtimeToolCallPart, RealtimeToolResultPart, RealtimeImagePart, RealtimeStatus, RealtimeMode, AudioVisualization, RealtimeEvent, RealtimeEventPayloads, RealtimeEventHandler, RealtimeErrorCode, RealtimeError, RealtimeAdapter, RealtimeConnection, } from './realtime/index.js';
33
+ export { realtimeToken, createRealtimeEventEmitter } from './realtime/index.js';
34
+ export type { RealtimeToken, RealtimeTokenAdapter, RealtimeTokenOptions, RealtimeSessionConfig, RealtimeToolConfig, VADConfig, RealtimeMessage, RealtimeMessagePart, RealtimeTextPart, RealtimeAudioPart, RealtimeToolCallPart, RealtimeToolResultPart, RealtimeImagePart, RealtimeStatus, RealtimeMode, AudioVisualization, RealtimeEvent, RealtimeEventPayloads, RealtimeEventHandler, RealtimeErrorCode, RealtimeError, RealtimeAdapter, RealtimeConnection, } from './realtime/index.js';
35
35
  export { convertMessagesToModelMessages, generateMessageId, uiMessageToModelMessages, modelMessageToUIMessage, modelMessagesToUIMessages, normalizeToUIMessage, } from './activities/chat/messages.js';
36
36
  export { StreamProcessor, createReplayStream, ImmediateStrategy, PunctuationStrategy, BatchStrategy, WordBoundaryStrategy, CompositeStrategy, PartialJSONParser, defaultJSONParser, parsePartialJSON, } from './activities/chat/stream/index.js';
37
37
  export type { ChunkStrategy, ChunkRecording, InternalToolCallState, ProcessorResult, ProcessorState, StreamProcessorEvents, StreamProcessorOptions, ToolCallState, ToolResultState, JSONParser, } from './activities/chat/stream/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";
@@ -33,6 +33,7 @@ import { PartialJSONParser, defaultJSONParser, parsePartialJSON } from "./activi
33
33
  import { StreamProcessor, createReplayStream } from "./activities/chat/stream/processor.js";
34
34
  import { createCapability } from "./activities/chat/middleware/capabilities.js";
35
35
  import { createChatMiddleware } from "./activities/chat/middleware/builder.js";
36
+ import { createRealtimeEventEmitter } from "./realtime/event-emitter.js";
36
37
  import { defineChatMiddleware } from "./activities/chat/middleware/define.js";
37
38
  export {
38
39
  BatchStrategy,
@@ -63,6 +64,7 @@ export {
63
64
  createFrozenRegistry,
64
65
  createImageOptions,
65
66
  createModel,
67
+ createRealtimeEventEmitter,
66
68
  createReplayStream,
67
69
  createSpeechOptions,
68
70
  createSummarizeOptions,
@@ -87,6 +89,7 @@ export {
87
89
  isProviderExecutedToolCall,
88
90
  isStandardSchema,
89
91
  maxIterations,
92
+ maxToolCalls,
90
93
  mergeAgentTools,
91
94
  modelMessageToUIMessage,
92
95
  modelMessagesToUIMessages,
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
@@ -0,0 +1,5 @@
1
+ import { RealtimeEvent, RealtimeEventHandler, RealtimeEventPayloads } from './types.js';
2
+ export declare function createRealtimeEventEmitter(): {
3
+ emit<TEvent extends RealtimeEvent>(event: TEvent, payload: RealtimeEventPayloads[TEvent]): void;
4
+ on<TEvent extends RealtimeEvent>(event: TEvent, handler: RealtimeEventHandler<TEvent>): () => void;
5
+ };
@@ -0,0 +1,27 @@
1
+ function createRealtimeEventEmitter() {
2
+ const eventHandlers = /* @__PURE__ */ new Map();
3
+ return {
4
+ emit(event, payload) {
5
+ const handlers = eventHandlers.get(event);
6
+ if (!handlers) return;
7
+ for (const handler of handlers) {
8
+ handler(payload);
9
+ }
10
+ },
11
+ on(event, handler) {
12
+ let handlers = eventHandlers.get(event);
13
+ if (!handlers) {
14
+ handlers = /* @__PURE__ */ new Set();
15
+ eventHandlers.set(event, handlers);
16
+ }
17
+ handlers.add(handler);
18
+ return () => {
19
+ handlers.delete(handler);
20
+ };
21
+ }
22
+ };
23
+ }
24
+ export {
25
+ createRealtimeEventEmitter
26
+ };
27
+ //# sourceMappingURL=event-emitter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"event-emitter.js","sources":["../../../src/realtime/event-emitter.ts"],"sourcesContent":["import type {\n RealtimeEvent,\n RealtimeEventHandler,\n RealtimeEventPayloads,\n} from './types'\n\n/**\n * Handlers are stored with a `never` payload so any specific\n * `RealtimeEventHandler<TEvent>` is assignable in (contravariance), keeping the\n * heterogeneous handler map type-safe without `any`. `emit` narrows back to the\n * event's real payload type via its signature; the lone `as never` at the call\n * site is the inverse of that stored `never`.\n */\ntype StoredHandler = (payload: never) => void\n\nexport function createRealtimeEventEmitter() {\n const eventHandlers = new Map<RealtimeEvent, Set<StoredHandler>>()\n\n return {\n emit<TEvent extends RealtimeEvent>(\n event: TEvent,\n payload: RealtimeEventPayloads[TEvent],\n ) {\n const handlers = eventHandlers.get(event)\n if (!handlers) return\n for (const handler of handlers) {\n handler(payload as never)\n }\n },\n on<TEvent extends RealtimeEvent>(\n event: TEvent,\n handler: RealtimeEventHandler<TEvent>,\n ): () => void {\n let handlers = eventHandlers.get(event)\n if (!handlers) {\n handlers = new Set<StoredHandler>()\n eventHandlers.set(event, handlers)\n }\n handlers.add(handler)\n\n return () => {\n handlers.delete(handler)\n }\n },\n }\n}\n"],"names":[],"mappings":"AAeO,SAAS,6BAA6B;AAC3C,QAAM,oCAAoB,IAAA;AAE1B,SAAO;AAAA,IACL,KACE,OACA,SACA;AACA,YAAM,WAAW,cAAc,IAAI,KAAK;AACxC,UAAI,CAAC,SAAU;AACf,iBAAW,WAAW,UAAU;AAC9B,gBAAQ,OAAgB;AAAA,MAC1B;AAAA,IACF;AAAA,IACA,GACE,OACA,SACY;AACZ,UAAI,WAAW,cAAc,IAAI,KAAK;AACtC,UAAI,CAAC,UAAU;AACb,uCAAe,IAAA;AACf,sBAAc,IAAI,OAAO,QAAQ;AAAA,MACnC;AACA,eAAS,IAAI,OAAO;AAEpB,aAAO,MAAM;AACX,iBAAS,OAAO,OAAO;AAAA,MACzB;AAAA,IACF;AAAA,EAAA;AAEJ;"}
@@ -1,4 +1,5 @@
1
1
  import { RealtimeToken, RealtimeTokenOptions } from './types.js';
2
+ export { createRealtimeEventEmitter } from './event-emitter.js';
2
3
  export type * from './types.js';
3
4
  /**
4
5
  * Generate a realtime token using the provided adapter.
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":["../../../src/realtime/index.ts"],"sourcesContent":["import type { RealtimeToken, RealtimeTokenOptions } from './types'\n\n// Re-export all types\nexport type * from './types'\n\n/**\n * Generate a realtime token using the provided adapter.\n *\n * This function is used on the server to generate ephemeral tokens\n * that clients can use to establish realtime connections.\n *\n * @param options - Token generation options including the adapter\n * @returns Promise resolving to a RealtimeToken\n *\n * @example\n * ```typescript\n * import { realtimeToken } from '@tanstack/ai'\n * import { openaiRealtimeToken } from '@tanstack/ai-openai'\n *\n * // Server function (TanStack Start example)\n * export const getRealtimeToken = createServerFn()\n * .handler(async () => {\n * return realtimeToken({\n * adapter: openaiRealtimeToken({\n * model: 'gpt-realtime',\n * }),\n * })\n * })\n * ```\n */\nexport async function realtimeToken(\n options: RealtimeTokenOptions,\n): Promise<RealtimeToken> {\n const { adapter } = options\n return adapter.generateToken()\n}\n"],"names":[],"mappings":"AA8BA,eAAsB,cACpB,SACwB;AACxB,QAAM,EAAE,YAAY;AACpB,SAAO,QAAQ,cAAA;AACjB;"}
1
+ {"version":3,"file":"index.js","sources":["../../../src/realtime/index.ts"],"sourcesContent":["import type { RealtimeToken, RealtimeTokenOptions } from './types'\n\nexport { createRealtimeEventEmitter } from './event-emitter'\n\n// Re-export all types\nexport type * from './types'\n\n/**\n * Generate a realtime token using the provided adapter.\n *\n * This function is used on the server to generate ephemeral tokens\n * that clients can use to establish realtime connections.\n *\n * @param options - Token generation options including the adapter\n * @returns Promise resolving to a RealtimeToken\n *\n * @example\n * ```typescript\n * import { realtimeToken } from '@tanstack/ai'\n * import { openaiRealtimeToken } from '@tanstack/ai-openai'\n *\n * // Server function (TanStack Start example)\n * export const getRealtimeToken = createServerFn()\n * .handler(async () => {\n * return realtimeToken({\n * adapter: openaiRealtimeToken({\n * model: 'gpt-realtime',\n * }),\n * })\n * })\n * ```\n */\nexport async function realtimeToken(\n options: RealtimeTokenOptions,\n): Promise<RealtimeToken> {\n const { adapter } = options\n return adapter.generateToken()\n}\n"],"names":[],"mappings":"AAgCA,eAAsB,cACpB,SACwB;AACxB,QAAM,EAAE,YAAY;AACpB,SAAO,QAAQ,cAAA;AACjB;"}
@@ -1,4 +1,5 @@
1
1
  import { AnyClientTool } from '../activities/chat/tools/tool-definition.js';
2
+ import { UsageInfo } from '../activities/chat/middleware/types.js';
2
3
  /**
3
4
  * Voice activity detection configuration
4
5
  */
@@ -18,6 +19,7 @@ export interface RealtimeToolConfig {
18
19
  name: string;
19
20
  description: string;
20
21
  inputSchema?: Record<string, any>;
22
+ outputSchema?: Record<string, any>;
21
23
  }
22
24
  /**
23
25
  * Configuration for a realtime session
@@ -182,7 +184,7 @@ export interface AudioVisualization {
182
184
  /**
183
185
  * Events emitted by the realtime connection
184
186
  */
185
- export type RealtimeEvent = 'status_change' | 'mode_change' | 'transcript' | 'audio_chunk' | 'tool_call' | 'message_complete' | 'interrupted' | 'error';
187
+ export type RealtimeEvent = 'status_change' | 'mode_change' | 'transcript' | 'audio_chunk' | 'tool_call' | 'message_complete' | 'interrupted' | 'error' | 'go_away' | 'usage';
186
188
  /**
187
189
  * Event payloads for realtime events
188
190
  */
@@ -216,6 +218,10 @@ export interface RealtimeEventPayloads {
216
218
  error: {
217
219
  error: Error;
218
220
  };
221
+ go_away: {
222
+ timeLeft?: string;
223
+ };
224
+ usage: UsageInfo;
219
225
  }
220
226
  /**
221
227
  * Handler type for realtime events
@@ -273,6 +279,8 @@ export interface RealtimeConnection {
273
279
  sendToolResult: (callId: string, result: string) => void;
274
280
  /** Update session configuration */
275
281
  updateSession: (config: Partial<RealtimeSessionConfig>) => void;
282
+ /** Update the ephemeral token (e.g. on refresh); provider may reconnect */
283
+ updateToken?: (token: RealtimeToken) => void;
276
284
  /** Interrupt the current response */
277
285
  interrupt: () => void;
278
286
  /** Subscribe to connection events */
@@ -261,6 +261,15 @@ export interface ToolCallPart<TMetadata = unknown> {
261
261
  id: string;
262
262
  name: string;
263
263
  arguments: string;
264
+ /**
265
+ * Parsed tool input. Set from the parsed arguments once they are complete
266
+ * (`state: 'input-complete'` and later). `undefined` while the raw
267
+ * `arguments` string is still streaming, and may stay `undefined` for a call
268
+ * that terminates in an error state — the raw `arguments` string is always
269
+ * available as a fallback. Typed per-tool on the client `ToolCallPart` (see
270
+ * `@tanstack/ai-client`); `unknown` on this base type.
271
+ */
272
+ input?: unknown;
264
273
  state: ToolCallState;
265
274
  /** Approval metadata if tool requires user approval */
266
275
  approval?: {
@@ -645,12 +654,24 @@ export interface ResponseFormat<TData = any> {
645
654
  * State passed to agent loop strategy for determining whether to continue
646
655
  */
647
656
  export interface AgentLoopState {
648
- /** Current iteration count (0-indexed) */
657
+ /** Current iteration count (0-indexed). One iteration = one model turn. */
649
658
  iterationCount: number;
650
659
  /** Current messages array */
651
660
  messages: Array<ModelMessage>;
652
661
  /** Finish reason from the last response */
653
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;
654
675
  }
655
676
  /**
656
677
  * Strategy function that determines whether the agent loop should continue
@@ -660,8 +681,10 @@ export interface AgentLoopState {
660
681
  *
661
682
  * @example
662
683
  * ```typescript
663
- * // Continue for up to 5 iterations
684
+ * // Continue for up to 5 iterations (model turns, not tool calls)
664
685
  * const strategy: AgentLoopStrategy = ({ iterationCount }) => iterationCount < 5;
686
+ * // Cap total tool calls across the run
687
+ * const byTools: AgentLoopStrategy = ({ toolCallCount }) => toolCallCount < 20;
665
688
  * ```
666
689
  */
667
690
  export type AgentLoopStrategy = (state: AgentLoopState) => boolean;
@@ -693,6 +716,24 @@ export interface TextOptions<TProviderOptionsSuperset extends Record<string, any
693
716
  */
694
717
  systemPrompts?: Array<SystemPrompt>;
695
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;
696
737
  /**
697
738
  * Optional configuration for lazy-tool discovery (tools marked `lazy: true`).
698
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.40.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)
@@ -261,6 +261,17 @@ await generateVideo({
261
261
  })
262
262
  ```
263
263
 
264
+ **URL inputs that require an upload throw by default.** Most adapters pass a
265
+ `type: 'url'` source straight through to the provider. Three paths can't —
266
+ OpenAI `images.edit()`, OpenAI Sora `input_reference`, and Gemini **Veo** —
267
+ because the provider only accepts uploaded bytes (Veo also takes a `gs://`
268
+ reference). For those, an HTTP(S) URL would have to be downloaded and buffered
269
+ in memory, which can OOM constrained runtimes, so they **throw** on an HTTP(S)
270
+ URL image input by default. Pass a `data:` URI (or `gs://` for Veo), or opt in
271
+ with `allowUrlFetch: true` on the adapter config
272
+ (`createOpenaiImage(model, apiKey, { allowUrlFetch: true })`, and likewise on
273
+ `createOpenaiVideo` / `createGeminiVideo`). `data:` URIs never need the flag.
274
+
264
275
  **Role hints** (`metadata.role`):
265
276
 
266
277
  | Role | Maps to |
@@ -448,8 +459,8 @@ return toServerSentEventsResponse(stream)
448
459
  ```
449
460
 
450
461
  Google Veo (`@tanstack/ai-gemini`) uses the same jobs/polling flow. Its
451
- `duration` option is typed per model (e.g. `4 | 6 | 8` for Veo 3.x,
452
- `5 | 6 | 8` for Veo 2); use `adapter.snapDuration(seconds)` to coerce raw
462
+ `duration` option is typed per model (`4 | 6 | 8` for the Veo 3.1 models);
463
+ use `adapter.snapDuration(seconds)` to coerce raw
453
464
  seconds and `adapter.availableDurations()` to enumerate the valid set.
454
465
  Image prompt parts route by `metadata.role`: first un-roled /
455
466
  `'start_frame'` image → input image, `'end_frame'` → `lastFrame`,
@@ -472,6 +483,35 @@ const { jobId } = await generateVideo({
472
483
  // (x-goog-api-key header or ?key= query parameter).
473
484
  ```
474
485
 
486
+ Gemini Omni Flash (`geminiVideo('gemini-omni-flash-preview')`) is served by
487
+ the Interactions API instead of Veo's operations flow — same adapter, routed
488
+ by model. Clips are 720p; `duration` is any number of seconds in the 3–10
489
+ range (fractional ok, default 10 — availableDurations() reports the range),
490
+ `size` is the aspect ratio (`'16:9' | '9:16'`), and the finished video arrives
491
+ **inline** as a `data:video/mp4;base64,…` URL (no key needed to use it).
492
+ Image/video prompt parts are sent as interaction content blocks, grouped
493
+ as images, then videos, then text (no
494
+ `metadata.role` routing); `data` sources go inline, `url` sources pass
495
+ through as-is (never downloaded — use Gemini Files API URIs for remote
496
+ media). For conversational editing, pass a prior generation's `jobId` as
497
+ `modelOptions.previous_interaction_id` with a prompt describing the change:
498
+
499
+ ```typescript
500
+ import { geminiVideo } from '@tanstack/ai-gemini'
501
+
502
+ const omni = geminiVideo('gemini-omni-flash-preview')
503
+ const first = await generateVideo({
504
+ adapter: omni,
505
+ prompt: 'A violinist outdoors',
506
+ })
507
+ // …poll first.jobId to completion, then edit it:
508
+ const edited = await generateVideo({
509
+ adapter: omni,
510
+ prompt: 'Make the violin invisible',
511
+ modelOptions: { previous_interaction_id: first.jobId },
512
+ })
513
+ ```
514
+
475
515
  Other video adapters: `openaiVideo('sora-2')` (pixel sizes like `'1280x720'`,
476
516
  durations 4/8/12s, single `input_reference` image prompt part), `grokVideo(...)`
477
517
  (`grok-imagine-video` does text-to-video + image-to-video; `grok-imagine-video-1.5` is
@@ -453,11 +453,26 @@ accessors throw: a deleted file resolves `after()` to `''` (it still has
453
453
  a non-git workspace resolves **both** `before()` and `after()` to `''` and
454
454
  makes `diff()` fall back to a synthesized add-patch built from `after()` —
455
455
  except for a `delete` event in a non-git workspace, where there's nothing to
456
- synthesize and `diff()` resolves to `''`.
456
+ synthesize and `diff()` resolves to `''`. In a git workspace a file git
457
+ **isn't tracking yet** (a file the agent created, and every later edit to it)
458
+ diffs empty because `git diff` ignores untracked files, so `diff()` falls
459
+ back to the same synthesized add-patch whenever the file is absent at the
460
+ baseline — a create-or-edit of an untracked file never streams an empty diff.
461
+ An empty diff for a **tracked** file (identical to the baseline) stays empty,
462
+ as it should. A **git-ignored** file is withheld: the file event still fires
463
+ (you're notified it changed) but `diff()` returns `''`, so a secret like a
464
+ `.env` never has its contents surfaced in the diff feed.
465
+
466
+ **Failures are logged, not silent.** Every git/exec/fs failure behind these
467
+ accessors (and behind the `find`-poll watcher) still falls back to `''`/an
468
+ empty snapshot, but logs first: real anomalies (a failed `git diff`, an
469
+ unreadable file, a lost `find` poll) under the `errors` category (on by
470
+ default); expected-empty conditions (a new file's `before()`, a non-git
471
+ baseline) under the `sandbox` debug category.
457
472
 
458
473
  **Hook errors are swallowed per hook.** A throwing `sandbox` hook is caught
459
- and logged under the `sandbox` debug category — it cannot break the run or
460
- stop other hooks (or the `sandbox.file` chunk) from continuing.
474
+ and logged under the `errors` category (on by default) — it cannot break the
475
+ run or stop other hooks (or the `sandbox.file` chunk) from continuing.
461
476
 
462
477
  Source: docs/sandbox/observability.md
463
478
 
@@ -289,7 +289,10 @@ function ChatPage() {
289
289
  return (
290
290
  <div key={part.id}>
291
291
  <p>Approve "{part.name}"?</p>
292
- <pre>{part.arguments}</pre>
292
+ {/* `part.input` is the parsed, typed object (populated once
293
+ the arguments are complete, as they are at approval
294
+ time); `part.arguments` remains the raw JSON string. */}
295
+ <pre>{JSON.stringify(part.input, null, 2)}</pre>
293
296
  <button
294
297
  onClick={() =>
295
298
  addToolApprovalResponse({
@@ -322,6 +325,15 @@ function ChatPage() {
322
325
  }
323
326
  ```
324
327
 
328
+ > **Type-safe approval:** With typed `tools`, `part.approval` exists **only**
329
+ > on parts for tools defined with `needsApproval: true`. Tools without approval
330
+ > have no `approval` field (reading it is a compile error). For a
331
+ > tool-agnostic handler over a typed union, narrow with `'approval' in part`
332
+ > (`if (part.type === 'tool-call' && 'approval' in part && part.approval)`),
333
+ > or type a shared component against the base `ToolCallPart`. An untyped
334
+ > `useChat()` keeps `approval` on every tool-call part, which is why the
335
+ > snippet above (no `tools` generic) reads it directly.
336
+
325
337
  ### Pattern 4: Lazy Tool Discovery
326
338
 
327
339
  Set `lazy: true` on rarely-needed tools. The LLM sees their names via a synthetic
@@ -363,6 +375,8 @@ export async function POST(request: Request) {
363
375
  adapter: openaiText('gpt-5.5'),
364
376
  messages,
365
377
  tools: [getProducts, compareProducts],
378
+ // maxIterations bounds model turns, not tool calls. Prefer maxToolCalls
379
+ // (and maxToolCallsPerTurn) when you need a tool-call budget.
366
380
  agentLoopStrategy: maxIterations(20),
367
381
  })
368
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
  * });