@tanstack/ai 0.59.0 → 0.63.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 (123) hide show
  1. package/README.md +1 -0
  2. package/dist/esm/activities/chat/adapter.d.ts +9 -0
  3. package/dist/esm/activities/chat/adapter.js +1 -0
  4. package/dist/esm/activities/chat/adapter.js.map +1 -1
  5. package/dist/esm/activities/chat/agents/define-agent.d.ts +17 -5
  6. package/dist/esm/activities/chat/agents/define-agent.js.map +1 -1
  7. package/dist/esm/activities/chat/agents/spawn.d.ts +2 -0
  8. package/dist/esm/activities/chat/agents/spawn.js +8 -5
  9. package/dist/esm/activities/chat/agents/spawn.js.map +1 -1
  10. package/dist/esm/activities/chat/index.js +289 -78
  11. package/dist/esm/activities/chat/index.js.map +1 -1
  12. package/dist/esm/activities/chat/messages.js +35 -19
  13. package/dist/esm/activities/chat/messages.js.map +1 -1
  14. package/dist/esm/activities/chat/middleware/types.d.ts +1 -0
  15. package/dist/esm/activities/chat/middleware/types.js.map +1 -1
  16. package/dist/esm/activities/chat/stream/message-updaters.d.ts +2 -2
  17. package/dist/esm/activities/chat/stream/message-updaters.js +11 -3
  18. package/dist/esm/activities/chat/stream/message-updaters.js.map +1 -1
  19. package/dist/esm/activities/chat/stream/processor.d.ts +11 -10
  20. package/dist/esm/activities/chat/stream/processor.js +51 -28
  21. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  22. package/dist/esm/activities/chat/tools/tool-calls.d.ts +16 -2
  23. package/dist/esm/activities/chat/tools/tool-calls.js +57 -16
  24. package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
  25. package/dist/esm/activities/chat/tools/tool-definition.d.ts +4 -0
  26. package/dist/esm/activities/chat/tools/tool-definition.js +4 -0
  27. package/dist/esm/activities/chat/tools/tool-definition.js.map +1 -1
  28. package/dist/esm/activities/embed/adapter.d.ts +7 -0
  29. package/dist/esm/activities/embed/adapter.js +1 -0
  30. package/dist/esm/activities/embed/adapter.js.map +1 -1
  31. package/dist/esm/activities/embed/index.js +2 -0
  32. package/dist/esm/activities/embed/index.js.map +1 -1
  33. package/dist/esm/activities/evaluate/adapter.d.ts +4 -0
  34. package/dist/esm/activities/evaluate/adapter.js.map +1 -1
  35. package/dist/esm/activities/evaluate/index.d.ts +4 -0
  36. package/dist/esm/activities/evaluate/index.js +3 -1
  37. package/dist/esm/activities/evaluate/index.js.map +1 -1
  38. package/dist/esm/activities/files/adapter.d.ts +97 -0
  39. package/dist/esm/activities/files/adapter.js +45 -0
  40. package/dist/esm/activities/files/adapter.js.map +1 -0
  41. package/dist/esm/activities/files/index.d.ts +66 -0
  42. package/dist/esm/activities/files/index.js +78 -0
  43. package/dist/esm/activities/files/index.js.map +1 -0
  44. package/dist/esm/activities/generateImage/adapter.d.ts +8 -0
  45. package/dist/esm/activities/generateImage/adapter.js +1 -0
  46. package/dist/esm/activities/generateImage/adapter.js.map +1 -1
  47. package/dist/esm/activities/generateImage/index.js +2 -0
  48. package/dist/esm/activities/generateImage/index.js.map +1 -1
  49. package/dist/esm/activities/generateVideo/adapter.d.ts +8 -0
  50. package/dist/esm/activities/generateVideo/adapter.js +1 -0
  51. package/dist/esm/activities/generateVideo/adapter.js.map +1 -1
  52. package/dist/esm/activities/generateVideo/index.js +3 -0
  53. package/dist/esm/activities/generateVideo/index.js.map +1 -1
  54. package/dist/esm/activities/generateWorld/adapter.d.ts +4 -2
  55. package/dist/esm/activities/generateWorld/adapter.js.map +1 -1
  56. package/dist/esm/activities/generateWorld/index.d.ts +4 -3
  57. package/dist/esm/activities/generateWorld/index.js +5 -4
  58. package/dist/esm/activities/generateWorld/index.js.map +1 -1
  59. package/dist/esm/activities/index.d.ts +6 -3
  60. package/dist/esm/activities/index.js +13 -11
  61. package/dist/esm/activities/summarize/chat-stream-summarize.d.ts +2 -0
  62. package/dist/esm/activities/summarize/chat-stream-summarize.js +8 -8
  63. package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -1
  64. package/dist/esm/client.d.ts +3 -1
  65. package/dist/esm/client.js +2 -1
  66. package/dist/esm/client.js.map +1 -1
  67. package/dist/esm/index.d.ts +4 -3
  68. package/dist/esm/index.js +5 -3
  69. package/dist/esm/interrupt-resume.js +29 -4
  70. package/dist/esm/interrupt-resume.js.map +1 -1
  71. package/dist/esm/middlewares/otel.d.ts +5 -2
  72. package/dist/esm/middlewares/otel.js +114 -0
  73. package/dist/esm/middlewares/otel.js.map +1 -1
  74. package/dist/esm/types.d.ts +114 -14
  75. package/dist/esm/utilities/ag-ui-wire.js +39 -16
  76. package/dist/esm/utilities/ag-ui-wire.js.map +1 -1
  77. package/dist/esm/utilities/content-source.d.ts +60 -0
  78. package/dist/esm/utilities/content-source.js +85 -0
  79. package/dist/esm/utilities/content-source.js.map +1 -0
  80. package/dist/esm/utilities/provider-executed.d.ts +7 -0
  81. package/dist/esm/utilities/provider-executed.js +10 -1
  82. package/dist/esm/utilities/provider-executed.js.map +1 -1
  83. package/dist/esm/utilities/tool-result.d.ts +14 -3
  84. package/dist/esm/utilities/tool-result.js +26 -3
  85. package/dist/esm/utilities/tool-result.js.map +1 -1
  86. package/package.json +4 -4
  87. package/skills/ai-core/adapter-configuration/SKILL.md +62 -0
  88. package/skills/ai-core/chat-experience/SKILL.md +134 -0
  89. package/skills/ai-core/media-generation/SKILL.md +8 -0
  90. package/skills/ai-core/tool-calling/SKILL.md +103 -0
  91. package/src/activities/chat/adapter.ts +10 -0
  92. package/src/activities/chat/agents/define-agent.ts +20 -3
  93. package/src/activities/chat/agents/spawn.ts +20 -12
  94. package/src/activities/chat/index.ts +458 -99
  95. package/src/activities/chat/messages.ts +52 -6
  96. package/src/activities/chat/middleware/types.ts +1 -0
  97. package/src/activities/chat/stream/message-updaters.ts +27 -2
  98. package/src/activities/chat/stream/processor.ts +81 -49
  99. package/src/activities/chat/tools/tool-calls.ts +104 -9
  100. package/src/activities/chat/tools/tool-definition.ts +8 -0
  101. package/src/activities/embed/adapter.ts +7 -0
  102. package/src/activities/embed/index.ts +5 -0
  103. package/src/activities/evaluate/adapter.ts +4 -0
  104. package/src/activities/evaluate/index.ts +6 -0
  105. package/src/activities/files/adapter.ts +120 -0
  106. package/src/activities/files/index.ts +113 -0
  107. package/src/activities/generateImage/adapter.ts +8 -0
  108. package/src/activities/generateImage/index.ts +4 -0
  109. package/src/activities/generateVideo/adapter.ts +8 -0
  110. package/src/activities/generateVideo/index.ts +7 -0
  111. package/src/activities/generateWorld/adapter.ts +4 -2
  112. package/src/activities/generateWorld/index.ts +7 -6
  113. package/src/activities/index.ts +25 -1
  114. package/src/activities/summarize/chat-stream-summarize.ts +22 -12
  115. package/src/client.ts +8 -0
  116. package/src/index.ts +17 -0
  117. package/src/interrupt-resume.ts +55 -4
  118. package/src/middlewares/otel.ts +161 -3
  119. package/src/types.ts +114 -14
  120. package/src/utilities/ag-ui-wire.ts +72 -17
  121. package/src/utilities/content-source.ts +138 -0
  122. package/src/utilities/provider-executed.ts +13 -0
  123. package/src/utilities/tool-result.ts +45 -3
