@tanstack/ai 0.23.1 → 0.24.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/index.d.ts +33 -9
  2. package/dist/esm/activities/chat/index.js +19 -9
  3. package/dist/esm/activities/chat/index.js.map +1 -1
  4. package/dist/esm/activities/chat/messages.js +2 -1
  5. package/dist/esm/activities/chat/messages.js.map +1 -1
  6. package/dist/esm/activities/chat/middleware/compose.d.ts +14 -14
  7. package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
  8. package/dist/esm/activities/chat/middleware/types.d.ts +16 -16
  9. package/dist/esm/activities/chat/runtime-context-types.d.ts +43 -0
  10. package/dist/esm/activities/chat/stream/message-updaters.d.ts +2 -2
  11. package/dist/esm/activities/chat/stream/message-updaters.js.map +1 -1
  12. package/dist/esm/activities/chat/stream/processor.d.ts +1 -0
  13. package/dist/esm/activities/chat/stream/processor.js +35 -12
  14. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  15. package/dist/esm/activities/chat/tools/tool-calls.d.ts +15 -5
  16. package/dist/esm/activities/chat/tools/tool-calls.js +59 -19
  17. package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
  18. package/dist/esm/activities/chat/tools/tool-definition.d.ts +12 -8
  19. package/dist/esm/activities/chat/tools/tool-definition.js.map +1 -1
  20. package/dist/esm/activities/error-payload.d.ts +26 -0
  21. package/dist/esm/activities/error-payload.js +12 -1
  22. package/dist/esm/activities/error-payload.js.map +1 -1
  23. package/dist/esm/adapter-internals.d.ts +1 -1
  24. package/dist/esm/adapter-internals.js +3 -2
  25. package/dist/esm/client.d.ts +1 -1
  26. package/dist/esm/client.js +3 -1
  27. package/dist/esm/client.js.map +1 -1
  28. package/dist/esm/index.d.ts +2 -1
  29. package/dist/esm/index.js +7 -1
  30. package/dist/esm/index.js.map +1 -1
  31. package/dist/esm/tool-registry.d.ts +7 -7
  32. package/dist/esm/tool-registry.js +1 -1
  33. package/dist/esm/tool-registry.js.map +1 -1
  34. package/dist/esm/types.d.ts +40 -8
  35. package/dist/esm/utilities/ag-ui-wire.js +1 -1
  36. package/dist/esm/utilities/ag-ui-wire.js.map +1 -1
  37. package/dist/esm/utilities/chat-params.d.ts +8 -3
  38. package/dist/esm/utilities/chat-params.js +6 -2
  39. package/dist/esm/utilities/chat-params.js.map +1 -1
  40. package/dist/esm/utilities/tool-result.d.ts +21 -0
  41. package/dist/esm/utilities/tool-result.js +37 -0
  42. package/dist/esm/utilities/tool-result.js.map +1 -0
  43. package/package.json +2 -2
  44. package/src/activities/chat/index.ts +219 -47
  45. package/src/activities/chat/messages.ts +2 -1
  46. package/src/activities/chat/middleware/compose.ts +23 -17
  47. package/src/activities/chat/middleware/types.ts +16 -16
  48. package/src/activities/chat/runtime-context-types.ts +68 -0
  49. package/src/activities/chat/stream/message-updaters.ts +2 -1
  50. package/src/activities/chat/stream/processor.ts +48 -8
  51. package/src/activities/chat/tools/tool-calls.ts +138 -43
  52. package/src/activities/chat/tools/tool-definition.ts +25 -31
  53. package/src/activities/error-payload.ts +44 -0
  54. package/src/adapter-internals.ts +4 -1
  55. package/src/client.ts +5 -1
  56. package/src/index.ts +7 -0
  57. package/src/tool-registry.ts +16 -14
  58. package/src/types.ts +86 -29
  59. package/src/utilities/ag-ui-wire.ts +4 -1
  60. package/src/utilities/chat-params.ts +22 -7
  61. package/src/utilities/tool-result.ts +60 -0
@@ -34,7 +34,7 @@ export type ChatMiddlewarePhase =
34
34
  * Stable context object passed to all middleware hooks.
35
35
  * Created once per chat() invocation and shared across all hooks.
36
36
  */
