@tanstack/ai 0.63.0 → 0.64.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 (61) hide show
  1. package/dist/esm/activities/chat/agents/spawn.js +2 -28
  2. package/dist/esm/activities/chat/agents/spawn.js.map +1 -1
  3. package/dist/esm/activities/chat/index.d.ts +2 -0
  4. package/dist/esm/activities/chat/index.js +10 -3
  5. package/dist/esm/activities/chat/index.js.map +1 -1
  6. package/dist/esm/activities/chat/messages.js +13 -4
  7. package/dist/esm/activities/chat/messages.js.map +1 -1
  8. package/dist/esm/activities/chat/stream/message-updaters.js +4 -1
  9. package/dist/esm/activities/chat/stream/message-updaters.js.map +1 -1
  10. package/dist/esm/activities/chat/tools/schema-converter.d.ts +8 -0
  11. package/dist/esm/activities/chat/tools/schema-converter.js +6 -5
  12. package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
  13. package/dist/esm/activities/chat/tools/tool-calls.d.ts +5 -2
  14. package/dist/esm/activities/chat/tools/tool-calls.js +73 -22
  15. package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
  16. package/dist/esm/activities/generateSpeech/index.d.ts +1 -1
  17. package/dist/esm/activities/generateSpeech/index.js +1 -1
  18. package/dist/esm/activities/generateSpeech/index.js.map +1 -1
  19. package/dist/esm/activities/generateVideo/adapter.d.ts +14 -6
  20. package/dist/esm/activities/generateVideo/adapter.js +6 -3
  21. package/dist/esm/activities/generateVideo/adapter.js.map +1 -1
  22. package/dist/esm/activities/generateVideo/index.d.ts +5 -4
  23. package/dist/esm/activities/generateVideo/index.js.map +1 -1
  24. package/dist/esm/activities/generateVideo/snap.d.ts +12 -3
  25. package/dist/esm/activities/generateVideo/snap.js +47 -8
  26. package/dist/esm/activities/generateVideo/snap.js.map +1 -1
  27. package/dist/esm/activities/generateVoice/index.d.ts +1 -1
  28. package/dist/esm/activities/generateVoice/index.js +1 -1
  29. package/dist/esm/activities/generateVoice/index.js.map +1 -1
  30. package/dist/esm/activities/index.d.ts +2 -2
  31. package/dist/esm/activities/index.js +2 -2
  32. package/dist/esm/adapter-internals.d.ts +1 -0
  33. package/dist/esm/adapter-internals.js +2 -1
  34. package/dist/esm/types.d.ts +28 -3
  35. package/dist/esm/utilities/ag-ui-wire.js +3 -1
  36. package/dist/esm/utilities/ag-ui-wire.js.map +1 -1
  37. package/dist/esm/utilities/merge-streams.d.ts +6 -0
  38. package/dist/esm/utilities/merge-streams.js +37 -0
  39. package/dist/esm/utilities/merge-streams.js.map +1 -0
  40. package/dist/esm/utilities/reasoning-encrypted-value.d.ts +8 -0
  41. package/dist/esm/utilities/reasoning-encrypted-value.js +11 -1
  42. package/dist/esm/utilities/reasoning-encrypted-value.js.map +1 -1
  43. package/package.json +1 -1
  44. package/skills/ai-core/media-generation/SKILL.md +3 -3
  45. package/src/activities/chat/agents/spawn.ts +2 -31
  46. package/src/activities/chat/index.ts +13 -3
  47. package/src/activities/chat/messages.ts +14 -2
  48. package/src/activities/chat/stream/message-updaters.ts +5 -0
  49. package/src/activities/chat/tools/schema-converter.ts +17 -5
  50. package/src/activities/chat/tools/tool-calls.ts +121 -63
  51. package/src/activities/generateSpeech/index.ts +1 -1
  52. package/src/activities/generateVideo/adapter.ts +21 -7
  53. package/src/activities/generateVideo/index.ts +5 -4
  54. package/src/activities/generateVideo/snap.ts +64 -6
  55. package/src/activities/generateVoice/index.ts +1 -1
  56. package/src/activities/index.ts +2 -1
  57. package/src/adapter-internals.ts +1 -0
  58. package/src/types.ts +28 -4
  59. package/src/utilities/ag-ui-wire.ts +5 -1
  60. package/src/utilities/merge-streams.ts +34 -0
  61. package/src/utilities/reasoning-encrypted-value.ts +12 -0
