@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
@@ -240,6 +240,11 @@ export interface TextActivityOptions<
240
240
  abortController?: TextOptions['abortController']
241
241
  /** Strategy for controlling the agent loop */
242
242
  agentLoopStrategy?: TextOptions['agentLoopStrategy']
243
+ /**
244
+ * Cap how many tool calls from a single model turn are executed.
245
+ * Excess calls receive error results. See {@link TextOptions.maxToolCallsPerTurn}.
246
+ */
247
+ maxToolCallsPerTurn?: TextOptions['maxToolCallsPerTurn']
243
248
  /**
244
249
  * Optional configuration for lazy-tool discovery (tools marked `lazy: true`).
245
250
  * Tunes how much of each lazy tool's description appears in the discovery
@@ -473,6 +478,23 @@ interface TextEngineConfig<
473
478
  type ToolPhaseResult = 'continue' | 'stop' | 'wait'
474
479
  type CyclePhase = 'processText' | 'executeToolCalls'
475
480
 
481
+ /**
482
+ * Validate and normalize `maxToolCallsPerTurn`.
483
+ * Unset → unlimited. `0` → execute none. Negatives / non-finite → throw
484
+ * (Array#slice treats negatives as "from end", which is not a useful cap).
485
+ */
486
+ function resolveMaxToolCallsPerTurn(
487
+ cap: number | undefined,
488
+ ): number | undefined {
489
+ if (cap == null) return undefined
490
+ if (!Number.isFinite(cap) || cap < 0) {
491
+ throw new Error(
492
+ `maxToolCallsPerTurn must be a non-negative finite number, got ${cap}`,
493
+ )
494
+ }
495
+ return Math.floor(cap)
496
+ }
497
+
476
498
  /**
477
499
  * Combine two optional AbortSignals into one that aborts when either does.
478
500
  * Returns the other signal directly when one is absent or already aborted.
@@ -519,6 +541,12 @@ class TextEngine<
519
541
 
520
542
  private messages: Array<ModelMessage>
521
543
  private iterationCount = 0
544
+ /** Cumulative tool calls counted in this run (emitted + pending resume). */
545
+ private toolCallCount = 0
546
+ /** Tool calls in the most recent budgeted batch (0 when none). */
547
+ private lastTurnToolCallCount = 0
548
+ /** Tool call IDs already counted toward `toolCallCount` (avoids double-count on resume). */
549
+ private readonly countedToolCallIds = new Set<string>()
522
550
  private lastFinishReason: string | null = null
523
551
  private streamStartTime = 0
524
552
  private totalChunkCount = 0
@@ -534,6 +562,7 @@ class TextEngine<
534
562
  private earlyTermination = false
535
563
  private toolPhase: ToolPhaseResult = 'continue'
536
564
  private cyclePhase: CyclePhase = 'processText'
565
+ private readonly maxToolCallsPerTurn: number | undefined
537
566
  // Client state extracted from initial messages (before conversion to ModelMessage)
538
567
  private readonly initialApprovals: Map<string, boolean>
539
568
  private readonly initialClientToolResults: Map<string, any>
@@ -599,6 +628,9 @@ class TextEngine<
599
628
  this.systemPrompts = config.params.systemPrompts || []
600
629
  this.loopStrategy =
601
630
  config.params.agentLoopStrategy || maxIterationsStrategy(5)
631
+ this.maxToolCallsPerTurn = resolveMaxToolCallsPerTurn(
632
+ config.params.maxToolCallsPerTurn,
633
+ )
602
634
  this.initialMessageCount = config.params.messages.length
603
635
 
604
636
  // Extract client state (approvals, client tool results) from original messages BEFORE conversion
@@ -1302,9 +1334,14 @@ class TextEngine<
1302
1334
 
1303
1335
  const finishEvent = this.createSyntheticFinishedEvent()
1304
1336
 
1337
+ // Same fan-out budget as live model turns (seeded history / resume).
1338
+ // Count is deduped so wait→resume after a live turn does not double-count.
1339
+ const { toExecute: budgetedToolCalls, skippedResults } =
1340
+ this.applyToolCallBudget(pendingToolCalls)
1341
+
1305
1342
  // Handle undiscovered lazy tool calls with self-correcting error messages
1306
1343
  const undiscoveredLazyResults: Array<ToolResult> = []