@@ -74,6 +74,13 @@ export interface VideoAdapter<
74
74
  readonly kind: 'video'
75
75
  /** Adapter name identifier */
76
76
  readonly name: string
77
+ /**
78
+ * Declares that this adapter can consume `{ type: 'file' }` content
79
+ * sources (provider Files API references). The activity dispatcher rejects
80
+ * file sources in preflight for adapters that don't declare this, so
81
+ * adapters written before the file arm existed fail closed.
82
+ */
83
+ readonly supportsFileSources?: boolean
77
84
  /** The model this adapter is configured for */
78
85
  readonly model: TModel
79
86
 
@@ -161,6 +168,7 @@ export abstract class BaseVideoAdapter<
161
168
  > {
162
169
  readonly kind = 'video' as const
163
170
  abstract readonly name: string
171
+ readonly supportsFileSources: boolean = false
164
172
  readonly model: TModel
165
173
 
166
174
  // Type-only property - never assigned at runtime
@@ -12,6 +12,7 @@
12
12
  import { aiEventClient } from '@tanstack/ai-event-client'
13
13
  import { toRunErrorPayload } from '../error-payload'
14
14
  import { resolveDebugOption } from '../../logger/resolve'
15
+ import { assertPromptFileSourceSupport } from '../../utilities/content-source'
15
16
  import {
16
17
  applyGenerationResultTransforms,
17
18
  createGenerationContext,
@@ -452,6 +453,9 @@ async function runCreateVideoJob<
452
453
  timeout,
453
454
  abortSignal: callerAbortSignal,
454
455
  } = options
456
+ // Fail closed on `{ type: 'file' }` sources for adapters that haven't
457
+ // declared support (see assertPromptFileSourceSupport).
458
+ assertPromptFileSourceSupport(adapter, prompt)
455
459
  const model = adapter.model
456
460
  const requestId = createId('video')
457
461
  const startTime = Date.now()
@@ -580,6 +584,9 @@ async function* runStreamingVideoGeneration<
580
584
  timeout,
581
585
  abortSignal: callerAbortSignal,
582
586
  } = options