@@ -41,6 +41,7 @@ import {
41
41
  import { subagentHostMessageId } from '../../utilities/subagent-wire'
42
42
  import { withDurabilityBatchHint } from '../../utilities/durability-batch'
43
43
  import { normalizeStreamChunk } from '../../utilities/normalize-stream-chunk'
44
+ import { isRedactedThinkingId } from '../../utilities/reasoning-encrypted-value'
44
45
  import { restorePublicUsage } from '../../utilities/restore-inbound-chunk'
45
46
  import type { AdapterYieldChunk } from '../../utilities/adapter-yield-chunk'
46
47
  import {
@@ -523,6 +524,8 @@ export interface TextActivityOptions<
523
524
  abortController?: TextOptions['abortController']
524
525
  /** Strategy for controlling the agent loop */
525
526
  agentLoopStrategy?: TextOptions['agentLoopStrategy']
527
+ /** How the server tools of one model turn run. Default `'parallel'`. */
528
+ toolExecution?: TextOptions['toolExecution']
526
529
  /**
527
530
  * Optional configuration for lazy-tool discovery (tools marked `lazy: true`).
528
531
  * Tunes how much of each lazy tool's description appears in the discovery
@@ -853,8 +856,7 @@ class TextEngine<
853
856
  private currentMessageCreatedAt: Date | null = null
854
857
  private streamIdentityCaptured = false
855
858
  private accumulatedContent = ''
856
- private accumulatedThinking: Array<{ content: string; signature?: string }> =
857
- []
859
+ private accumulatedThinking: NonNullable<ModelMessage['thinking']> = []
858
860
  /**
859
861
  * Arrival order of this iteration's thinking steps, text and tool calls.
860
862
  * A ModelMessage keeps `thinking` apart from `content`/`toolCalls`, so a
@@ -866,6 +868,7 @@ class TextEngine<
866
868
  private turnParts: Array<TurnPart> | null = []
867
869
  private currentThinkingContent = ''
868
870
  private currentThinkingSignature = ''
871
+ private currentThinkingRedacted = false
869
872
  private eventOptions?: Record<string, unknown> | undefined
870
873
  private eventToolNames?: Array<string>
871
874
  private finishedEvent: RunFinishedEvent | null = null
@@ -1524,6 +1527,7 @@ class TextEngine<
1524
1527
  this.turnParts = []
1525
1528
  this.currentThinkingContent = ''
1526
1529
  this.currentThinkingSignature = ''
1530
+ this.currentThinkingRedacted = false
1527
1531
 
1528
1532
  this.finishedEvent = null
1529
1533
  this.streamedToolErrorResults.clear()
@@ -1992,6 +1996,7 @@ class TextEngine<
1992
1996
  ...(this.currentThinkingSignature && {
1993
1997
  signature: this.currentThinkingSignature,
1994
1998
  }),
1999
+ ...(this.currentThinkingRedacted && { redacted: true }),
1995
2000
  })
1996
2001
  if (this.turnParts) {
1997
2002
  const placeholder = [...this.turnParts]
@@ -2009,6 +2014,7 @@ class TextEngine<
2009
2014
  }
2010
2015
  this.currentThinkingContent = ''
2011
2016
  this.currentThinkingSignature = ''
2017
+ this.currentThinkingRedacted = false
2012
2018
  }
2013
2019
  }
2014
2020
 
@@ -2036,6 +2042,7 @@ class TextEngine<
2036
2042
  if (typeof chunk.signature === 'string' && chunk.signature !== '') {
2037
2043
  this.noteThinkingStepPosition()
2038
2044
  this.currentThinkingSignature = chunk.signature
2045
+ this.currentThinkingRedacted = isRedactedThinkingId(chunk.stepId)
2039
2046
  }
2040
2047
  }
2041
2048
 
@@ -2065,6 +2072,7 @@ class TextEngine<
2065
2072
  }
2066
2073
  this.noteThinkingStepPosition()
2067
2074
  this.currentThinkingSignature = chunk.encryptedValue
2075
+ this.currentThinkingRedacted = isRedactedThinkingId(chunk.entityId)
2068
2076
  }
2069
2077
 
2070
2078
  /**
@@ -2202,6 +2210,7 @@ class TextEngine<
2202
2210
  cancelledToolCallIds: this.resumeCancelledToolCallIds,
2203
2211
  inputResponses: this.resumeInputResponses,
2204
2212
  },
2213
+ this.params.toolExecution,
2205
2214
  )
2206
2215
 
2207
2216
  // Consume the async generator, yielding custom events and collecting the return value
@@ -2390,6 +2399,7 @@ class TextEngine<
2390
2399
  cancelledToolCallIds: this.resumeCancelledToolCallIds,
2391
2400
  inputResponses: this.resumeInputResponses,
2392
2401
  },
2402
+ this.params.toolExecution,
2393
2403
  )
2394
2404
 
2395
2405
  // Consume the async generator, yielding custom events and collecting the return value
@@ -2579,7 +2589,7 @@ class TextEngine<
2579
2589
  ),
2580
2590
  )
2581
2591
  type Segment = {
2582
- thinking: Array<{ content: string; signature?: string }>
2592
+ thinking: NonNullable<ModelMessage['thinking']>
2583
2593
  text: string
2584
2594
  callIds: Array<string>
2585
2595
  }
@@ -11,6 +11,7 @@ import {
11
11
  tanstackMetadata,
12
12
  withTanstackMetadata,
13
13
  } from '../../utilities/merge-metadata'
14
+ import { isRedactedThinkingId } from '../../utilities/reasoning-encrypted-value'
14
15
  import {
15
16
  splitSubagentWire,
16
17
  subagentWireText,
@@ -72,6 +73,13 @@ function encryptedValueFrom(value: object): string | undefined {
72
73
  return nonEmptyString(tanstackMetadata(value)?.signature)
73
74
  }
74
75
 
76
+ /** `{ redacted: true }` when a reasoning message's id marks a redacted block. */
77
+ function redactedFrom(value: object) {
78
+ return 'id' in value && isRedactedThinkingId(value.id)
79
+ ? { redacted: true }
80
+ : {}
81
+ }
82
+
75
83
  function toolCallFromWire(toolCall: ToolCall, bag: unknown): ToolCall {
76
84
  const fromBag =
77
85
  bag != null && typeof bag === 'object' && !Array.isArray(bag)
@@ -246,7 +254,7 @@ function convertOwnMessages(
246
254
  }
247
255
 
248
256
  const modelMessages: Array<ModelMessage> = []
249
- let pendingThinking: Array<{ content: string; signature?: string }> = []
257
+ let pendingThinking: NonNullable<ModelMessage['thinking']> = []
250
258
  for (const msg of messages) {
251
259
  if ('parts' in msg) {
252
260
  modelMessages.push(...uiMessageToModelMessages(msg))
@@ -273,6 +281,7 @@ function convertOwnMessages(
273
281
  pendingThinking.push({
274
282
  content: typeof content === 'string' ? content : '',
275
283
  ...(signature !== undefined ? { signature } : {}),
284
+ ...redactedFrom(msg),
276
285
  })
277
286
  }
278
287
  continue
@@ -663,7 +672,7 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
663
672
  // shared UI id on each one so persistence can retain the original identity.
664
673
  const messageList: Array<ModelMessage> = []
665
674
  let current = createSegment()
666
- let pendingThinking: Array<{ content: string; signature?: string }> = []
675
+ let pendingThinking: NonNullable<ModelMessage['thinking']> = []
667
676
 
668
677
  // Track emitted tool result IDs to avoid duplicates.
669
678
  // A tool call can have BOTH an explicit tool-result part AND an output
@@ -764,6 +773,7 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
764
773
  pendingThinking.push({
765
774
  content: part.content,
766
775
  ...(part.signature && { signature: part.signature }),
776
+ ...(part.redacted && { redacted: true }),
767
777
  })
768
778
  }
769
779
  break
@@ -898,6 +908,7 @@ export function modelMessageToUIMessage(
898
908
  type: 'thinking',
899
909
  content: thinking.content,
900
910
  ...(thinking.signature && { signature: thinking.signature }),
911
+ ...(thinking.redacted && { redacted: true }),
901
912
  })
902
913
  }
903
914
  }
@@ -1121,6 +1132,7 @@ export function aguiSnapshotMessageToUIMessage(
1121
1132
  type: 'thinking' as const,
1122
1133
  content,
1123
1134
  ...(signature !== undefined ? { signature } : {}),
1135
+ ...redactedFrom(message),
1124
1136
  },
1125
1137
  ]
