@tanstack/ai 0.5.1 → 0.6.2

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.
@@ -24,6 +24,7 @@ import type { AnyTextAdapter } from './adapter'
24
24
  import type {
25
25
  AgentLoopStrategy,
26
26
  ConstrainedModelMessage,
27
+ CustomEvent,
27
28
  InferSchemaType,
28
29
  ModelMessage,
29
30
  RunFinishedEvent,
@@ -601,17 +602,30 @@ class TextEngine<
601
602
 
602
603
  const { approvals, clientToolResults } = this.collectClientState()
603
604
 
604
- const executionResult = await executeToolCalls(
605
+ const generator = executeToolCalls(
605
606
  pendingToolCalls,
606
607
  this.tools,
607
608
  approvals,
608
609
  clientToolResults,
610
+ (eventName, data) => this.createCustomEventChunk(eventName, data),
609
611
  )
610
612
 
613
+ // Consume the async generator, yielding custom events and collecting the return value
614
+ const executionResult = yield* this.drainToolCallGenerator(generator)
615
+
611
616
  if (
612
617
  executionResult.needsApproval.length > 0 ||
613
618
  executionResult.needsClientExecution.length > 0
614
619
  ) {
620
+ if (executionResult.results.length > 0) {
621
+ for (const chunk of this.emitToolResults(
622
+ executionResult.results,
623
+ finishEvent,
624
+ )) {
625
+ yield chunk
626
+ }
627
+ }
628
+
615
629
  for (const chunk of this.emitApprovalRequests(
616
630
  executionResult.needsApproval,
617
631
  finishEvent,
@@ -660,17 +674,30 @@ class TextEngine<
660
674
 
661
675
  const { approvals, clientToolResults } = this.collectClientState()
662
676
 
663
- const executionResult = await executeToolCalls(
677
+ const generator = executeToolCalls(
664
678
  toolCalls,
665
679
  this.tools,
666
680
  approvals,
667
681
  clientToolResults,
682
+ (eventName, data) => this.createCustomEventChunk(eventName, data),
668
683
  )
669
684
 
685
+ // Consume the async generator, yielding custom events and collecting the return value
686
+ const executionResult = yield* this.drainToolCallGenerator(generator)
687
+
670
688
  if (
671
689
  executionResult.needsApproval.length > 0 ||
672
690
  executionResult.needsClientExecution.length > 0
673
691
  ) {
692
+ if (executionResult.results.length > 0) {
693
+ for (const chunk of this.emitToolResults(
694
+ executionResult.results,
695
+ finishEvent,
696
+ )) {
697
+ yield chunk
698
+ }
699
+ }
700
+
674
701
  for (const chunk of this.emitApprovalRequests(
675
702
  executionResult.needsApproval,
676
703
  finishEvent,
@@ -833,7 +860,7 @@ class TextEngine<
833
860
  timestamp: Date.now(),
834
861
  model: finishEvent.model,
835
862
  name: 'approval-requested',
836
- data: {
863
+ value: {
837
864
  toolCallId: approval.toolCallId,
838
865
  toolName: approval.toolName,
839
866
  input: approval.input,
@@ -870,7 +897,7 @@ class TextEngine<
870
897
  timestamp: Date.now(),
871
898
  model: finishEvent.model,
872
899
  name: 'tool-input-available',
873
- data: {
900
+ value: {
874
901
  toolCallId: clientTool.toolCallId,
875
902
  toolName: clientTool.toolName,
876
903
  input: clientTool.input,
@@ -1052,6 +1079,50 @@ class TextEngine<
1052
1079
  }
1053
1080
  }
1054
1081
 
1082
+ /**
1083
+ * Drain an executeToolCalls async generator, yielding any CustomEvent chunks
1084
+ * and returning the final ExecuteToolCallsResult.
1085
+ */
1086
+ private async *drainToolCallGenerator(
1087
+ generator: AsyncGenerator<
1088
+ CustomEvent,
1089
+ {
1090
+ results: Array<ToolResult>
1091
+ needsApproval: Array<ApprovalRequest>
1092
+ needsClientExecution: Array<ClientToolRequest>
1093
+ },
1094
+ void
1095
+ >,
1096
+ ): AsyncGenerator<
1097
+ StreamChunk,
1098
+ {
1099
+ results: Array<ToolResult>
1100
+ needsApproval: Array<ApprovalRequest>
1101
+ needsClientExecution: Array<ClientToolRequest>
1102
+ },
1103
+ void
1104
+ > {
1105
+ let next = await generator.next()
1106
+ while (!next.done) {
1107
+ yield next.value
1108
+ next = await generator.next()
1109
+ }
1110
+ return next.value
1111
+ }
1112
+
1113
+ private createCustomEventChunk(
1114
+ eventName: string,
1115
+ value: Record<string, any>,
1116
+ ): CustomEvent {
1117
+ return {
1118
+ type: 'CUSTOM',
1119
+ timestamp: Date.now(),
1120
+ model: this.params.model,
1121
+ name: eventName,
1122
+ value,
1123
+ }
1124
+ }
1125
+
1055
1126
  private createId(prefix: string): string {
1056
1127
  return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
1057
1128
  }
@@ -243,12 +243,9 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
243
243
  for (const part of uiMessage.parts) {
244
244
  if (part.type !== 'tool-call') continue
245
245
 
246
- // Client tool with output - add as tool result (if not already emitted)
247
- if (
248
- part.output !== undefined &&
249
- !part.approval &&
250
- !emittedToolResultIds.has(part.id)
251
- ) {
246
+ // Output takes priority — if the tool has already produced a result,
247
+ // emit the concrete output regardless of approval metadata.
248
+ if (part.output !== undefined && !emittedToolResultIds.has(part.id)) {
252
249
  messageList.push({
253
250
  role: 'tool',
254
251
  content: JSON.stringify(part.output),
@@ -257,8 +254,9 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
257
254
  emittedToolResultIds.add(part.id)
258
255
  }
259
256
 
260
- // Approval response - add as tool result for iteration tracking
257
+ // Approval response without output — emit approval status for iteration tracking
261
258
  if (
259
+ part.output === undefined &&
262
260
  part.state === 'approval-responded' &&
263
261
  part.approval?.approved !== undefined &&
264
262
  !emittedToolResultIds.has(part.id)
@@ -63,8 +63,7 @@ export function updateToolCallPart(
63
63
  }
64
64
 
65
65
  const parts = [...msg.parts]
66
- // Find by ID, not index!
67
- const existingPartIndex = parts.findIndex(
66
+ const existing = parts.find(
68
67
  (p): p is ToolCallPart => p.type === 'tool-call' && p.id === toolCall.id,
69
68
  )
70
69
 
@@ -74,11 +73,14 @@ export function updateToolCallPart(
74
73
  name: toolCall.name,
75
74
  arguments: toolCall.arguments,
76
75
  state: toolCall.state,
76
+ // Carry forward approval and output from the existing part
77
+ ...(existing?.approval && { approval: { ...existing.approval } }),
78
+ ...(existing?.output !== undefined && { output: existing.output }),
77
79
  }
78
80
 
79
- if (existingPartIndex >= 0) {
81
+ if (existing) {
80
82
  // Update existing tool call
81
- parts[existingPartIndex] = toolCallPart
83
+ parts[parts.indexOf(existing)] = toolCallPart
82
84
  } else {
83
85
  // Add new tool call at the end (preserve natural streaming order)
84
86
  parts.push(toolCallPart)
@@ -73,6 +73,13 @@ export interface StreamProcessorEvents {
73
73
  approvalId: string
74
74
  }) => void
75
75
 
76
+ // Custom events from server-side tools
77
+ onCustomEvent?: (
78
+ eventType: string,
79
+ data: unknown,
80
+ context: { toolCallId?: string },
81
+ ) => void
82
+
76
83
  // Granular events for UI optimization (character-by-character, state tracking)
77
84
  onTextUpdate?: (messageId: string, content: string) => void
78
85
  onToolCallStateChange?: (
@@ -807,8 +814,8 @@ export class StreamProcessor {
807
814
  chunk: Extract<StreamChunk, { type: 'CUSTOM' }>,
808
815
  ): void {
809
816
  // Handle client tool input availability - trigger client-side execution
810
- if (chunk.name === 'tool-input-available' && chunk.data) {
811
- const { toolCallId, toolName, input } = chunk.data as {
817
+ if (chunk.name === 'tool-input-available' && chunk.value) {
818
+ const { toolCallId, toolName, input } = chunk.value as {
812
819
  toolCallId: string
813
820
  toolName: string
814
821
  input: any
@@ -820,11 +827,12 @@ export class StreamProcessor {
820
827
  toolName,
821
828
  input,
822
829
  })
830
+ return
823
831
  }
824
832
 
825
833
  // Handle approval requests
826
- if (chunk.name === 'approval-requested' && chunk.data) {
827
- const { toolCallId, toolName, input, approval } = chunk.data as {
834
+ if (chunk.name === 'approval-requested' && chunk.value) {
835
+ const { toolCallId, toolName, input, approval } = chunk.value as {
828
836
  toolCallId: string
829
837
  toolName: string
830
838
  input: any
@@ -849,6 +857,16 @@ export class StreamProcessor {
849
857
  input,
850
858
  approvalId: approval.id,
851
859
  })
860
+ return
861
+ }
862
+
863
+ // Forward non-system custom events to onCustomEvent callback
864
+ if (this.events.onCustomEvent) {
865
+ const toolCallId =
866
+ chunk.value && typeof chunk.value === 'object'
867
+ ? (chunk.value as any).toolCallId
868
+ : undefined
869
+ this.events.onCustomEvent(chunk.name, chunk.value, { toolCallId })
852
870
  }
853
871
  }
854
872
 
@@ -1,5 +1,6 @@
1
1
  import { isStandardSchema, parseWithStandardSchema } from './schema-converter'
2
2
  import type {
3
+ CustomEvent,
3
4
  ModelMessage,
4
5
  RunFinishedEvent,
5
6
  Tool,
@@ -7,6 +8,7 @@ import type {
7
8
  ToolCallArgsEvent,
8
9
  ToolCallEndEvent,
9
10
  ToolCallStartEvent,
11
+ ToolExecutionContext,
10
12
  } from '../../../types'
11
13
 
12
14
  /**
@@ -250,7 +252,46 @@ interface ExecuteToolCallsResult {
250
252
  }
251
253
 
252
254
  /**
253
- * Execute tool calls based on their configuration
255
+ * Helper that runs a tool execution promise while polling for pending custom events.
256
+ * Yields any custom events that are emitted during execution, then returns the
257
+ * execution result.
258
+ */
259
+ async function* executeWithEventPolling<T>(
260
+ executionPromise: Promise<T>,
261
+ pendingEvents: Array<CustomEvent>,
262
+ ): AsyncGenerator<CustomEvent, T, void> {
263
+ // Use an object to track mutable state across the async boundary
264
+ const state = { done: false, result: undefined as T }
265
+ const executionWithFlag = executionPromise.then((r) => {
266
+ state.done = true
267
+ state.result = r
268
+ return r
269
+ })
270
+
271
+ while (!state.done) {
272
+ // Wait for either the execution to complete or a short timeout
273
+ await Promise.race([
274
+ executionWithFlag,
275
+ new Promise((resolve) => setTimeout(resolve, 10)),
276
+ ])
277
+
278
+ // Flush any pending events
279
+ while (pendingEvents.length > 0) {
280
+ yield pendingEvents.shift()!
281
+ }
282
+ }
283
+
284
+ // Final flush in case events were emitted right at completion
285
+ while (pendingEvents.length > 0) {
286
+ yield pendingEvents.shift()!
287
+ }
288
+
289
+ return state.result
290
+ }
291
+
292
+ /**
293
+ * Execute tool calls based on their configuration.
294
+ * Yields CustomEvent chunks during tool execution for real-time progress updates.
254
295
  *
255
296
  * Handles three cases:
256
297
  * 1. Client tools (no execute) - request client to execute
@@ -261,13 +302,18 @@ interface ExecuteToolCallsResult {
261
302
  * @param tools - Available tools with their configurations
262
303
  * @param approvals - Map of approval decisions (approval.id -> approved boolean)
263
304
  * @param clientResults - Map of client-side execution results (toolCallId -> result)
305
+ * @param createCustomEventChunk - Factory to create CustomEvent chunks (optional)
264
306
  */
265
- export async function executeToolCalls(
307
+ export async function* executeToolCalls(
266
308
  toolCalls: Array<ToolCall>,
267
309
  tools: ReadonlyArray<Tool>,
268
310
  approvals: Map<string, boolean> = new Map(),
269
311
  clientResults: Map<string, any> = new Map(),
270
- ): Promise<ExecuteToolCallsResult> {
312
+ createCustomEventChunk?: (
313
+ eventName: string,
314
+ value: Record<string, any>,
315
+ ) => CustomEvent,
316
+ ): AsyncGenerator<CustomEvent, ExecuteToolCallsResult, void> {
271
317
  const results: Array<ToolResult> = []
272
318
  const needsApproval: Array<ApprovalRequest> = []
273
319
  const needsClientExecution: Array<ClientToolRequest> = []
@@ -278,6 +324,13 @@ export async function executeToolCalls(
278
324
  toolMap.set(tool.name, tool)
279
325
  }
280
326
 
327
+ // Batch gating: when any tool in the batch still needs an approval decision,
328
+ // defer all execution so side effects don't happen before the user decides.
329
+ const hasPendingApprovals = toolCalls.some((tc) => {
330
+ const t = toolMap.get(tc.function.name)
331
+ return t?.needsApproval && !approvals.has(`approval_${tc.id}`)
332
+ })
333
+
281
334
  for (const toolCall of toolCalls) {
282
335
  const tool = toolMap.get(toolCall.function.name)
283
336
  const toolName = toolCall.function.name
@@ -293,6 +346,13 @@ export async function executeToolCalls(
293
346
  continue
294
347
  }
295
348
 
349
+ // Skip non-pending tools while approvals are outstanding
350
+ if (hasPendingApprovals) {
351
+ if (!tool.needsApproval || approvals.has(`approval_${toolCall.id}`)) {
352
+ continue
353
+ }
354
+ }
355
+
296
356
  // Parse arguments, throwing error if invalid JSON
297
357
  let input: unknown = {}
298
358
  const argsStr = toolCall.function.arguments.trim() || '{}'
@@ -326,6 +386,29 @@ export async function executeToolCalls(
326
386
  }
327
387
  }
328
388
 
389
+ // Create a ToolExecutionContext for this tool call with event emission
390
+ const pendingEvents: Array<CustomEvent> = []
391
+ const context: ToolExecutionContext = {
392
+ toolCallId: toolCall.id,
393
+ emitCustomEvent: (eventName: string, value: Record<string, any>) => {
394
+ if (createCustomEventChunk) {
395
+ pendingEvents.push(
396
+ createCustomEventChunk(eventName, {
397
+ ...value,
398
+ toolCallId: toolCall.id,
399
+ }),
400
+ )
401
+ }
402
+ },
403
+ }
404
+
405
+ // Helper to flush any pending events
406
+ function* flushEvents(): Generator<CustomEvent> {
407
+ while (pendingEvents.length > 0) {
408
+ yield pendingEvents.shift()!
409
+ }
410
+ }
411
+
329
412
  // CASE 1: Client-side tool (no execute function)
330
413
  if (!tool.execute) {
331
414
  // Check if tool needs approval
@@ -402,8 +485,15 @@ export async function executeToolCalls(
402
485
  // Execute after approval
403
486
  const startTime = Date.now()
404
487
  try {
405
- let result = await tool.execute(input)
488
+ const executionPromise = Promise.resolve(
489
+ tool.execute(input, context),
490
+ )
491
+ let result = yield* executeWithEventPolling(
492
+ executionPromise,
493
+ pendingEvents,
494
+ )
406
495
  const duration = Date.now() - startTime
496
+ yield* flushEvents()
407
497
 
408
498
  // Validate output against outputSchema if provided (for Standard Schema compliant schemas)
409
499
  if (
@@ -426,6 +516,7 @@ export async function executeToolCalls(
426
516
  })
427
517
  } catch (error: unknown) {
428
518
  const duration = Date.now() - startTime
519
+ yield* flushEvents()
429
520
  const message =
430
521
  error instanceof Error ? error.message : 'Unknown error'
431
522
  results.push({
@@ -460,8 +551,13 @@ export async function executeToolCalls(
460
551
  // CASE 3: Normal server tool - execute immediately
461
552
  const startTime = Date.now()
462
553
  try {
463
- let result = await tool.execute(input)
554
+ const executionPromise = Promise.resolve(tool.execute(input, context))
555
+ let result = yield* executeWithEventPolling(
556
+ executionPromise,
557
+ pendingEvents,
558
+ )
464
559
  const duration = Date.now() - startTime
560
+ yield* flushEvents()
465
561
 
466
562
  // Validate output against outputSchema if provided (for Standard Schema compliant schemas)
467
563
  if (
@@ -482,6 +578,7 @@ export async function executeToolCalls(
482
578
  })
483
579
  } catch (error: unknown) {
484
580
  const duration = Date.now() - startTime
581
+ yield* flushEvents()
485
582
  const message = error instanceof Error ? error.message : 'Unknown error'
486
583
  results.push({
487
584
  toolCallId: toolCall.id,
@@ -4,6 +4,7 @@ import type {
4
4
  JSONSchema,
5
5
  SchemaInput,
6
6
  Tool,
7
+ ToolExecutionContext,
7
8
  } from '../../../types'
8
9
 
9
10
  /**
@@ -112,6 +113,7 @@ export interface ToolDefinition<
112
113
  server: (
113
114
  execute: (
114
115
  args: InferSchemaType<TInput>,
116
+ context?: ToolExecutionContext,
115
117
  ) => Promise<InferSchemaType<TOutput>> | InferSchemaType<TOutput>,
116
118
  ) => ServerTool<TInput, TOutput, TName>
117
119
 
@@ -193,6 +195,7 @@ export function toolDefinition<
193
195
  server(
194
196
  execute: (
195
197
  args: InferSchemaType<TInput>,
198
+ context?: ToolExecutionContext,
196
199
  ) => Promise<InferSchemaType<TOutput>> | InferSchemaType<TOutput>,
197
200
  ): ServerTool<TInput, TOutput, TName> {
198
201
  return {
package/src/types.ts CHANGED
@@ -344,6 +344,34 @@ export type ConstrainedModelMessage<
344
344
  content: ConstrainedContent<TInputModalitiesTypes>
345
345
  }
346
346
 
347
+ /**
348
+ * Context passed to tool execute functions, providing capabilities like
349
+ * emitting custom events during execution.
350
+ */
351
+ export interface ToolExecutionContext {
352
+ /** The ID of the tool call being executed */
353
+ toolCallId?: string
354
+ /**
355
+ * Emit a custom event during tool execution.
356
+ * Events are streamed to the client in real-time as AG-UI CUSTOM events.
357
+ *
358
+ * @param eventName - Name of the custom event
359
+ * @param value - Event payload value
360
+ *
361
+ * @example
362
+ * ```ts
363
+ * const tool = toolDefinition({ ... }).server(async (args, context) => {
364
+ * context?.emitCustomEvent('progress', { step: 1, total: 3 })
365
+ * // ... do work ...
366
+ * context?.emitCustomEvent('progress', { step: 2, total: 3 })
367
+ * // ... do more work ...
368
+ * return result
369
+ * })
370
+ * ```
371
+ */
372
+ emitCustomEvent: (eventName: string, value: Record<string, any>) => void
373
+ }
374
+
347
375
  /**
348
376
  * Tool/Function definition for function calling.
349
377
  *
@@ -460,7 +488,7 @@ export interface Tool<
460
488
  * return weather; // Can return object or string
461
489
  * }
462
490
  */
463
- execute?: (args: any) => Promise<any> | any
491
+ execute?: (args: any, context?: ToolExecutionContext) => Promise<any> | any
464
492
 
465
493
  /** If true, tool execution requires user approval before running. Works with both server and client tools. */
466
494
  needsApproval?: boolean
@@ -894,8 +922,8 @@ export interface CustomEvent extends BaseAGUIEvent {
894
922
  type: 'CUSTOM'
895
923
  /** Custom event name */
896
924
  name: string
897
- /** Custom event data */
898
- data?: unknown
925
+ /** Custom event value */
926
+ value?: unknown
899
927
  }
900
928
 
901
929
  /**