587
+ // Fail closed on `{ type: 'file' }` sources for adapters that haven't
588
+ // declared support (see assertPromptFileSourceSupport).
589
+ assertPromptFileSourceSupport(adapter, prompt)
583
590
  const model = adapter.model
584
591
  const runId = options.runId ?? createId('run')
585
592
  const requestId = createId('video')
@@ -44,10 +44,12 @@ export interface WorldAdapter<
44
44
  }
45
45
 
46
46
  /**
47
- * Open a world session from a prompt.
47
+ * Create a world from a prompt.
48
48
  *
49
- * Server adapters typically mint a short-lived token and return it with the
49
+ * Live session adapters mint a short-lived token and return it with the
50
50
  * prompt so a browser can connect, set the prompt, and start streaming.
51
+ * Job adapters start generation and return a world URL (or an operation
52
+ * id while the job is still running).
51
53
  */
52
54
  createWorld: (
53
55
  options: WorldGenerationOptions<TProviderOptions>,
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * World Activity (Experimental)
3
3
  *
4
- * Mints a session token for a live, prompt-steerable world. Unlike
5
- * generateVideo (a job that finishes with a URL), the browser then connects
6
- * with the token, sets the prompt, and streams until pause/reset/close.
4
+ * Live adapters mint a session token for a prompt-steerable world. Job
5
+ * adapters start generation and return a viewer URL, or an operation id
6
+ * while the job is still running.
7
7
  *
8
8
  * @experimental World generation is an experimental feature and may change.
9
9
  */
@@ -76,7 +76,7 @@ export interface WorldActivityOptions<
76
76
  /** Provider-specific options for world generation */
77
77
  modelOptions?: WorldProviderOptions<TAdapter>
78
78
  /**
79
- * Whether to wrap the token result as StreamChunks for SSE transport.
79
+ * Whether to wrap the result as StreamChunks for SSE transport.
80
80
  * This is not the live video. When false or omitted, returns
81
81
  * Promise<WorldGenerationResult>.
82
82
  *
@@ -100,7 +100,7 @@ export interface WorldActivityOptions<
100
100
  /** Stable run id for correlating this run when persisted. */
101
101
  runId?: string
102
102
  /**
103
- * Maximum duration of the token mint in milliseconds.
103
+ * Maximum wait for the mint or job poll, in milliseconds.
104
104
  * No SDK-wide default. Composed with {@link abortSignal}; the first abort wins.
105
105
  */
106
106
  timeout?: number
@@ -135,7 +135,8 @@ function createId(prefix: string): string {
135
135
  // ===========================
136
136
 
137
137
  /**
138
- * World generation activity - opens a live, prompt-steerable world session.
138
+ * World generation activity. Live adapters mint a session token. Job
139
+ * adapters return a viewer URL or an in-progress operation id.
139
140
  *
140
141
  * @example Mint a session token on the server
141
142
  * ```ts
@@ -27,6 +27,7 @@ import type { AnyRerankAdapter } from './rerank/adapter'
27
27
  import type { AnyEvaluateAdapter } from './evaluate/adapter'
28
28
  import type { AnyWorldAdapter } from './generateWorld/adapter'
29
29
  import type { AnyLiveVideoAdapter } from './generateLiveVideo/adapter'
30
+ import type { AnyFilesAdapter } from './files/adapter'
30
31
 
31
32
  // ===========================
32
33
  // Chat Activity
@@ -330,11 +331,32 @@ export {
330
331
  type AnyLiveVideoAdapter,
331
332
  } from './generateLiveVideo/adapter'
332
333
 
334
+ // ===========================
335
+ // Files Activity
336
+ // ===========================
337
+
338
+ export {
339
+ kind as filesKind,
340
+ uploadFile,
341
+ getFile,
342
+ deleteFile,
343
+ fileSourceFromHandle,
344
+ } from './files/index'
345
+
346
+ export {
347
+ BaseFilesAdapter,
348
+ normalizeFileUploadInput,
349
+ type FilesAdapter,
350
+ type AnyFilesAdapter,
351
+ type FileHandle,
352
+ type FileUploadInput,
353
+ } from './files/adapter'
354
+
333
355
  // ===========================
334
356
  // Adapter Union Types
335
357
  // ===========================
336
358
 
337
- /** Union of all adapter types that can be passed to chat() */
359
+ /** Union of all adapter types across every activity kind */
338
360
  export type AIAdapter =
339
361
  | AnyTextAdapter
340
362
  | AnySummarizeAdapter
@@ -349,6 +371,7 @@ export type AIAdapter =
349
371
  | AnyEvaluateAdapter
350
372
  | AnyWorldAdapter
351
373
  | AnyLiveVideoAdapter
374
+ | AnyFilesAdapter
352
375
 
353
376
  /** Union type of all adapter kinds */
354
377
  export type AdapterKind =
@@ -365,3 +388,4 @@ export type AdapterKind =
365
388
  | 'evaluate'
366
389
  | 'world'
367
390
  | 'liveVideo'
391
+ | 'files'
@@ -65,6 +65,8 @@ function throwRunError(
65
65
  * `SummarizationOptions<TProviderOptions>` on the wrapper itself.
66
66
  */
67
67
  export interface ChatStreamCapable {
68
+ /** Native token-limit option for adapters whose provider name is user-defined. */
69
+ readonly maxTokensKey?: string
68
70
  chatStream: (options: TextOptions<any>) => AsyncIterable<AdapterYieldChunk>
69
71
  }
70
72
 
@@ -151,8 +153,8 @@ function applyDefaultTemperature(
151
153
 
152
154
  /**
153
155
  * Resolve `maxLength` to the provider-native max-output-tokens key for the
154
- * given summarize-adapter `name` (this wrapper's OWN `name`, not the wrapped
155
- * text adapter's) and merge it into a working copy of the caller's
156
+ * wrapped text adapter's explicit key, falling back to this wrapper's `name`,
157
+ * and merge it into a working copy of the caller's
156
158
  * `modelOptions`. The caller always wins: if they already set any recognised
157
159
  * token-limit key (flat or, for Ollama, nested `options.num_predict`), the
158
160
  * default is left untouched. Unknown/unrecognised adapter names fall back to
@@ -172,10 +174,11 @@ function applyMaxLength(
172
174
  adapterName: string,
173
175
  maxLength: number,
174
176
  modelOptions: Record<string, unknown>,
177
+ maxTokensKey?: string,
175
178
  ): Record<string, unknown> {
176
179
  const merged: Record<string, unknown> = { ...modelOptions }
177
180
 
178
- if (adapterName === 'ollama') {
181
+ if (adapterName === 'ollama' && maxTokensKey === undefined) {
179
182
  // Honor a caller-set limit in either shape: a recognised flat key (e.g.
180
183
  // left over from a migration) or the nested `options.num_predict`.
181
184
  const callerSetFlatLimit = KNOWN_MAX_TOKENS_KEYS.some(
@@ -195,12 +198,12 @@ function applyMaxLength(
195
198
  return merged
196
199
  }
197
200
 
198
- const key = MAX_TOKENS_KEY_BY_ADAPTER[adapterName]
201
+ const key = maxTokensKey ?? MAX_TOKENS_KEY_BY_ADAPTER[adapterName]
199
202
  if (key === undefined) return merged
200
203
 
201
- const callerSetLimit = KNOWN_MAX_TOKENS_KEYS.some(
202
- (k) => typeof merged[k] === 'number',
203
- )
204
+ const callerSetLimit =
205
+ typeof merged[key] === 'number' ||
206
+ KNOWN_MAX_TOKENS_KEYS.some((k) => typeof merged[k] === 'number')
204
207
  if (callerSetLimit) return merged
205
208
 
206
209
  merged[key] = maxLength
@@ -372,17 +375,24 @@ export class ChatStreamSummarizeAdapter<
372
375
  working = applyDefaultTemperature(this.name, 0.3, working)
373
376
  // `maxLength` must reach the wire under the provider-native token key (it
374
377
  // differs per provider, and no adapter reads a generic `maxTokens`).
375
- // Resolve it from this summarize adapter's `name` (the constructor arg,
376
- // not the wrapped text adapter's name), never overriding a caller-supplied
377
- // token limit.
378
+ // Prefer the wrapped adapter's explicit key, then this wrapper's name,
379
+ // never overriding a caller-supplied token limit.
378
380
  if (options.maxLength !== undefined) {
379
- if (!isKnownMaxTokensAdapter(this.name)) {
381
+ if (
382
+ this.textAdapter.maxTokensKey === undefined &&
383
+ !isKnownMaxTokensAdapter(this.name)
384
+ ) {
380
385
  options.logger.warn(
381
386
  `summarize: maxLength=${options.maxLength} could not be mapped to a provider token key for adapter name "${this.name}" — it was dropped from modelOptions (the prompt still asks the model to stay under it). Construct ChatStreamSummarizeAdapter with a recognised provider name to forward the cap.`,
382
387
  { provider: this.name },
383
388
  )
384
389
  }
385
- working = applyMaxLength(this.name, options.maxLength, working)
390
+ working = applyMaxLength(
391
+ this.name,
392
+ options.maxLength,
393
+ working,
394
+ this.textAdapter.maxTokensKey,
395
+ )
386
396
  }
387
397
  const modelOptions = working as TProviderOptions
388
398
 
package/src/client.ts CHANGED
@@ -329,6 +329,13 @@ export type { AdapterYieldChunk } from './utilities/adapter-yield-chunk'
329
329
  export { getChunkRunId, getChunkThreadId } from './utilities/chunk-ids'
330
330
  export type { WireMessage } from './utilities/ag-ui-wire'
331
331
 
332
+ // A browser client that received an uploaded handle from its server can build
333
+ // the `{ type: 'file' }` content source itself — `fileSourceFromHandle` is a
334
+ // pure object builder, so exporting it here keeps the documented client flow
335
+ // from pulling in the server entry.
336
+ export { fileSourceFromHandle } from './activities/files/index'
337
+ export type { FileHandle } from './activities/files/adapter'
338
+
332
339
  export type {
333
340
  AudioPart,
334
341
  ContentPart,
@@ -364,6 +371,7 @@ export type {
364
371
  ThinkingPart,
365
372
  ToolCall,
366
373
  ToolCallPart,
374
+ ToolResultOutcome,
367
375
  ToolResultPart,
368
376
  UIMessage,
369
377
  UIResourcePart,
package/src/index.ts CHANGED
@@ -18,6 +18,10 @@ export {
18
18
  embed,
19
19
  generateWorld,
20
20
  generateLiveVideo,
21
+ uploadFile,
22
+ getFile,
23
+ deleteFile,
24
+ fileSourceFromHandle,
21
25
  } from './activities/index'
22
26
 
23
27
  // Create options functions - for pre-defining typed configurations
@@ -70,6 +74,10 @@ export type {
70
74
  WorldAdapter,
71
75
  AnyLiveVideoAdapter,
72
76
  LiveVideoAdapter,
77
+ FilesAdapter,
78
+ AnyFilesAdapter,
79
+ FileHandle,
80
+ FileUploadInput,
73
81
  } from './activities/index'
74
82
 
75
83
  // Rerank adapter base + types
@@ -527,8 +535,17 @@ export type { WireMessage } from './utilities/ag-ui-wire'
527
535
  export {
528
536
  isContentPart,
529
537
  isContentPartArray,
538
+ isToolResultOutcome,
530
539
  normalizeToolResult,
531
540
  } from './utilities/tool-result'
541
+ export {
542
+ assertMessagesFileSourceSupport,
543
+ assertPromptFileSourceSupport,
544
+ fileReferenceFor,
545
+ isFileSource,
546
+ unsupportedFileSourceError,
547
+ type FileSourceCapable,
548
+ } from './utilities/content-source'
532
549
 
533
550
  export {
534
551
  getProviderExecutedMetadata,
@@ -14,6 +14,7 @@ import {
14
14
  isStandardSchema,
15
15
  validateWithStandardSchema,
16
16
  } from './activities/chat/tools/schema-converter'
17
+ import { tanstackMetadata } from './utilities/merge-metadata'
17
18
  import type {
18
19
  InterruptBinding,
19
20
  InterruptSubmissionError,
@@ -95,6 +96,33 @@ function stringField(
95
96
  return typeof value[key] === 'string' ? value[key] : undefined
96
97
  }
97
98
 
99
+ type ClientToolResumeResult =
100
+ | { state: 'output-available'; output: unknown }
101
+ | { state: 'output-error'; errorText: string }
102
+
103
+ function clientToolResult(
104
+ entry: RunAgentResumeItem,
105
+ ): ClientToolResumeResult | null {
106
+ if (tanstackMetadata(entry)?.state === 'output-error') {
107
+ const result = objectValue(entry.payload)
108
+ if (
109
+ !result ||
110
+ Object.keys(result).length !== 1 ||
111
+ typeof result.error !== 'string'
112
+ ) {
113
+ return null
114
+ }
115
+ return {
116
+ state: 'output-error',
117
+ errorText: result.error,
118
+ }
119
+ }
120
+ return {
121
+ state: 'output-available',
122
+ output: entry.payload,
123
+ }
124
+ }
125
+
98
126
  function normalizeIssuePath(
99
127
  path: ReadonlyArray<unknown> | undefined,
100
128
  ): ReadonlyArray<string | number> | undefined {
@@ -528,7 +556,19 @@ export async function validateInterruptResumeBatch(
528
556
  if (schemaDrifted) continue
529
557
 
530
558
  if (binding.kind === 'client-tool-execution') {
531
- if (responseSchema !== undefined) {
559
+ const result = clientToolResult(entry)
560
+ if (!result) {
561
+ errors.push(
562
+ interruptItemError(
563
+ input,
564
+ record.interruptId,
565
+ 'invalid-tool-output',
566
+ `Tool ${binding.toolName} result is invalid.`,
567
+ ),
568
+ )
569
+ continue
570
+ }
571
+ if (result.state === 'output-available' && responseSchema !== undefined) {
532
572
  await pushSchemaIssues({
533
573
  request: input,
534
574
  errors,
@@ -539,13 +579,16 @@ export async function validateInterruptResumeBatch(
539
579
  label: `Tool ${binding.toolName} output is invalid`,
540
580
  })
541
581
  }
542
- if (tool.outputSchema !== undefined) {
582
+ if (
583
+ result.state === 'output-available' &&
584
+ tool.outputSchema !== undefined
585
+ ) {
543
586
  await pushSchemaIssues({
544
587
  request: input,
545
588
  errors,
546
589
  interruptId: record.interruptId,
547
590
  schema: tool.outputSchema,
548
- value: entry.payload,
591
+ value: result.output,
549
592
  code: 'invalid-tool-output',
550
593
  label: `Tool ${binding.toolName} output is invalid`,
551
594
  })
@@ -682,6 +725,7 @@ export async function validateInterruptResumeBatch(
682
725
  const canonical = canonicalizeInterruptResolutions(input.resume ?? [])
683
726
  const approvals = new Map<string, ToolApprovalResolution>()
684
727
  const clientToolResults = new Map<string, unknown>()
728
+ const clientToolErrors = new Map<string, string>()
685
729
  const genericInterrupts = new Map<
686
730
  string,
687
731
  | { interruptId: string; status: 'resolved'; payload: unknown }
@@ -738,7 +782,13 @@ export async function validateInterruptResumeBatch(
738
782
  continue
739
783
  }
740
784
  if (binding.kind === 'client-tool-execution') {
741
- clientToolResults.set(binding.toolCallId, entry.payload)
785
+ const result = clientToolResult(entry)
786
+ if (!result) continue
787
+ if (result.state === 'output-error') {
788
+ clientToolErrors.set(binding.toolCallId, result.errorText)
789
+ } else {
790
+ clientToolResults.set(binding.toolCallId, result.output)
791
+ }
742
792
  continue
743
793
  }
744
794
  const envelope = objectValue(entry.payload)
@@ -781,6 +831,7 @@ export async function validateInterruptResumeBatch(
781
831
  resumeToolState: {
782
832
  approvals,
783
833
  clientToolResults,
834
+ clientToolErrors,
784
835
  genericInterrupts,
785
836
  deniedToolResults,
786
837
  cancelledToolCallIds,
@@ -108,7 +108,9 @@ export interface OtelMiddlewareOptions {
108
108
  meter?: Meter
109
109
  /**
110
110
  * When `true`, prompt and completion content is attached to iteration spans
111
- * as `gen_ai.*.message` / `gen_ai.choice` events. Defaults to `false` so
111
+ * as `gen_ai.*.message` / `gen_ai.choice` events. Media spans get the
112
+ * prompt, input media and output (URLs or transcript text) as
113
+ * `gen_ai.input.messages` / `gen_ai.output.messages`. Defaults to `false` so
112
114
  * that PII never lands on a span by accident.
113
115
  */
114
116
  captureContent?: boolean
@@ -121,7 +123,8 @@ export interface OtelMiddlewareOptions {
121
123
  redact?: (text: string) => string
122
124
  /**
123
125
  * Maximum characters kept in the per-iteration assistant text buffer used
124
- * to emit `gen_ai.choice` events. Extra characters are truncated with a
126
+ * to emit `gen_ai.choice` events, and in each text part of a media span's
127
+ * input and output. Extra characters are truncated with a
125
128
  * trailing `"…"` marker. Defaults to 100 000. Set to `0` to disable the
126
129
  * cap. Exporters typically truncate long attribute values anyway.
127
130
  */
@@ -221,6 +224,111 @@ function serializeContent(content: unknown): string {
221
224
  return parts.join(' ')
222
225
  }
223
226
 
227
+ type InputPart =
228
+ | { type: 'text'; content: string }
229
+ | { type: 'uri'; modality: string; uri: string; mime_type?: string }
230
+ | { type: 'file'; modality: string; file_id: string; mime_type?: string }
231
+
232
+ /**
233
+ * Structured form of `ContentPart[]` for `gen_ai.input.messages`, using the
234
+ * OTel GenAI semconv part shapes. URL and file-handle media keep their
235
+ * reference; inline bytes (and `data:` URLs) stay a `[type]` placeholder so
236
+ * they never blow attribute size limits. `redact` runs on text parts only.
237
+ */
238
+ function serializeParts(
239
+ content: Array<unknown>,
240
+ redact: (text: string) => string,
241
+ ): Array<InputPart> {
242
+ const parts: Array<InputPart> = []
243
+ for (const part of content) {
244
+ if (!part || typeof part !== 'object') continue
245
+ const p = part as {
246
+ type?: string
247
+ text?: string
248
+ content?: string
249
+ source?: { type?: string; value?: string; mimeType?: string }
250
+ }
251
+ if (p.type === 'text') {
252
+ parts.push({
253
+ type: 'text',
254
+ content: redact((p.text ?? p.content ?? '').toString()),
255
+ })
256
+ continue
257
+ }
258
+ const modality = p.type ?? 'unknown'
259
+ const { type, value, mimeType } = p.source ?? {}
260
+ const mime = mimeType ? { mime_type: mimeType } : {}
261
+ if (type === 'url' && value && !value.startsWith('data:')) {
262
+ parts.push({ type: 'uri', modality, uri: value, ...mime })
263
+ } else if (type === 'file' && value) {
264
+ parts.push({ type: 'file', modality, file_id: value, ...mime })
265
+ } else {
266
+ parts.push({ type: 'text', content: `[${modality}]` })
267
+ }
268
+ }
269
+ return parts
270
+ }
271
+
272
+ /** A media reference as a content part, so `serializeParts` can map it. */
273
+ function mediaPart(modality: string, url: unknown): unknown {
274
+ return {
275
+ type: modality,
276
+ source: typeof url === 'string' ? { type: 'url', value: url } : undefined,
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Content parts for a media call's inputs, read from `artifactInputs`: the
282
+ * prompt (a string or `MediaPrompt` parts), TTS `text`, and transcription
283
+ * `audio`. Audio is a reference only when it is an http(s) URL; a base64
284
+ * string, `File` or `Blob` becomes a placeholder.
285
+ */
286
+ function mediaInputParts(inputs: unknown): Array<unknown> {
287
+ if (!inputs || typeof inputs !== 'object') return []
288
+ const { prompt, text, audio } = inputs as Record<string, unknown>
289
+ const parts: Array<unknown> = []
290
+ for (const value of [prompt, text]) {
291
+ if (typeof value === 'string') parts.push({ type: 'text', content: value })
292
+ else if (Array.isArray(value)) parts.push(...value)
293
+ }
294
+ if (audio !== undefined) {
295
+ const url =
296
+ typeof audio === 'string' && /^https?:\/\//.test(audio) ? audio : null
297
+ parts.push(mediaPart('audio', url))
298
+ }
299
+ return parts
300
+ }
301
+
302
+ /**
303
+ * Content parts for a media call's result: generated image, audio or video
304
+ * URLs, and transcript text. Base64 output becomes a placeholder.
305
+ */
306
+ function mediaOutputParts(
307
+ activity: GenerationActivity,
308
+ result: unknown,
309
+ ): Array<unknown> {
310
+ if (!result || typeof result !== 'object') return []
311
+ const r = result as Record<string, unknown>
312
+ const parts: Array<unknown> = []
313
+ if (Array.isArray(r.images)) {
314
+ for (const image of r.images) {
315
+ parts.push(mediaPart('image', (image as { url?: unknown } | null)?.url))
316
+ }
317
+ }
318
+ // `TTSResult.audio` is a base64 string; `AudioGenerationResult.audio` is a
319
+ // `{ url } | { b64Json }` source.
320
+ if (r.audio !== undefined) {
321
+ parts.push(mediaPart('audio', (r.audio as { url?: unknown } | null)?.url))
322
+ }
323
+ // Only video puts its asset on a top-level `url`. A world `url` is a viewer
324
+ // page, not media.
325
+ if (activity === 'video' && typeof r.url === 'string') {
326
+ parts.push(mediaPart('video', r.url))
327
+ }
328
+ if (typeof r.text === 'string') parts.push({ type: 'text', content: r.text })
329
+ return parts
330
+ }
331
+
224
332
  function messageEventName(role: string): string {
225
333
  switch (role) {
226
334
  case 'user':
@@ -340,6 +448,29 @@ export function otelMiddleware(
340
448
  })
341
449
  }
342
450
 
451
+ // Media prompts and transcripts are single strings, so cap each text part
452
+ // with `maxContentLength` the same way the chat completion buffer is capped.
453
+ const redactMediaText = (text: string): string =>
454
+ redactContent(
455
+ maxContentLength > 0 && text.length > maxContentLength
456
+ ? text.slice(0, maxContentLength) + '…'
457
+ : text,
458
+ )
459
+
460
+ const setMediaMessages = (
461
+ span: Span,
462
+ direction: 'input' | 'output',
463
+ parts: Array<unknown>,
464
+ ): void => {
465
+ const content = serializeParts(parts, redactMediaText)
466
+ if (content.length === 0) return
467
+ const json = JSON.stringify([
468
+ { role: direction === 'input' ? 'user' : 'assistant', content },
469
+ ])
470
+ span.setAttribute(`gen_ai.${direction}.messages`, json)
471
+ span.setAttribute(`langfuse.observation.${direction}`, json)
472
+ }
473
+
343
474
  const startMediaSpan = (ctx: GenerationMiddlewareContext): void => {
344
475
  safeCall('otel.onStart', () => {
345
476
  const operationName = OPERATION_NAME[ctx.activity]
@@ -365,6 +496,22 @@ export function otelMiddleware(
365
496
  )
366
497
  if (enriched) span.setAttributes(enriched)
367
498
  mediaSpans.set(ctx, span)
499
+
500
+ if (captureContent) {
501
+ setMediaMessages(span, 'input', mediaInputParts(ctx.artifactInputs))
502
+ // Observe the result through a transform: the terminal hooks never
503
+ // see it. Returns `undefined`, so the result is left unchanged.
504
+ ctx.resultTransforms.push((result) => {
505
+ safeCall('otel.captureOutput', () =>
506
+ setMediaMessages(
507
+ span,
508
+ 'output',
509
+ mediaOutputParts(ctx.activity, result),
510
+ ),
511
+ )
512
+ return undefined
513
+ })
514
+ }
368
515
  })
369
516
  }
370
517
 
@@ -581,7 +728,12 @@ export function otelMiddleware(
581
728
  // Also emit the current GenAI-semconv attribute form
582
729
  // (`gen_ai.input.messages`) — backends like PostHog read prompt
583
730
  // content from this attribute, not from span events.
584
- const inputMessages: Array<{ role: string; content: string }> = []
731
+ // Multimodal messages keep their parts structured so image / audio /
732
+ // video / document references survive into the trace (#1525).
733
+ const inputMessages: Array<{
734
+ role: string
735
+ content: string | Array<InputPart>
736
+ }> = []
585
737
  for (const sys of systemPromptContents) {
586
738
  inputMessages.push({
587
739
  role: 'system',
@@ -589,6 +741,12 @@ export function otelMiddleware(
589
741
  })
590
742
  }
591
743
  for (const m of config.messages) {
744
+ if (Array.isArray(m.content)) {
745
+ const parts = serializeParts(m.content, redactContent)
746
+ if (parts.length === 0) continue
747
+ inputMessages.push({ role: m.role, content: parts })
748
+ continue
749
+ }
592
750
  const body = serializeContent(m.content)
593
751
  if (body.length === 0) continue
594
752
  inputMessages.push({