1307
- const executablePendingCalls = pendingToolCalls.filter((tc) => {
1344
+ const executablePendingCalls = budgetedToolCalls.filter((tc) => {
1308
1345
  if (this.lazyToolManager.isUndiscoveredLazyTool(tc.function.name)) {
1309
1346
  undiscoveredLazyResults.push({
1310
1347
  toolCallId: tc.id,
@@ -1321,16 +1358,27 @@ class TextEngine<
1321
1358
  return true
1322
1359
  })
1323
1360
 
1324
- if (undiscoveredLazyResults.length > 0) {
1325
- for (const chunk of this.buildToolResultChunks(
1326
- undiscoveredLazyResults,
1327
- finishEvent,
1328
- )) {
1329
- yield* this.pipeThroughMiddleware(chunk)
1330
- }
1361
+ // Non-executed outcomes (undiscovered lazy + per-turn fan-out skips).
1362
+ // Emitted after executed results so the stream prefers real results first.
1363
+ const deferredErrorResults = [...undiscoveredLazyResults, ...skippedResults]
1364
+
1365
+ // Build args lookup so buildToolResultChunks can emit TOOL_CALL_START +
1366
+ // TOOL_CALL_ARGS before TOOL_CALL_END during continuation re-executions.
1367
+ const argsMap = new Map<string, string>()
1368
+ for (const tc of pendingToolCalls) {
1369
+ argsMap.set(tc.id, tc.function.arguments)
1331
1370
  }
1332
1371
 
1333
1372
  if (executablePendingCalls.length === 0) {
1373
+ if (deferredErrorResults.length > 0) {
1374
+ for (const chunk of this.buildToolResultChunks(
1375
+ deferredErrorResults,
1376
+ finishEvent,
1377
+ argsMap,
1378
+ )) {
1379
+ yield* this.pipeThroughMiddleware(chunk)
1380
+ }
1381
+ }
1334
1382
  return 'continue'
1335
1383
  }
1336
1384
 
@@ -1384,28 +1432,23 @@ class TextEngine<
1384
1432
  return 'stop'
1385
1433
  }
1386
1434
 
1435
+ const allResults = [...executionResult.results, ...deferredErrorResults]
1436
+
1387
1437
  // Notify middleware of tool phase completion (devtools emits aggregate events here)
1388
1438
  await this.middlewareRunner.runOnToolPhaseComplete(this.middlewareCtx, {
1389
1439
  toolCalls: pendingToolCalls,
1390
- results: executionResult.results,
1440
+ results: allResults,
1391
1441
  needsApproval: executionResult.needsApproval,
1392
1442
  needsClientExecution: executionResult.needsClientExecution,
1393
1443
  })
1394
1444
 
1395
- // Build args lookup so buildToolResultChunks can emit TOOL_CALL_START +
1396
- // TOOL_CALL_ARGS before TOOL_CALL_END during continuation re-executions.
1397
- const argsMap = new Map<string, string>()
1398
- for (const tc of pendingToolCalls) {
1399
- argsMap.set(tc.id, tc.function.arguments)
1400
- }
1401
-
1402
1445
  if (
1403
1446
  executionResult.needsApproval.length > 0 ||
1404
1447
  executionResult.needsClientExecution.length > 0
1405
1448
  ) {
1406
- if (executionResult.results.length > 0) {
1449
+ if (allResults.length > 0) {
1407
1450
  for (const chunk of this.buildToolResultChunks(
1408
- executionResult.results,
1451
+ allResults,
1409
1452
  finishEvent,
1410
1453
  argsMap,
1411
1454
  )) {
@@ -1432,7 +1475,7 @@ class TextEngine<
1432
1475
  }
1433
1476
 
1434
1477
  const toolResultChunks = this.buildToolResultChunks(
1435
- executionResult.results,
1478
+ allResults,
1436
1479
  finishEvent,
1437
1480
  argsMap,
1438
1481
  )
@@ -1446,6 +1489,8 @@ class TextEngine<
1446
1489
 
1447
1490
  private async *processToolCalls(): AsyncGenerator<StreamChunk, void, void> {
1448
1491
  if (!this.shouldExecuteToolPhase()) {
1492
+ // Text-only turn — clear per-turn count so strategies see 0 tools.
1493
+ this.lastTurnToolCallCount = 0
1449
1494
  this.setToolPhase('stop')
1450
1495
  return
1451
1496
  }
@@ -1454,15 +1499,20 @@ class TextEngine<
1454
1499
  const finishEvent = this.finishedEvent
1455
1500
 
1456
1501
  if (!finishEvent || toolCalls.length === 0) {
1502
+ this.lastTurnToolCallCount = 0
1457
1503
  this.setToolPhase('stop')
1458
1504
  return
1459
1505
  }
1460
1506
 
1507
+ // Count every model-emitted tool call (including ones we may skip).
1508
+ const { toExecute: budgetedToolCalls, skippedResults } =
1509
+ this.applyToolCallBudget(toolCalls)
1510
+
1461
1511
  this.addAssistantToolCallMessage(toolCalls)
1462
1512
 
1463
1513
  // Handle undiscovered lazy tool calls with self-correcting error messages
1464
1514
  const undiscoveredLazyResults: Array<ToolResult> = []
1465
- const executableToolCalls = toolCalls.filter((tc) => {
1515
+ const executableToolCalls = budgetedToolCalls.filter((tc) => {
1466
1516
  if (this.lazyToolManager.isUndiscoveredLazyTool(tc.function.name)) {
1467
1517
  undiscoveredLazyResults.push({
1468
1518
  toolCallId: tc.id,
@@ -1479,17 +1529,21 @@ class TextEngine<
1479
1529
  return true
1480
1530
  })
1481
1531
 
1482
- if (undiscoveredLazyResults.length > 0 && this.finishedEvent) {
1483
- for (const chunk of this.buildToolResultChunks(
1484
- undiscoveredLazyResults,
1485
- this.finishedEvent,
1486
- )) {
1487
- yield* this.pipeThroughMiddleware(chunk)
1488
- }
1489
- }
1532
+ // Non-executed outcomes (undiscovered lazy + per-turn fan-out skips).
1533
+ // Emitted after executed results so the stream prefers real results first.
1534
+ const deferredErrorResults = [...undiscoveredLazyResults, ...skippedResults]
1490
1535
 
1491
1536
  if (executableToolCalls.length === 0) {
1492
- // All tool calls were undiscovered lazy tools — errors emitted, continue loop
1537
+ // All tool calls were undiscovered lazy tools and/or skipped by the
1538
+ // per-turn fan-out cap — errors emitted, continue loop (strategy may stop).
1539
+ if (deferredErrorResults.length > 0) {
1540
+ for (const chunk of this.buildToolResultChunks(
1541
+ deferredErrorResults,
1542
+ finishEvent,
1543
+ )) {
1544
+ yield* this.pipeThroughMiddleware(chunk)
1545
+ }
1546
+ }
1493
1547
  this.toolCallManager.clear()
1494
1548
  this.setToolPhase('continue')
1495
1549
  return
@@ -1548,10 +1602,13 @@ class TextEngine<
1548
1602
  return
1549
1603
  }
1550
1604
 
1605
+ // Executed results first, then deferred errors (fan-out skips / undiscovered)
1606
+ const allResults = [...executionResult.results, ...deferredErrorResults]
1607
+
1551
1608
  // Notify middleware of tool phase completion (devtools emits aggregate events here)
1552
1609
  await this.middlewareRunner.runOnToolPhaseComplete(this.middlewareCtx, {
1553
1610
  toolCalls,
1554
- results: executionResult.results,
1611
+ results: allResults,
1555
1612
  needsApproval: executionResult.needsApproval,
1556
1613
  needsClientExecution: executionResult.needsClientExecution,
1557
1614
  })
@@ -1560,9 +1617,9 @@ class TextEngine<
1560
1617
  executionResult.needsApproval.length > 0 ||
1561
1618
  executionResult.needsClientExecution.length > 0
1562
1619
  ) {
1563
- if (executionResult.results.length > 0) {
1620
+ if (allResults.length > 0) {
1564
1621
  for (const chunk of this.buildToolResultChunks(
1565
- executionResult.results,
1622
+ allResults,
1566
1623
  finishEvent,
1567
1624
  )) {
1568
1625
  yield* this.pipeThroughMiddleware(chunk)
@@ -1587,10 +1644,7 @@ class TextEngine<
1587
1644
  return
1588
1645
  }
1589
1646
 
1590
- const toolResultChunks = this.buildToolResultChunks(
1591
- executionResult.results,
1592
- finishEvent,
1593
- )
1647
+ const toolResultChunks = this.buildToolResultChunks(allResults, finishEvent)
1594
1648
 
1595
1649
  for (const chunk of toolResultChunks) {
1596
1650
  yield* this.pipeThroughMiddleware(chunk)
@@ -1943,10 +1997,64 @@ class TextEngine<
1943
1997
  iterationCount: this.iterationCount,
1944
1998
  messages: this.messages,
1945
1999
  finishReason: this.lastFinishReason,
2000
+ toolCallCount: this.toolCallCount,
2001
+ lastTurnToolCallCount: this.lastTurnToolCallCount,
1946
2002
  }) && this.toolPhase === 'continue'
1947
2003
  )
1948
2004
  }
1949
2005
 
2006
+ /**
2007
+ * Record tool calls (deduped by id) and return the subset that should be
2008
+ * executed after applying `maxToolCallsPerTurn`. Excess calls get synthetic
2009
+ * error results so every tool_call still has a matching result.
2010
+ *
2011
+ * Used for both live model turns and pending/resume batches. IDs already
2012
+ * counted in this run (e.g. wait→resume after a live turn) are not
2013
+ * re-added to `toolCallCount`.
2014
+ */
2015
+ private applyToolCallBudget(toolCalls: Array<ToolCall>): {
2016
+ toExecute: Array<ToolCall>
2017
+ skippedResults: Array<ToolResult>
2018
+ } {
2019
+ this.lastTurnToolCallCount = toolCalls.length
2020
+ let newlyCounted = 0
2021
+ for (const tc of toolCalls) {
2022
+ if (!this.countedToolCallIds.has(tc.id)) {
2023
+ this.countedToolCallIds.add(tc.id)
2024
+ newlyCounted++
2025
+ }
2026
+ }
2027
+ this.toolCallCount += newlyCounted
2028
+
2029
+ const cap = this.maxToolCallsPerTurn
2030
+ if (cap == null || toolCalls.length <= cap) {
2031
+ return { toExecute: toolCalls, skippedResults: [] }
2032
+ }
2033
+
2034
+ this.logger.agentLoop(
2035
+ `maxToolCallsPerTurn=${cap} skipped=${toolCalls.length - cap}`,
2036
+ {
2037
+ maxToolCallsPerTurn: cap,
2038
+ emitted: toolCalls.length,
2039
+ skipped: toolCalls.length - cap,
2040
+ },
2041
+ )
2042
+
2043
+ const toExecute = toolCalls.slice(0, cap)
2044
+ const skippedResults: Array<ToolResult> = toolCalls
2045
+ .slice(cap)
2046
+ .map((tc) => ({
2047
+ toolCallId: tc.id,
2048
+ toolName: tc.function.name,
2049
+ result: {
2050
+ error: `Skipped: exceeded maxToolCallsPerTurn (${cap})`,
2051
+ },
2052
+ state: 'output-error' as const,
2053
+ }))
2054
+
2055
+ return { toExecute, skippedResults }
2056
+ }
2057
+
1950
2058
  private isAborted(): boolean {
1951
2059
  return !!this.effectiveSignal?.aborted
1952
2060
  }
@@ -444,12 +444,21 @@ export function modelMessageToUIMessage(
444
444
  // Handle tool calls
445
445
  if (modelMessage.toolCalls && modelMessage.toolCalls.length > 0) {
446
446
  for (const toolCall of modelMessage.toolCalls) {
447
+ // Model-message arguments are complete, so surface the parsed input.
448
+ // A malformed arguments string just leaves `input` undefined.
449
+ let input: unknown
450
+ try {
451
+ input = JSON.parse(toolCall.function.arguments)
452
+ } catch {
453
+ input = undefined
454
+ }
447
455
  parts.push({
448
456
  type: 'tool-call',
449
457
  id: toolCall.id,
450
458
  name: toolCall.function.name,
451
459
  arguments: toolCall.function.arguments,
452
460
  state: 'input-complete', // Model messages have complete arguments
461
+ ...(input !== undefined && { input }),
453
462
  ...(toolCall.metadata !== undefined && { metadata: toolCall.metadata }),
454
463
  })
455
464
  }
@@ -58,6 +58,8 @@ export function updateToolCallPart(
58
58
  name: string
59
59
  arguments: string
60
60
  state: ToolCallState
61
+ /** Parsed input — set when the arguments are complete. */
62
+ input?: unknown
61
63
  metadata?: Record<string, unknown>
62
64
  },
63
65
  ): Array<UIMessage> {
@@ -76,6 +78,9 @@ export function updateToolCallPart(
76
78
  // Gemini's thoughtSignature on TOOL_CALL_START) we must not lose it on
77
79
  // subsequent updates that don't re-supply it.
78
80
  const metadata = toolCall.metadata ?? existing?.metadata
81
+ // Same for the parsed input: it's supplied once at completion, so
82
+ // subsequent arg-less updates (approval, etc.) must not drop it.
83
+ const input = toolCall.input ?? existing?.input
79
84
 
80
85
  const toolCallPart: ToolCallPart = {
81
86
  type: 'tool-call',
@@ -83,9 +88,10 @@ export function updateToolCallPart(
83
88
  name: toolCall.name,
84
89
  arguments: toolCall.arguments,
85
90
  state: toolCall.state,
86
- // Carry forward approval and output from the existing part
91
+ // Carry forward approval, output and parsed input from the existing part
87
92
  ...(existing?.approval && { approval: { ...existing.approval } }),
88
93
  ...(existing?.output !== undefined && { output: existing.output }),
94
+ ...(input !== undefined && { input }),
89
95
  ...(metadata !== undefined && { metadata }),
90
96
  }
91
97
 
@@ -1298,15 +1298,35 @@ export class StreamProcessor {
1298
1298
  // received, back-fill the arguments string so the UIMessage ToolCallPart
1299
1299
  // carries the correct value (defensive against adapters that skip ARGS).
1300
1300
  if (chunk.input !== undefined && !existingToolCall.arguments) {
1301
- existingToolCall.arguments = JSON.stringify(chunk.input)
1301
+ try {
1302
+ existingToolCall.arguments = JSON.stringify(chunk.input)
1303
+ } catch {
1304
+ // circular refs, BigInt, etc. — leave arguments empty rather than
1305
+ // aborting stream processing
1306
+ }
1302
1307
  }
1303
1308
 
1304
1309
  const index = msgState.toolCallOrder.indexOf(chunk.toolCallId)
1305
1310
  this.completeToolCall(messageId, index, existingToolCall)
1306
1311
  // If TOOL_CALL_END provides parsed input, use it as the canonical parsed
1307
1312
  // arguments (overrides the accumulated string parse from completeToolCall)
1313
+ // and refresh the rendered part's `input` so it reflects the canonical
1314
+ // value rather than the possibly-divergent accumulated-args parse that
1315
+ // completeToolCall wrote (e.g. an adapter that coerces values differently
1316
+ // between the streamed args and the final structured input).
1308
1317
  if (chunk.input !== undefined) {
1309
1318
  existingToolCall.parsedArguments = chunk.input
1319
+ this.messages = updateToolCallPart(this.messages, messageId, {
1320
+ id: existingToolCall.id,
1321
+ name: existingToolCall.name,
1322
+ arguments: existingToolCall.arguments,
1323
+ state: 'input-complete',
1324
+ input: chunk.input,
1325
+ ...(existingToolCall.metadata !== undefined && {
1326
+ metadata: existingToolCall.metadata,
1327
+ }),
1328
+ })
1329
+ this.emitMessagesChange()
1310
1330
  }
1311
1331
  }
1312
1332
 
@@ -1890,12 +1910,20 @@ export class StreamProcessor {
1890
1910
  return
1891
1911
  }
1892
1912
 
1893
- // Update UIMessage
1913
+ // Update UIMessage. The arguments are complete now, so surface the parsed
1914
+ // input on the part. For adapters that skip TOOL_CALL_ARGS the arguments
1915
+ // string was back-filled from TOOL_CALL_END.input, so this parse matches
1916
+ // the canonical input. If a TOOL_CALL_END.input diverges from the
1917
+ // accumulated args, handleToolCallEndEvent re-updates the part with the
1918
+ // canonical value after this call.
1894
1919
  this.messages = updateToolCallPart(this.messages, messageId, {
1895
1920
  id: toolCall.id,
1896
1921
  name: toolCall.name,
1897
1922
  arguments: toolCall.arguments,
1898
1923
  state: 'input-complete',
1924
+ ...(toolCall.parsedArguments !== undefined && {
1925
+ input: toolCall.parsedArguments,
1926
+ }),
1899
1927
  ...(toolCall.metadata !== undefined && { metadata: toolCall.metadata }),
1900
1928
  })
1901
1929
  this.emitMessagesChange()
@@ -26,6 +26,10 @@ export interface ClientTool<
26
26
  TOutput extends SchemaInput = SchemaInput,
27
27
  TName extends string = string,
28
28
  TContext = unknown,
29
+ // Captured as a literal (`true` / `false`) so downstream types — notably
30
+ // the tool-call part's `approval` field — can be gated on it. Defaults to
31
+ // `false` when the tool config omits `needsApproval`.
32
+ TNeedsApproval extends boolean = false,
29
33
  > {
30
34
  __toolSide: 'client'
31
35
  name: TName
@@ -37,7 +41,7 @@ export interface ClientTool<
37
41
  // because `undefined` doesn't extend the schema constraint.
38
42
  inputSchema?: TInput
39
43
  outputSchema?: TOutput
40
- needsApproval?: boolean
44
+ needsApproval?: TNeedsApproval
41
45
  lazy?: boolean
42
46
  metadata?: Record<string, unknown>
43
47
  execute?: ToolExecuteFunction<TInput, TOutput, TContext>
@@ -51,18 +55,22 @@ export interface ToolDefinitionInstance<
51
55
  TOutput extends SchemaInput = SchemaInput,
52
56
  TName extends string = string,
53
57
  TContext = unknown,
58
+ TNeedsApproval extends boolean = false,
54
59
  > extends Tool<TInput, TOutput, TName, TContext> {
55
60
  __toolSide: 'definition'
61
+ // Narrow the base `needsApproval?: boolean` to the captured literal so it
62
+ // survives into `ToolCallPartForTool`'s approval gate.
63
+ needsApproval?: TNeedsApproval
56
64
  }
57
65
 
58
66
  /**
59
67
  * Union type for any kind of client-side tool (client tool or definition)
60
68
  */
61
69
  export type AnyClientTool =
62
- | (Omit<ClientTool<any, any, string, any>, 'execute'> & {
70
+ | (Omit<ClientTool<any, any, string, any, boolean>, 'execute'> & {
63
71
  execute?: ((args: any, context?: any) => any) | undefined
64
72
  })
65
- | (Omit<ToolDefinitionInstance<any, any, string, any>, 'execute'> & {
73
+ | (Omit<ToolDefinitionInstance<any, any, string, any, boolean>, 'execute'> & {
66
74
  execute?: ((args: any, context?: any) => any) | undefined
67
75
  })
68
76
 
@@ -100,12 +108,13 @@ export interface ToolDefinitionConfig<
100
108
  TInput extends SchemaInput = SchemaInput,
101
109
  TOutput extends SchemaInput = SchemaInput,
102
110
  TName extends string = string,
111
+ TNeedsApproval extends boolean = false,
103
112
  > {
104
113
  name: TName
105
114
  description: string
106
115
  inputSchema?: TInput
107
116
  outputSchema?: TOutput
108
- needsApproval?: boolean
117
+ needsApproval?: TNeedsApproval
109
118
  lazy?: boolean
110
119
  metadata?: Record<string, unknown>
111
120
  }
@@ -117,7 +126,14 @@ export interface ToolDefinition<
117
126
  TInput extends SchemaInput = SchemaInput,
118
127
  TOutput extends SchemaInput = SchemaInput,
119
128
  TName extends string = string,
120
- > extends ToolDefinitionInstance<TInput, TOutput, TName> {
129
+ TNeedsApproval extends boolean = false,
130
+ > extends ToolDefinitionInstance<
131
+ TInput,
132
+ TOutput,
133
+ TName,
134
+ unknown,
135
+ TNeedsApproval
136
+ > {
121
137
  /**
122
138
  * Create a server-side tool with execute function
123
139
  */
@@ -126,11 +142,13 @@ export interface ToolDefinition<
126
142
  ) => ServerTool<TInput, TOutput, TName, TContext>
127
143
 
128
144
  /**
129
- * Create a client-side tool with optional execute function
145
+ * Create a client-side tool with optional execute function.
146
+ * Carries the definition's `needsApproval` literal through to the client
147
+ * tool so the tool-call part's `approval` field stays gated on it.
130
148
  */
131
149
  client: <TContext = unknown>(
132
150
  execute?: ToolExecuteFunction<TInput, TOutput, TContext>,
133
- ) => ClientTool<TInput, TOutput, TName, TContext>
151
+ ) => ClientTool<TInput, TOutput, TName, TContext, TNeedsApproval>
134
152
  }
135
153
 
136
154
  /**
@@ -192,10 +210,14 @@ export function toolDefinition<
192
210
  TInput extends SchemaInput = SchemaInput,
193
211
  TOutput extends SchemaInput = SchemaInput,
194
212
  TName extends string = string,
213
+ // `const` forces the literal (`true` / `false`) to be captured from the
214
+ // config's optional `needsApproval` — without it TS widens to `boolean`,
215
+ // which collapses the approval gate in `ToolCallPartForTool`.
216
+ const TNeedsApproval extends boolean = false,
195
217
  >(
196
- config: ToolDefinitionConfig<TInput, TOutput, TName>,
197
- ): ToolDefinition<TInput, TOutput, TName> {
198
- const definition: ToolDefinition<TInput, TOutput, TName> = {
218
+ config: ToolDefinitionConfig<TInput, TOutput, TName, TNeedsApproval>,
219
+ ): ToolDefinition<TInput, TOutput, TName, TNeedsApproval> {
220
+ const definition: ToolDefinition<TInput, TOutput, TName, TNeedsApproval> = {
199
221
  __toolSide: 'definition',
200
222
  ...config,
201
223
  server<TContext = unknown>(
@@ -210,7 +232,7 @@ export function toolDefinition<
210
232
 
211
233
  client<TContext = unknown>(
212
234
  execute?: ToolExecuteFunction<TInput, TOutput, TContext>,
213
- ): ClientTool<TInput, TOutput, TName, TContext> {
235
+ ): ClientTool<TInput, TOutput, TName, TContext, TNeedsApproval> {
214
236
  return {
215
237
  __toolSide: 'client',
216
238
  ...config,
@@ -14,10 +14,10 @@ import {
14
14
  runGenerationFinish,
15
15
  runGenerationStart,
16
16
  runGenerationUsage,
17
- } from '../middleware'
17
+ } from '../middleware/run'
18
18
  import type { InternalLogger } from '../../logger/internal-logger'
19
19
  import type { DebugOption } from '../../logger/types'
20
- import type { GenerationMiddleware } from '../middleware'
20
+ import type { GenerationMiddleware } from '../middleware/types'
21
21
  import type { AudioAdapter } from './adapter'
22
22
  import type { AudioGenerationResult, StreamChunk } from '../../types'
23
23
 
@@ -14,11 +14,11 @@ import {
14
14
  runGenerationFinish,
15
15
  runGenerationStart,
16
16
  runGenerationUsage,
17
- } from '../middleware'
17
+ } from '../middleware/run'
18
18
  import { resolveMediaPrompt } from '../../utilities/media-prompt'
19
19
  import type { InternalLogger } from '../../logger/internal-logger'
20
20
  import type { DebugOption } from '../../logger/types'
21
- import type { GenerationMiddleware } from '../middleware'
21
+ import type { GenerationMiddleware } from '../middleware/types'
22
22
  import type { ImageAdapter } from './adapter'
23
23
  import type {
24
24
  ImageGenerationResult,
@@ -14,10 +14,10 @@ import {
14
14
  runGenerationFinish,
15
15
  runGenerationStart,
16
16
  runGenerationUsage,
17
- } from '../middleware'
17
+ } from '../middleware/run'
18
18
  import type { InternalLogger } from '../../logger/internal-logger'
19
19
  import type { DebugOption } from '../../logger/types'
20
- import type { GenerationMiddleware } from '../middleware'
20
+ import type { GenerationMiddleware } from '../middleware/types'
21
21
  import type { TTSAdapter } from './adapter'
22
22
  import type { StreamChunk, TTSResult } from '../../types'
23
23
 
@@ -14,10 +14,10 @@ import {
14
14
  runGenerationFinish,
15
15
  runGenerationStart,
16
16
  runGenerationUsage,
17
- } from '../middleware'
17
+ } from '../middleware/run'
18
18
  import type { InternalLogger } from '../../logger/internal-logger'
19
19
  import type { DebugOption } from '../../logger/types'
20
- import type { GenerationMiddleware } from '../middleware'
20
+ import type { GenerationMiddleware } from '../middleware/types'
21
21
  import type { TranscriptionAdapter } from './adapter'
22
22
  import type {
23
23
  StreamChunk,