37
- export interface ChatMiddlewareContext {
37
+ export interface ChatMiddlewareContext<TContext = unknown> {
38
38
  /** Unique identifier for this chat request */
39
39
  requestId: string
40
40
  /** Unique identifier for this stream */
@@ -64,8 +64,8 @@ export interface ChatMiddlewareContext {
64
64
  signal?: AbortSignal
65
65
  /** Abort the chat run with a reason */
66
66
  abort: (reason?: string) => void
67
- /** Opaque user-provided value from chat() options */
68
- context: unknown
67
+ /** Runtime context provided by chat() options */
68
+ context: TContext
69
69
  /**
70
70
  * Defer a non-blocking side-effect promise.
71
71
  * Deferred promises do not block streaming and are awaited
@@ -343,7 +343,7 @@ export interface ErrorInfo {
343
343
  * }
344
344
  * ```
345
345
  */
346
- export interface ChatMiddleware {
346
+ export interface ChatMiddleware<TContext = unknown> {
347
347
  /** Optional name for debugging and identification */
348
348
  name?: string
349
349
 
@@ -355,7 +355,7 @@ export interface ChatMiddleware {
355
355
  * Only the fields you return are overwritten — everything else is preserved.
356
356
  */
357
357
  onConfig?: (
358
- ctx: ChatMiddlewareContext,
358
+ ctx: ChatMiddlewareContext<TContext>,
359
359
  config: ChatMiddlewareConfig,
360
360
  ) =>
361
361
  | void
@@ -379,7 +379,7 @@ export interface ChatMiddleware {
379
379
  * outputSchema or apply structured-output-specific behavior.
380
380
  */
381
381
  onStructuredOutputConfig?: (
382
- ctx: ChatMiddlewareContext,
382
+ ctx: ChatMiddlewareContext<TContext>,
383
383
  config: StructuredOutputMiddlewareConfig,
384
384
  ) =>
385
385
  | void
@@ -390,14 +390,14 @@ export interface ChatMiddleware {
390
390
  /**
391
391
  * Called when the chat run starts (after initial onConfig).
392
392
  */
393
- onStart?: (ctx: ChatMiddlewareContext) => void | Promise<void>
393
+ onStart?: (ctx: ChatMiddlewareContext<TContext>) => void | Promise<void>
394
394
 
395
395
  /**
396
396
  * Called at the start of each agent loop iteration, after a new assistant message ID
397
397
  * is created. Use this to observe iteration boundaries.
398
398
  */
399
399
  onIteration?: (
400
- ctx: ChatMiddlewareContext,
400
+ ctx: ChatMiddlewareContext<TContext>,
401
401
  info: IterationInfo,
402
402
  ) => void | Promise<void>
403
403
 
@@ -408,7 +408,7 @@ export interface ChatMiddleware {
408
408
  * @returns void (pass through), chunk (replace), chunk[] (expand), null (drop)
409
409
  */
410
410
  onChunk?: (
411
- ctx: ChatMiddlewareContext,
411
+ ctx: ChatMiddlewareContext<TContext>,
412
412
  chunk: StreamChunk,
413
413
  ) =>
414
414
  | void
@@ -422,7 +422,7 @@ export interface ChatMiddleware {
422
422
  * Can observe, transform args, skip execution, or abort the run.
423
423
  */
424
424
  onBeforeToolCall?: (
425
- ctx: ChatMiddlewareContext,
425
+ ctx: ChatMiddlewareContext<TContext>,
426
426
  hookCtx: ToolCallHookContext,
427
427
  ) => BeforeToolCallDecision | Promise<BeforeToolCallDecision>
428
428
 
@@ -430,7 +430,7 @@ export interface ChatMiddleware {
430
430
  * Called after a tool execution completes (success or failure).
431
431
  */
432
432
  onAfterToolCall?: (
433
- ctx: ChatMiddlewareContext,
433
+ ctx: ChatMiddlewareContext<TContext>,
434
434
  info: AfterToolCallInfo,
435
435
  ) => void | Promise<void>
436
436
 
@@ -439,7 +439,7 @@ export interface ChatMiddleware {
439
439
  * Provides aggregate data about tool execution results, approvals, and client tools.
440
440
  */
441
441
  onToolPhaseComplete?: (
442
- ctx: ChatMiddlewareContext,
442
+ ctx: ChatMiddlewareContext<TContext>,
443
443
  info: ToolPhaseCompleteInfo,
444
444
  ) => void | Promise<void>
445
445
 
@@ -448,7 +448,7 @@ export interface ChatMiddleware {
448
448
  * Called once per model iteration that reports usage.
449
449
  */
450
450
  onUsage?: (
451
- ctx: ChatMiddlewareContext,
451
+ ctx: ChatMiddlewareContext<TContext>,
452
452
  usage: UsageInfo,
453
453
  ) => void | Promise<void>
454
454
 
@@ -457,7 +457,7 @@ export interface ChatMiddleware {
457
457
  * Exactly one of onFinish/onAbort/onError will be called per run.
458
458
  */
459
459
  onFinish?: (
460
- ctx: ChatMiddlewareContext,
460
+ ctx: ChatMiddlewareContext<TContext>,
461
461
  info: FinishInfo,
462
462
  ) => void | Promise<void>
463
463
 
@@ -466,7 +466,7 @@ export interface ChatMiddleware {
466
466
  * Exactly one of onFinish/onAbort/onError will be called per run.
467
467
  */
468
468
  onAbort?: (
469
- ctx: ChatMiddlewareContext,
469
+ ctx: ChatMiddlewareContext<TContext>,
470
470
  info: AbortInfo,
471
471
  ) => void | Promise<void>
472
472
 
@@ -475,7 +475,7 @@ export interface ChatMiddleware {
475
475
  * Exactly one of onFinish/onAbort/onError will be called per run.
476
476
  */
477
477
  onError?: (
478
- ctx: ChatMiddlewareContext,
478
+ ctx: ChatMiddlewareContext<TContext>,
479
479
  info: ErrorInfo,
480
480
  ) => void | Promise<void>
481
481
  }
@@ -0,0 +1,68 @@
1
+ import type { ChatMiddleware } from './middleware/types'
2
+
3
+ /**
4
+ * Shared type-level helpers for inferring the runtime `context` requirement
5
+ * from typed tools and middleware.
6
+ *
7
+ * These primitives are consumed by both the chat activity options
8
+ * (`./index.ts`, which merges tool + middleware requirements) and the tool
9
+ * execution layer (`./tools/tool-calls.ts`, which only sees tools). They live
10
+ * here so the two call sites share one definition instead of maintaining
11
+ * divergent copies.
12
+ */
13
+
14
+ /** True only when `T` is exactly `unknown`. */
15
+ type IsUnknown<T> = unknown extends T
16
+ ? [T] extends [unknown]
17
+ ? true
18
+ : false
19
+ : false
20
+
21
+ /**
22
+ * Drops an `unknown` context requirement to `never` so that untyped tools and
23
+ * middleware (which default `TContext` to `unknown`) contribute no requirement
24
+ * to the merged context.
25
+ */
26
+ type KnownContext<T> = IsUnknown<T> extends true ? never : T
27
+
28
+ /**
29
+ * Merge two inferred context requirements, treating `never` as "no
30
+ * requirement". Using this instead of a raw intersection keeps a `never`
31
+ * (untyped) contributor from collapsing the whole merge to `never`.
32
+ */
33
+ export type MergeContext<TLeft, TRight> = [TLeft] extends [never]
34
+ ? TRight
35
+ : [TRight] extends [never]
36
+ ? TLeft
37
+ : TLeft & TRight
38
+
39
+ /** Collapse a union of context requirements into their intersection. */
40
+ export type UnionToIntersection<T> = [T] extends [never]
41
+ ? never
42
+ : (T extends unknown ? (value: T) => void : never) extends (
43
+ value: infer TIntersection,
44
+ ) => void
45
+ ? TIntersection
46
+ : never
47
+
48
+ /** Strip `undefined` from a context requirement. */
49
+ export type DefinedContext<T> = Exclude<T, undefined>
50
+
51
+ /**
52
+ * Extract the `context` requirement declared by a tool execute function's
53
+ * second argument, dropping `unknown` (untyped) contexts to `never`.
54
+ */
55
+ type ContextFromExecute<T> = T extends (...args: any) => any
56
+ ? NonNullable<Parameters<T>[1]> extends { context: infer TUserContext }
57
+ ? KnownContext<TUserContext>
58
+ : never
59
+ : never
60
+
61
+ /** Extract the context requirement declared by a single tool. */
62
+ export type ContextFromTool<T> = T extends { execute?: infer TExecute }
63
+ ? ContextFromExecute<TExecute>
64
+ : never
65
+
66
+ /** Extract the context requirement declared by a single middleware. */
67
+ export type ContextFromMiddleware<T> =
68
+ T extends ChatMiddleware<infer TContext> ? KnownContext<TContext> : never
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { parsePartialJSON } from './json-parser'
9
9
  import type {
10
+ ContentPart,
10
11
  StructuredOutputPart,
11
12
  ThinkingPart,
12
13
  ToolCallPart,
@@ -107,7 +108,7 @@ export function updateToolResultPart(
107
108
  messages: Array<UIMessage>,
108
109
  messageId: string,
109
110
  toolCallId: string,
110
- content: string,
111
+ content: string | Array<ContentPart>,
111
112
  state: ToolResultState,
112
113
  error?: string,
113
114
  ): Array<UIMessage> {
@@ -18,6 +18,7 @@
18
18
  * adapter contract, single-shot flows, and expected UIMessage output.
19
19
  */
20
20
  import { generateMessageId, uiMessageToModelMessages } from '../messages.js'
21
+ import { normalizeToolResult } from '../../../utilities/tool-result'
21
22
  import { defaultJSONParser } from './json-parser'
22
23
  import {
23
24
  appendStructuredOutputDelta,
@@ -321,7 +322,7 @@ export class StreamProcessor {
321
322
  )
322
323
 
323
324
  // Step 2: Create a tool-result part (for LLM conversation history)
324
- const content = typeof output === 'string' ? output : JSON.stringify(output)
325
+ const content = normalizeToolResult(output)
325
326
  const toolResultState: ToolResultState = error ? 'error' : 'complete'
326
327
 
327
328
  updatedMessages = updateToolResultPart(
@@ -1170,30 +1171,51 @@ export class StreamProcessor {
1170
1171
  // Step 1: Update the tool-call part's output field (for UI consistency
1171
1172
  // with client tools — see GitHub issue #176)
1172
1173
  let output: unknown
1173
- try {
1174
- output = JSON.parse(chunk.result)
1175
- } catch {
1174
+ if (Array.isArray(chunk.result)) {
1176
1175
  output = chunk.result
1176
+ } else {
1177
+ try {
1178
+ output = JSON.parse(chunk.result)
1179
+ } catch {
1180
+ output = chunk.result
1181
+ }
1177
1182
  }
1178
1183
  this.messages = updateToolCallWithOutput(
1179
1184
  this.messages,
1180
1185
  chunk.toolCallId,
1181
1186
  output,
1187
+ chunk.state === 'output-error' ? 'input-complete' : undefined,
1182
1188
  )
1183
1189
 
1184
1190
  // Step 2: Create/update the tool-result part (for LLM conversation history)
1185
- const resultState: ToolResultState = 'complete'
1191
+ const resultState: ToolResultState =
1192
+ chunk.state === 'output-error' ? 'error' : 'complete'
1186
1193
  this.messages = updateToolResultPart(
1187
1194
  this.messages,
1188
1195
  messageId,
1189
1196
  chunk.toolCallId,
1190
1197
  chunk.result,
1191
1198
  resultState,
1199
+ resultState === 'error'
1200
+ ? this.extractToolResultError(output)
1201
+ : undefined,
1192
1202
  )
1193
1203
  this.emitMessagesChange()
1194
1204
  }
1195
1205
  }
1196
1206
 
1207
+ private extractToolResultError(output: unknown): string {
1208
+ if (
1209
+ output &&
1210
+ typeof output === 'object' &&
1211
+ 'error' in output &&
1212
+ typeof output.error === 'string'
1213
+ ) {
1214
+ return output.error
1215
+ }
1216
+ return typeof output === 'string' ? output : 'Tool execution failed'
1217
+ }
1218
+
1197
1219
  /**
1198
1220
  * Handle TOOL_CALL_RESULT event (AG-UI spec).
1199
1221
  *
@@ -1218,16 +1240,19 @@ export class StreamProcessor {
1218
1240
  this.messages,
1219
1241
  chunk.toolCallId,
1220
1242
  output,
1243
+ chunk.state === 'output-error' ? 'input-complete' : undefined,
1221
1244
  )
1222
1245
 
1223
1246
  // Step 2: Create/update the tool-result part
1224
- const resultState: ToolResultState = 'complete'
1247
+ const resultState: ToolResultState =
1248
+ chunk.state === 'output-error' ? 'error' : 'complete'
1225
1249
  this.messages = updateToolResultPart(
1226
1250
  this.messages,
1227
1251
  messageId,
1228
1252
  chunk.toolCallId,
1229
1253
  chunk.content,
1230
1254
  resultState,
1255
+ resultState === 'error' ? this.extractToolResultError(output) : undefined,
1231
1256
  )
1232
1257
  this.emitMessagesChange()
1233
1258
  }
@@ -1274,7 +1299,10 @@ export class StreamProcessor {
1274
1299
  chunk: Extract<StreamChunk, { type: 'RUN_ERROR' }>,
1275
1300
  ): void {
1276
1301
  this.hasError = true
1277
- const runId = (chunk as any).runId as string | undefined
1302
+ const runId =
1303
+ 'runId' in chunk && typeof chunk.runId === 'string'
1304
+ ? chunk.runId
1305
+ : undefined
1278
1306
  if (runId) {
1279
1307
  this.activeRuns.delete(runId)
1280
1308
  } else {
@@ -1305,7 +1333,19 @@ export class StreamProcessor {
1305
1333
  this.emitMessagesChange()
1306
1334
  }
1307
1335
 
1308
- this.events.onError?.(new Error(errorMessage))
1336
+ // Attach the provider's structured error body (`rawEvent`) and `code` to
1337
+ // the surfaced Error so consumers can recover the upstream detail that the
1338
+ // RUN_ERROR's `message` alone discards. Both are optional and added only
1339
+ // when present, keeping the Error backward compatible.
1340
+ const error = new Error(errorMessage)
1341
+ const code = chunk.code ?? chunk.error?.code
1342
+ if (code !== undefined) {
1343
+ Object.assign(error, { code })
1344
+ }
1345
+ if (chunk.rawEvent !== undefined) {
1346
+ Object.assign(error, { rawEvent: chunk.rawEvent })
1347
+ }
1348
+ this.events.onError?.(error)
1309
1349
  }
1310
1350
 
1311
1351
  /**
@@ -1,5 +1,8 @@
1
+ import { normalizeToolResult } from '../../../utilities/tool-result'
1
2
  import { isStandardSchema, parseWithStandardSchema } from './schema-converter'
2
3
  import type {
4
+ AnyTool,
5
+ ContentPart,
3
6
  CustomEvent,
4
7
  ModelMessage,
5
8
  RunFinishedEvent,
@@ -9,11 +12,18 @@ import type {
9
12
  ToolCallEndEvent,
10
13
  ToolCallStartEvent,
11
14
  ToolExecutionContext,
15
+ ToolOutputState,
12
16
  } from '../../../types'
13
17
  import type {
14
18
  AfterToolCallInfo,
15
19
  BeforeToolCallDecision,
16
20
  } from '../middleware/types'
21
+ import type {
22
+ ContextFromTool,
23
+ DefinedContext,
24
+ MergeContext,
25
+ UnionToIntersection,
26
+ } from '../runtime-context-types'
17
27
 
18
28
  function safeJsonParse(value: string): unknown {
19
29
  try {
@@ -46,6 +56,36 @@ export class MiddlewareAbortError extends Error {
46
56
  }
47
57
  }
48
58
 
59
+ // The leaf context-inference primitives (ContextFromTool, MergeContext,
60
+ // UnionToIntersection, DefinedContext) are shared with the chat activity
61
+ // options layer — see ../runtime-context-types.
62
+ type RequiredContextFromToolUnion<T> = T extends unknown
63
+ ? undefined extends ContextFromTool<T>
64
+ ? never
65
+ : ContextFromTool<T>
66
+ : never
67
+
68
+ type ContextFromToolUnion<T> = [
69
+ UnionToIntersection<DefinedContext<ContextFromTool<T>>>,
70
+ ] extends [never]
71
+ ? unknown
72
+ : [RequiredContextFromToolUnion<T>] extends [never]
73
+ ? UnionToIntersection<DefinedContext<ContextFromTool<T>>> | undefined
74
+ : UnionToIntersection<DefinedContext<ContextFromTool<T>>>
75
+
76
+ type ContextFromTools<TTools> = TTools extends readonly [
77
+ infer THead,
78
+ ...infer TTail,
79
+ ]
80
+ ? MergeContext<ContextFromTool<THead>, ContextFromTools<TTail>>
81
+ : TTools extends ReadonlyArray<infer TTool>
82
+ ? ContextFromToolUnion<TTool>
83
+ : unknown
84
+
85
+ type ExecuteToolsContextArgs<TContext> = undefined extends TContext
86
+ ? [userContext?: TContext]
87
+ : [userContext: TContext]
88
+
49
89
  /**
50
90
  * Manages tool call accumulation and execution for the chat() method's automatic tool execution loop.
51
91
  *
@@ -80,11 +120,22 @@ export class MiddlewareAbortError extends Error {
80
120
  * }
81
121
  * ```
82
122
  */
83
- export class ToolCallManager {
123
+ export class ToolCallManager<
124
+ TToolsOrContext = ReadonlyArray<AnyTool>,
125
+ TContext = TToolsOrContext extends ReadonlyArray<AnyTool>
126
+ ? ContextFromTools<TToolsOrContext>
127
+ : TToolsOrContext,
128
+ > {
84
129
  private readonly toolCallsMap = new Map<number, ToolCall>()
85
- private readonly tools: ReadonlyArray<Tool>
86
-
87
- constructor(tools: ReadonlyArray<Tool>) {
130
+ private readonly tools: TToolsOrContext extends ReadonlyArray<AnyTool>
131
+ ? TToolsOrContext
132
+ : ReadonlyArray<AnyTool>
133
+
134
+ constructor(
135
+ tools: TToolsOrContext extends ReadonlyArray<AnyTool>
136
+ ? TToolsOrContext
137
+ : ReadonlyArray<AnyTool>,
138
+ ) {
88
139
  this.tools = tools
89
140
  }
90
141
 
@@ -161,14 +212,18 @@ export class ToolCallManager {
161
212
  */
162
213
  async *executeTools(
163
214
  finishEvent: RunFinishedEvent,
215
+ ...contextArgs: ExecuteToolsContextArgs<TContext>
164
216
  ): AsyncGenerator<ToolCallEndEvent, Array<ModelMessage>, void> {
165
217
  const toolCallsArray = this.getToolCalls()
166
218
  const toolResults: Array<ModelMessage> = []
219
+ const hasRuntimeContext = contextArgs.length > 0
220
+ const userContext = contextArgs[0]
167
221
 
168
222
  for (const toolCall of toolCallsArray) {
169
223
  const tool = this.tools.find((t) => t.name === toolCall.function.name)
170
224
 
171
- let toolResultContent: string
225
+ let toolResultContent: string | Array<ContentPart>
226
+ let toolResultState: ToolOutputState | undefined
172
227
  if (tool?.execute) {
173
228
  try {
174
229
  // Parse arguments (normalize null/non-object to {} for empty tool_use blocks)
@@ -199,15 +254,21 @@ export class ToolCallManager {
199
254
  }
200
255
 
201
256
  // Execute the tool
202
- let result = await tool.execute(args)
203
-
204
- // Validate output against outputSchema if provided (for Standard Schema compliant schemas)
205
- if (
206
- tool.outputSchema &&
207
- isStandardSchema(tool.outputSchema) &&
208
- result !== undefined &&
209
- result !== null
210
- ) {
257
+ const executionContext = {
258
+ toolCallId: toolCall.id,
259
+ context: userContext,
260
+ emitCustomEvent: () => {},
261
+ } as ToolExecutionContext<TContext>
262
+ let result = hasRuntimeContext
263
+ ? await tool.execute(args, executionContext)
264
+ : await tool.execute(args)
265
+
266
+ // Validate output against outputSchema if provided (for Standard
267
+ // Schema compliant schemas). Unlike the previous implementation we
268
+ // intentionally validate `undefined`/`null` results too, so a tool
269
+ // whose schema forbids them surfaces a validation error instead of
270
+ // silently passing — the schema itself decides whether they're valid.
271
+ if (tool.outputSchema && isStandardSchema(tool.outputSchema)) {
211
272
  try {
212
273
  result = parseWithStandardSchema(tool.outputSchema, result)
213
274
  } catch (validationError: unknown) {
@@ -221,13 +282,13 @@ export class ToolCallManager {
221
282
  }
222
283
  }
223
284
 
224
- toolResultContent =
225
- typeof result === 'string' ? result : JSON.stringify(result)
285
+ toolResultContent = normalizeToolResult(result)
226
286
  } catch (error: unknown) {
227
287
  // If tool execution fails, add error message
228
288
  const message =
229
289
  error instanceof Error ? error.message : 'Unknown error'
230
290
  toolResultContent = `Error executing tool: ${message}`
291
+ toolResultState = 'output-error'
231
292
  }
232
293
  } else {
233
294
  // Tool doesn't have execute function, add placeholder
@@ -243,6 +304,7 @@ export class ToolCallManager {
243
304
  model: finishEvent.model,
244
305
  timestamp: Date.now(),
245
306
  result: toolResultContent,
307
+ ...(toolResultState !== undefined && { state: toolResultState }),
246
308
  } as ToolCallEndEvent
247
309
 
248
310
  // Add tool result message
@@ -370,7 +432,7 @@ async function applyBeforeToolCallDecision(
370
432
  result:
371
433
  typeof skipResult === 'string'
372
434
  ? safeJsonParse(skipResult)
373
- : skipResult || null,
435
+ : (skipResult ?? null),
374
436
  duration: 0,
375
437
  })
376
438
  if (middlewareHooks.onAfterToolCall) {
@@ -394,12 +456,12 @@ async function applyBeforeToolCallDecision(
394
456
  * Execute a server-side tool with event polling, output validation, and middleware hooks.
395
457
  * Yields CustomEvent chunks during execution and pushes the result to the results array.
396
458
  */
397
- async function* executeServerTool(
459
+ async function* executeServerTool<TContext = unknown>(
398
460
  toolCall: ToolCall,
399
- tool: Tool,
461
+ tool: AnyTool,
400
462
  toolName: string,
401
463
  input: unknown,
402
- context: ToolExecutionContext,
464
+ context: ToolExecutionContext<TContext>,
403
465
  pendingEvents: Array<CustomEvent>,
404
466
  results: Array<ToolResult>,
405
467
  middlewareHooks?: ToolExecutionMiddlewareHooks,
@@ -419,18 +481,14 @@ async function* executeServerTool(
419
481
  yield pendingEvent
420
482
  }
421
483
 
422
- // Validate output against outputSchema if provided
423
- if (
424
- tool.outputSchema &&
425
- isStandardSchema(tool.outputSchema) &&
426
- result !== undefined &&
427
- result !== null
428
- ) {
484
+ // Validate output against outputSchema if provided. Validates
485
+ // `undefined`/`null` too — the schema decides whether they're valid.
486
+ if (tool.outputSchema && isStandardSchema(tool.outputSchema)) {
429
487
  result = parseWithStandardSchema(tool.outputSchema, result)
430
488
  }
431
489
 
432
490
  const finalResult =
433
- typeof result === 'string' ? safeJsonParse(result) : result || null
491
+ typeof result === 'string' ? safeJsonParse(result) : (result ?? null)
434
492
 
435
493
  results.push({
436
494
  toolCallId: toolCall.id,
@@ -486,6 +544,35 @@ async function* executeServerTool(
486
544
  }
487
545
  }
488
546
 
547
+ function buildClientToolResult(
548
+ toolCallId: string,
549
+ toolName: string,
550
+ tool: AnyTool,
551
+ rawResult: unknown,
552
+ ): ToolResult {
553
+ try {
554
+ let result = rawResult
555
+ if (tool.outputSchema && isStandardSchema(tool.outputSchema)) {
556
+ result = parseWithStandardSchema(tool.outputSchema, result)
557
+ }
558
+
559
+ return {
560
+ toolCallId,
561
+ toolName,
562
+ result:
563
+ typeof result === 'string' ? safeJsonParse(result) : (result ?? null),
564
+ }
565
+ } catch (error: unknown) {
566
+ const message = error instanceof Error ? error.message : 'Validation failed'
567
+ return {
568
+ toolCallId,
569
+ toolName,
570
+ result: { error: message },
571
+ state: 'output-error',
572
+ }
573
+ }
574
+ }
575
+
489
576
  /**
490
577
  * Execute tool calls based on their configuration.
491
578
  * Yields CustomEvent chunks during tool execution for real-time progress updates.
@@ -501,9 +588,9 @@ async function* executeServerTool(
501
588
  * @param clientResults - Map of client-side execution results (toolCallId -> result)
502
589
  * @param createCustomEventChunk - Factory to create CustomEvent chunks (optional)
503
590
  */
504
- export async function* executeToolCalls(
591
+ export async function* executeToolCalls<TContext = unknown>(
505
592
  toolCalls: Array<ToolCall>,
506
- tools: ReadonlyArray<Tool>,
593
+ tools: ReadonlyArray<AnyTool>,
507
594
  approvals: Map<string, boolean> = new Map(),
508
595
  clientResults: Map<string, any> = new Map(),
509
596
  createCustomEventChunk?: (
@@ -511,13 +598,14 @@ export async function* executeToolCalls(
511
598
  value: Record<string, any>,
512
599
  ) => CustomEvent,
513
600
  middlewareHooks?: ToolExecutionMiddlewareHooks,
601
+ userContext?: TContext,
514
602
  ): AsyncGenerator<CustomEvent, ExecuteToolCallsResult, void> {
515
603
  const results: Array<ToolResult> = []
516
604
  const needsApproval: Array<ApprovalRequest> = []
517
605
  const needsClientExecution: Array<ClientToolRequest> = []
518
606
 
519
607
  // Create tool lookup map
520
- const toolMap = new Map<string, Tool>()
608
+ const toolMap = new Map<string, AnyTool>()
521
609
  for (const tool of tools) {
522
610
  toolMap.set(tool.name, tool)
523
611
  }
@@ -588,8 +676,9 @@ export async function* executeToolCalls(
588
676
 
589
677
  // Create a ToolExecutionContext for this tool call with event emission
590
678
  const pendingEvents: Array<CustomEvent> = []
591
- const context: ToolExecutionContext = {
679
+ const context = {
592
680
  toolCallId: toolCall.id,
681
+ context: userContext,
593
682
  emitCustomEvent: (eventName: string, value: Record<string, any>) => {
594
683
  if (createCustomEventChunk) {
595
684
  pendingEvents.push(
@@ -600,7 +689,7 @@ export async function* executeToolCalls(
600
689
  )
601
690
  }
602
691
  },
603
- }
692
+ } as ToolExecutionContext<TContext>
604
693
 
605
694
  // CASE 1: Client-side tool (no execute function)
606
695
  if (!tool.execute) {
@@ -615,11 +704,14 @@ export async function* executeToolCalls(
615
704
  if (approved) {
616
705
  // Approved - check if client has executed
617
706
  if (clientResults.has(toolCall.id)) {
618
- results.push({
619
- toolCallId: toolCall.id,
620
- toolName,
621
- result: clientResults.get(toolCall.id),
622
- })
707
+ results.push(
708
+ buildClientToolResult(
709
+ toolCall.id,
710
+ toolName,
711
+ tool,
712
+ clientResults.get(toolCall.id),
713
+ ),
714
+ )
623
715
  } else {
624
716
  // Approved but not executed yet - request client execution
625
717
  needsClientExecution.push({
@@ -649,11 +741,14 @@ export async function* executeToolCalls(
649
741
  } else {
650
742
  // No approval needed - check if client has executed
651
743
  if (clientResults.has(toolCall.id)) {
652
- results.push({
653
- toolCallId: toolCall.id,
654
- toolName,
655
- result: clientResults.get(toolCall.id),
656
- })
744
+ results.push(
745
+ buildClientToolResult(
746
+ toolCall.id,
747
+ toolName,
748
+ tool,
749
+ clientResults.get(toolCall.id),
750
+ ),
751
+ )
657
752
  } else {
658
753
  // Request client execution
659
754
  needsClientExecution.push({