1126
1138
  : [],
@@ -5,6 +5,7 @@
5
5
  * These are used by StreamProcessor to manage the message array.
6
6
  */
7
7
 
8
+ import { isRedactedThinkingId } from '../../../utilities/reasoning-encrypted-value'
8
9
  import { parsePartialJSON } from './json-parser'
9
10
  import type {
10
11
  ContentPart,
@@ -483,12 +484,16 @@ export function updateThinkingPart(
483
484
  // not carry one; losing it would strip the provider's encrypted reasoning
484
485
  // from a message that is about to be sent back.
485
486
  const nextSignature = signature ?? adopted?.signature
487
+ // A hydrated part has no stepId to carry the redacted marker, so it keeps
488
+ // its own flag.
489
+ const redacted = isRedactedThinkingId(stepId) || adopted?.redacted === true
486
490
 
487
491
  const thinkingPart: ThinkingPart = {
488
492
  type: 'thinking',
489
493
  content,
490
494
  stepId,
491
495
  ...(nextSignature && { signature: nextSignature }),
496
+ ...(redacted && { redacted: true }),
492
497
  }
493
498
 
494
499
  if (thinkingPartIndex >= 0) {
@@ -230,6 +230,14 @@ export interface ConvertSchemaOptions {
230
230
  * @default false
231
231
  */
232
232
  forStructuredOutput?: boolean
233
+ /**
234
+ * Which view of a Standard JSON Schema to convert. A schema with a
235
+ * transform or a pipe has a different `output` view. Use `'output'` to
236
+ * describe the value that parsing returns.
237
+ *
238
+ * @default 'input'
239
+ */
240
+ io?: 'input' | 'output'
233
241
  }
234
242
 
235
243
  /**
@@ -238,16 +246,20 @@ export interface ConvertSchemaOptions {
238
246
  *
239
247
  * - Standard JSON Schemas are rebuilt structurally (dropping `$schema`, which
240
248
  * LLM providers ignore) and given the explicit `type`/`properties`/`required`
241
- * defaults object shapes need downstream.
249
+ * defaults object shapes need downstream. `io` picks the `input` (default)
250
+ * or `output` view.
242
251
  * - Plain `JSONSchema` inputs are rebuilt into the typed view; non-object inputs
243
252
  * are surfaced untouched (they can't be widened).
244
253
  * - Standard Schema validators lacking a `~standard.jsonSchema` converter throw
245
254
  * with actionable guidance, rather than shipping `{ '~standard': … }` to the
246
255
  * provider and producing an opaque downstream error.
247
256
  */
248
- function toTypedJsonSchema(schema: SchemaInput): JSONSchema | undefined {
257
+ function toTypedJsonSchema(
258
+ schema: SchemaInput,
259
+ io: 'input' | 'output' = 'input',
260
+ ): JSONSchema | undefined {
249
261
  if (isStandardJSONSchema(schema)) {
250
- const jsonSchema = schema['~standard'].jsonSchema.input({
262
+ const jsonSchema = schema['~standard'].jsonSchema[io]({
251
263
  target: 'draft-07',
252
264
  })
253
265
  const result: JSONSchema = toJsonSchema(jsonSchema)
@@ -340,7 +352,7 @@ export function convertSchemaToJsonSchema(
340
352
  ): JSONSchema | undefined {
341
353
  if (!schema) return undefined
342
354
 
343
- const { forStructuredOutput = false } = options
355
+ const { forStructuredOutput = false, io } = options
344
356
 
345
357
  // Plain-JSONSchema passthrough: with no widening requested, return the schema
346
358
  // by reference so callers comparing via `===` keep identity. Only the widening
@@ -353,7 +365,7 @@ export function convertSchemaToJsonSchema(
353
365
  return schema
354
366
  }
355
367
 
356
- const base = toTypedJsonSchema(schema)
368
+ const base = toTypedJsonSchema(schema, io)
357
369
  // Non-object inputs can't be widened; surface them untouched.
358
370
  if (!base || typeof base !== 'object') return base
359
371
  if (!forStructuredOutput) return base
@@ -1,6 +1,7 @@
1
1
  import { normalizeToolResult } from '../../../utilities/tool-result'
2
2
  import { tanstackMetadata } from '../../../utilities/merge-metadata'
3
3
  import { isProviderExecutedToolCall } from '../../../utilities/provider-executed'
4
+ import { mergeStreams } from '../../../utilities/merge-streams'
4
5
  import type { AdapterYieldChunk } from '../../../utilities/adapter-yield-chunk'
5
6
  import {
6
7
  StandardSchemaValidationError,
@@ -18,6 +19,7 @@ import type {
18
19
  ModelMessage,
19
20
  RunFinishedEvent,
20
21
  StreamChunk,
22
+ TextOptions,
21
23
  Tool,
22
24
  ToolCall,
23
25
  ToolCallArgsEvent,
@@ -887,6 +889,9 @@ async function buildClientToolResult(
887
889
  * @param approvals - Map keyed by toolCallId (or `approval_${toolCallId}`) → ToolApprovalResolution
888
890
  * @param clientResults - Map of client-side execution results (toolCallId -> result)
889
891
  * @param createCustomEventChunk - Factory to create CustomEvent chunks (optional)
892
+ * @param toolExecution - `'parallel'` (default) prepares every call in call
893
+ * order, then starts the server tools together. `'sequential'` runs one
894
+ * call at a time. Results come back in call order either way.
890
895
  */
891
896
  export async function* executeToolCalls<TContext = unknown>(
892
897
  toolCalls: Array<ToolCall>,
@@ -902,12 +907,12 @@ export async function* executeToolCalls<TContext = unknown>(
902
907
  userContext?: TContext,
903
908
  abortSignal?: AbortSignal,
904
909
  resumeState?: ToolResumeExecutionState,
910
+ toolExecution: NonNullable<TextOptions['toolExecution']> = 'parallel',
905
911
  ): AsyncGenerator<CustomEvent | StreamChunk, ExecuteToolCallsResult, void> {
906
912
  const results: Array<ToolResult> = []
907
913
  const needsApproval: Array<ApprovalRequest> = []
908
914
  const needsClientExecution: Array<ClientToolRequest> = []
909
915
  const inputRequired: Array<McpInputRequest> = []
910
- const subagentInterrupts: Array<Interrupt> = []
911
916
 
912
917
  // Create tool lookup map
913
918
  const toolMap = new Map<string, AnyTool>()
@@ -915,6 +920,66 @@ export async function* executeToolCalls<TContext = unknown>(
915
920
  toolMap.set(tool.name, tool)
916
921
  }
917
922
 
923
+ const runsInOrder = toolExecution === 'sequential'
924
+ // Parallel mode collects the server runs here. `mergeStreams` starts them
925
+ // together after the loop.
926
+ const runs: Array<AsyncGenerator<CustomEvent | StreamChunk, void, void>> = []
927
+ // Errors thrown by a run or a before-hook. They are rethrown only after every
928
+ // started tool has finished, so no tool outlives the batch.
929
+ const failures: Array<unknown> = []
930
+ // Each call's subagent interrupts, so they come back in call order.
931
+ const interruptsByCall = new Map<string, Array<Interrupt>>()
932
+
933
+ // A tool that has not started when the run aborts never starts. It gets an
934
+ // error result, so every call of the batch still has a result.
935
+ async function* runServerTool(
936
+ toolCall: ToolCall,
937
+ tool: AnyTool,
938
+ toolName: string,
939
+ input: unknown,
940
+ context: ToolExecutionContext<TContext>,
941
+ pendingEvents: Array<CustomEvent | StreamChunk>,
942
+ ): AsyncGenerator<CustomEvent | StreamChunk, void, void> {
943
+ try {
944
+ if (abortSignal?.aborted) {
945
+ results.push({
946
+ toolCallId: toolCall.id,
947
+ toolName,
948
+ result: { error: 'Operation aborted' },
949
+ input,
950
+ state: 'output-error',
951
+ duration: 0,
952
+ })
953
+ await middlewareHooks?.onAfterToolCall?.({
954
+ toolCall,
955
+ tool,
956
+ toolName,
957
+ toolCallId: toolCall.id,
958
+ ok: false,
959
+ duration: 0,
960
+ error: new Error('Operation aborted'),
961
+ })
962
+ return
963
+ }
964
+ const interrupts: Array<Interrupt> = []
965
+ interruptsByCall.set(toolCall.id, interrupts)
966
+ yield* executeServerTool(
967
+ toolCall,
968
+ tool,
969
+ toolName,
970
+ input,
971
+ context,
972
+ pendingEvents,
973
+ results,
974
+ middlewareHooks,
975
+ inputRequired,
976
+ interrupts,
977
+ )
978
+ } catch (error) {
979
+ failures.push(error)
980
+ }
981
+ }
982
+
918
983
  // Batch gating: when any tool in the batch still needs an approval decision,
919
984
  // defer all execution so side effects don't happen before the user decides.
920
985
  const hasPendingApprovals = toolCalls.some((tc) => {
@@ -1128,52 +1193,7 @@ export async function* executeToolCalls<TContext = unknown>(
1128
1193
  const approvalId = `approval_${toolCall.id}`
1129
1194
  const resolution = approvalResolution(approvals, toolCall.id)
1130
1195
 
1131
- // Check if approval decision exists
1132
- if (resolution !== undefined) {
1133
- const approved = isApproved(resolution)
1134
-
1135
- if (approved) {
1136
- input = editedApprovalArgs(resolution) ?? input
1137
- // Apply middleware before-hook for approved tools
1138
- if (middlewareHooks) {
1139
- const decision = await applyBeforeToolCallDecision(
1140
- toolCall,
1141
- tool,
1142
- input,
1143
- toolName,
1144
- middlewareHooks,
1145
- results,
1146
- )
1147
- if (!decision.proceed) continue
1148
- input = decision.input
1149
- }
1150
-
1151
- yield* executeServerTool(
1152
- toolCall,
1153
- tool,
1154
- toolName,
1155
- input,
1156
- context,
1157
- pendingEvents,
1158
- results,
1159
- middlewareHooks,
1160
- inputRequired,
1161
- subagentInterrupts,
1162
- )
1163
- } else {
1164
- // User declined
1165
- results.push({
1166
- toolCallId: toolCall.id,
1167
- toolName,
1168
- result:
1169
- resumeState?.deniedToolResults?.get(toolCall.id) ??
1170
- deniedApprovalResult(resolution),
1171
- input,
1172
- state: 'output-error',
1173
- outcome: 'denied',
1174
- })
1175
- }
1176
- } else {
1196
+ if (resolution === undefined) {
1177
1197
  // Need approval
1178
1198
  needsApproval.push({
1179
1199
  toolCallId: toolCall.id,
@@ -1181,43 +1201,81 @@ export async function* executeToolCalls<TContext = unknown>(
1181
1201
  input,
1182
1202
  approvalId,
1183
1203
  })
1204
+ continue
1184
1205
  }
1185
- continue
1206
+ if (!isApproved(resolution)) {
1207
+ // User declined
1208
+ results.push({
1209
+ toolCallId: toolCall.id,
1210
+ toolName,
1211
+ result:
1212
+ resumeState?.deniedToolResults?.get(toolCall.id) ??
1213
+ deniedApprovalResult(resolution),
1214
+ input,
1215
+ state: 'output-error',
1216
+ outcome: 'denied',
1217
+ })
1218
+ continue
1219
+ }
1220
+ // Approved: run it like any other server tool below.
1221
+ input = editedApprovalArgs(resolution) ?? input
1186
1222
  }
1187
1223
 
1188
- // CASE 3: Normal server tool - execute immediately
1224
+ // CASE 3: Server tool, approved or with no approval
1189
1225
  if (middlewareHooks) {
1190
- const decision = await applyBeforeToolCallDecision(
1191
- toolCall,
1192
- tool,
1193
- input,
1194
- toolName,
1195
- middlewareHooks,
1196
- results,
1197
- )
1226
+ let decision: Awaited<ReturnType<typeof applyBeforeToolCallDecision>>
1227
+ try {
1228
+ decision = await applyBeforeToolCallDecision(
1229
+ toolCall,
1230
+ tool,
1231
+ input,
1232
+ toolName,
1233
+ middlewareHooks,
1234
+ results,
1235
+ )
1236
+ } catch (error) {
1237
+ // The calls prepared so far still run, as they would one at a time.
1238
+ failures.push(error)
1239
+ break
1240
+ }
1198
1241
  if (!decision.proceed) continue
1199
1242
  input = decision.input
1200
1243
  }
1201
1244
 
1202
- yield* executeServerTool(
1245
+ const run = runServerTool(
1203
1246
  toolCall,
1204
1247
  tool,
1205
1248
  toolName,
1206
1249
  input,
1207
1250
  context,
1208
1251
  pendingEvents,
1209
- results,
1210
- middlewareHooks,
1211
- inputRequired,
1212
- subagentInterrupts,
1213
1252
  )
1253
+ if (!runsInOrder) {
1254
+ runs.push(run)
1255
+ continue
1256
+ }
1257
+ yield* run
1258
+ if (failures.length > 0) break
1214
1259
  }
1215
1260
 
1261
+ yield* mergeStreams(runs)
1262
+ if (failures.length > 0) throw failures[0]
1263
+
1264
+ // Parallel tools finish in any order. The model gets the results, input
1265
+ // requests, and interrupts in the order it made the calls.
1266
+ const callOrder = new Map(toolCalls.map((tc, index) => [tc.id, index]))
1267
+ const byCallOrder = (a: { toolCallId: string }, b: { toolCallId: string }) =>
1268
+ (callOrder.get(a.toolCallId) ?? 0) - (callOrder.get(b.toolCallId) ?? 0)
1269
+ results.sort(byCallOrder)
1270
+ inputRequired.sort(byCallOrder)
1271
+
1216
1272
  return {
1217
1273
  results,
1218
1274
  needsApproval,
1219
1275
  needsClientExecution,
1220
1276
  inputRequired,
1221
- subagentInterrupts,
1277
+ subagentInterrupts: toolCalls.flatMap(
1278
+ (tc) => interruptsByCall.get(tc.id) ?? [],
1279
+ ),
1222
1280
  }
1223
1281
  }
@@ -445,7 +445,7 @@ export interface ListVoicesActivityOptions<
445
445
  * import { elevenlabsSpeech } from '@tanstack/ai-elevenlabs'
446
446
  *
447
447
  * const { voices } = await listVoices({
448
- * adapter: elevenlabsSpeech('eleven_v3'),
448
+ * adapter: elevenlabsSpeech('eleven_v4'),
449
449
  * origins: ['generated', 'cloned'],
450
450
  * })
451
451
  * ```
@@ -1,3 +1,4 @@
1
+ import { snapToDurationOption } from './snap'
1
2
  import type {
2
3
  ModelInputModalitiesByName,
3
4
  VideoGenerationOptions,
@@ -25,6 +26,12 @@ export type DurationOptions<T extends string | number | undefined> =
25
26
  }
26
27
  | { kind: 'none' }
27
28
 
29
+ /**
30
+ * Spellings of one clip length: the number `6`, the string `"6"`, or the
31
+ * template `"6s"`.
32
+ */
33
+ export type VideoDurationSpell<N extends number> = N | `${N}` | `${N}s`
34
+
28
35
  /**
29
36
  * Configuration for video adapter instances
30
37
  *
@@ -126,10 +133,15 @@ export interface VideoAdapter<
126
133
  availableDurations: () => DurationOptions<TModelDurationByName[TModel]>
127
134
 
128
135
  /**
129
- * Coerce a raw seconds value to the closest valid duration for this model.
130
- * Returns `undefined` for models with no duration field.
136
+ * Coerce `input` to the closest duration this model accepts.
137
+ * `input` may be seconds (`7`), a numeric string (`"7"`), a template
138
+ * (`"6s"`), or a keyword the model lists (`"auto"`).
139
+ * Returns `undefined` when the model has no duration field, or when
140
+ * `input` is a keyword that model does not list.
131
141
  */
132
- snapDuration: (seconds: number) => TModelDurationByName[TModel] | undefined
142
+ snapDuration: (
143
+ input: number | string,
144
+ ) => TModelDurationByName[TModel] | undefined
133
145
  }
134
146
 
135
147
  /**
@@ -208,11 +220,13 @@ export abstract class BaseVideoAdapter<
208
220
  }
209
221
 
210
222
  /**
211
- * Default implementation returns `undefined`. Adapters that have declared
212
- * their per-model duration map should override.
223
+ * Uses `availableDurations()`. Adapters that declare a duration map only
224
+ * need to override that method.
213
225
  */
214
- snapDuration(_seconds: number): TModelDurationByName[TModel] | undefined {
215
- return undefined
226
+ snapDuration(
227
+ input: number | string,
228
+ ): TModelDurationByName[TModel] | undefined {
229
+ return snapToDurationOption(input, this.availableDurations())
216
230
  }
217
231
 
218
232
  protected generateId(): string {
@@ -173,10 +173,11 @@ export type VideoCreateOptions<
173
173
  /** Video size — format depends on the provider (e.g., "16:9", "1280x720") */
174
174
  size?: VideoSizeForAdapter<TAdapter>
175
175
  /**
176
- * Video duration in seconds. Adapters that declare a per-model duration
177
- * map narrow this to the model's valid union (e.g. `4 | 6 | 8` for Veo 3).
178
- * Pass `adapter.snapDuration(seconds)` to coerce raw seconds to a valid
179
- * value.
176
+ * Video duration. Adapters that declare a per-model duration map narrow
177
+ * this to that model's union (for example `4 | 6 | 8` for Veo 3, or
178
+ * `4 | "4" | "4s"` for Sora). Pass `adapter.snapDuration(input)` to coerce
179
+ * a raw value. `input` may be seconds, a `"6s"` template, or `"auto"` when
180
+ * the model lists it.
180
181
  */
181
182
  duration?: VideoDurationForAdapter<TAdapter>
182
183
  /**
@@ -1,8 +1,38 @@
1
1
  import type { DurationOptions } from './adapter'
2
2
 
3
+ /**
4
+ * `"6"`, `"6s"`, and `"6.5s"` are seconds. Anything else (`"auto"`) is a
5
+ * keyword the model must list exactly.
6
+ */
7
+ const DURATION_TEMPLATE = /^(\d+(?:\.\d+)?)s?$/
8
+
9
+ /**
10
+ * Seconds from a caller-supplied template, or `null` when `input` is a
11
+ * keyword (`"auto"`) rather than a length.
12
+ */
13
+ function templateToSeconds(input: string): number | null {
14
+ const match = DURATION_TEMPLATE.exec(input)
15
+ const digits = match?.[1]
16
+ if (digits === undefined) return null
17
+ const seconds = Number(digits)
18
+ return Number.isFinite(seconds) ? seconds : null
19
+ }
20
+
21
+ /**
22
+ * Seconds from a duration a caller wrote: `6`, `"6"`, or `"6s"`.
23
+ * Returns `undefined` for keywords such as `"auto"` and for non-finite numbers.
24
+ */
25
+ export function durationToSeconds(input: number | string): number | undefined {
26
+ if (typeof input === 'number') {
27
+ return Number.isFinite(input) ? input : undefined
28
+ }
29
+ const seconds = templateToSeconds(input)
30
+ return seconds === null ? undefined : seconds
31
+ }
32
+
3
33
  /**
4
34
  * Extract a numeric seconds value from a `DurationOptions` entry. Returns
5
- * `null` for entries that don't parse as a number — e.g. `'auto'`.
35
+ * `null` for entries that don't parse as a number, for example `'auto'`.
6
36
  *
7
37
  * Handles the keyword-with-unit form FAL uses for Luma/Veo (`'8s'`, `'9s'`)
8
38
  * by stripping a trailing `s`. Pure-numeric strings (`'5'`, `'10'`) parse via
@@ -12,14 +42,16 @@ function entryToSeconds(entry: string | number): number | null {
12
42
  if (typeof entry === 'number') {
13
43
  return Number.isFinite(entry) ? entry : null
14
44
  }
15
- const stripped = entry.endsWith('s') ? entry.slice(0, -1) : entry
16
- const parsed = Number(stripped)
17
- return Number.isFinite(parsed) ? parsed : null
45
+ return templateToSeconds(entry)
18
46
  }
19
47
 
20
48
  /**
21
- * Snap a raw seconds value to the closest valid duration for a model's
22
- * `DurationOptions`.
49
+ * Snap a caller duration to the closest valid option.
50
+ *
51
+ * `input` may be seconds (`7`), a numeric string (`"7"`), a template
52
+ * (`"6s"`), or a keyword the model lists (`"auto"`). A keyword that is not
53
+ * in the set returns `undefined`. Equal numeric distances keep the earlier
54
+ * option.
23
55
  *
24
56
  * - `none` → `undefined`
25
57
  * - `discrete` → closest numeric-parseable entry; if none parse,
@@ -30,6 +62,32 @@ function entryToSeconds(entry: string | number): number | null {
30
62
  * @experimental Video generation is an experimental feature and may change.
31
63
  */
32
64
  export function snapToDurationOption<T extends string | number | undefined>(
65
+ input: number | string,
66
+ options: DurationOptions<T>,
67
+ ): T | undefined {
68
+ // NaN is not a length. Infinity still clamps inside snapSeconds.
69
+ if (typeof input === 'number' && Number.isNaN(input)) return undefined
70
+
71
+ if (typeof input === 'string') {
72
+ const seconds = templateToSeconds(input)
73
+ if (seconds === null) return matchKeyword(input, options)
74
+ return snapSeconds(seconds, options)
75
+ }
76
+ return snapSeconds(input, options)
77
+ }
78
+
79
+ function matchKeyword<T extends string | number | undefined>(
80
+ keyword: string,
81
+ options: DurationOptions<T>,
82
+ ): T | undefined {
83
+ if (options.kind !== 'discrete' && options.kind !== 'mixed') return undefined
84
+ for (const value of options.values) {
85
+ if (value === keyword) return value
86
+ }
87
+ return undefined
88
+ }
89
+
90
+ function snapSeconds<T extends string | number | undefined>(
33
91
  seconds: number,
34
92
  options: DurationOptions<T>,
35
93
  ): T | undefined {