@tanstack/ai 0.49.1 → 0.51.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.
@@ -1,6 +1,16 @@
1
+ import type {
2
+ AssistantMessage,
3
+ InputContent,
4
+ ReasoningMessage,
5
+ SystemMessage,
6
+ ToolCall,
7
+ ToolMessage,
8
+ UserMessage,
9
+ } from '@ag-ui/core'
1
10
  import type {
2
11
  ContentPart,
3
12
  MessagePart,
13
+ ModelMessage,
4
14
  StructuredOutputPart,
5
15
  TanStackMessageMetadata,
6
16
  UIMessage,
@@ -8,72 +18,125 @@ import type {
8
18
  } from '../types'
9
19
  import type { MetadataRecord } from './merge-metadata'
10
20
  import { tanstackMetadata } from './merge-metadata'
21
+ import { normalizeToolResult } from './tool-result'
22
+ import {
23
+ coerceCreatedAt,
24
+ modelMessageToUIMessage,
25
+ } from '../activities/chat/messages'
11
26
 
12
- type AGUITextInputContent = { type: 'text'; text: string }
13
- type AGUIInputContent =
14
- | AGUITextInputContent
15
- | (ContentPart & { type: 'image' | 'audio' | 'video' | 'document' })
16
-
17
- type AGUIToolCallMirror = {
18
- id: string
19
- type: 'function'
20
- function: { name: string; arguments: string }
21
- encryptedValue?: string
22
- }
23
-
24
- type AGUIToolMessage = {
25
- role: 'tool'
26
- id: string
27
- toolCallId: string
28
- content: string
29
- error?: string
30
- }
27
+ type WithMetadata<T> = T & { metadata?: MetadataRecord }
28
+ type WireSystemMessage = WithMetadata<SystemMessage>
29
+ type WireUserMessage = WithMetadata<UserMessage>
30
+ type WireAssistantMessage = WithMetadata<AssistantMessage>
31
+ type WireToolMessage = WithMetadata<
32
+ ToolMessage & {
33
+ name?: string
34
+ }
35
+ >
36
+ type WireReasoningMessage = WithMetadata<ReasoningMessage>
31
37
 
32
- type AGUIReasoningMessage = {
33
- role: 'reasoning'
34
- id: string
35
- content: string
36
- encryptedValue?: string
37
- metadata?: MetadataRecord
38
+ function isRecord(value: unknown): value is Record<string, unknown> {
39
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
38
40
  }
39
41
 
40
- /** Spec AG-UI message. No `parts`, no `createdAt` Date. */
41
- type WireAnchorMessage = {
42
- id: string
43
- role: UIMessage['role']
44
- name?: string
45
- content?: string | Array<AGUIInputContent>
46
- toolCalls?: Array<AGUIToolCallMirror>
47
- metadata?: MetadataRecord
42
+ function rebuiltToolMetadata(
43
+ metadata: unknown,
44
+ createdAt: unknown,
45
+ id: string | undefined,
46
+ content: string | null | Array<ContentPart>,
47
+ anchorOwnsUiResources = false,
48
+ ): MetadataRecord | undefined {
49
+ const source: MetadataRecord = isRecord(metadata) ? metadata : {}
50
+ const tanstack = isRecord(source.tanstack) ? { ...source.tanstack } : {}
51
+ if (anchorOwnsUiResources) delete tanstack.uiResources
52
+ const date = coerceCreatedAt(createdAt)
53
+ const toolResult: NonNullable<TanStackMessageMetadata['toolResult']> = {
54
+ ...(id !== undefined ? { id } : {}),
55
+ ...(date && { createdAt: date.toISOString() }),
56
+ ...(Array.isArray(content) && { content }),
57
+ }
58
+ const result = {
59
+ ...source,
60
+ tanstack: { ...tanstack, toolResult },
61
+ }
62
+ return Object.keys(result).length ? result : undefined
48
63
  }
49
64
 
50
65
  export type WireMessage =
51
- | WireAnchorMessage
52
- | AGUIToolMessage
53
- | AGUIReasoningMessage
66
+ | WireSystemMessage
67
+ | WireUserMessage
68
+ | WireAssistantMessage
69
+ | WireToolMessage
70
+ | WireReasoningMessage
54
71
 
55
72
  /**
56
- * Serialize TanStack `UIMessage`s into the AG-UI `RunAgentInput.messages`
57
- * wire shape. Anchors are spec-only (`id`, `role`, `name`, `content`,
58
- * `toolCalls`, `metadata`). Tool results and thinking parts on assistant
59
- * messages are additionally emitted as fan-out `{role:'tool',...}` and
60
- * `{role:'reasoning',...}` entries for strict AG-UI server consumers.
73
+ * Serialize TanStack `UIMessage`s and `ModelMessage`s into the AG-UI
74
+ * `RunAgentInput.messages` wire shape. Anchors are spec-only (`id`, `role`,
75
+ * `name`, `content`, `toolCalls`, `metadata`). Tool results and thinking parts
76
+ * on assistant messages are additionally emitted as fan-out
77
+ * `{role:'tool',...}` and `{role:'reasoning',...}` entries for strict AG-UI
78
+ * server consumers. Set `includeSnapshotStructuredOutput` to retain complete
79
+ * structured-output metadata for UI snapshots.
61
80
  */
62
81
  export function uiMessagesToWire(
63
- messages: Array<UIMessage>,
82
+ messages: Array<UIMessage | ModelMessage>,
83
+ options?: { includeSnapshotStructuredOutput: boolean },
64
84
  ): Array<WireMessage> {
65
85
  const wire: Array<WireMessage> = []
86
+ const usedWireIds = new Set<string>(
87
+ messages.flatMap((message) =>
88
+ 'id' in message && message.id && message.role !== 'tool'
89
+ ? [message.id]
90
+ : [],
91
+ ),
92
+ )
93
+ const includeSnapshotStructuredOutput =
94
+ options?.includeSnapshotStructuredOutput ?? false
95
+
96
+ const assistantIds = new Set<string>()
97
+ for (const msg of messages) {
98
+ if (msg.role === 'assistant' && msg.id !== undefined) {
99
+ assistantIds.add(msg.id)
100
+ }
101
+ }
66
102
 
67
103
  for (const msg of messages) {
68
- // Defensive: ModelMessage-shaped input has no `parts`; fall back to `content`.
69
- const parts: ReadonlyArray<MessagePart> =
70
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- runtime input may be ModelMessage-shaped (no `parts`); cast forces the optional-chain fallback below to remain in scope
71
- (msg.parts as ReadonlyArray<MessagePart> | undefined) ?? []
104
+ if (!('parts' in msg) && msg.role === 'tool' && msg.toolCallId) {
105
+ const id = uniqueToolWireId(
106
+ toolWireId(msg.id, msg.toolCallId, assistantIds),
107
+ usedWireIds,
108
+ )
109
+ const metadata = rebuiltToolMetadata(
110
+ msg.metadata,
111
+ msg.createdAt,
112
+ msg.id,
113
+ msg.content,
114
+ )
115
+ wire.push({
116
+ role: 'tool',
117
+ id,
118
+ ...(msg.name !== undefined && { name: msg.name }),
119
+ toolCallId: msg.toolCallId,
120
+ content:
121
+ typeof msg.content === 'string'
122
+ ? msg.content
123
+ : JSON.stringify(msg.content),
124
+ ...(msg.error !== undefined && { error: msg.error }),
125
+ ...(metadata !== undefined && { metadata }),
126
+ })
127
+ continue
128
+ }
129
+
130
+ const uiMessage: UIMessage =
131
+ 'parts' in msg ? msg : modelMessageToUIMessage(msg, msg.id)
132
+ const parts: ReadonlyArray<MessagePart> = uiMessage.parts
133
+ usedWireIds.add(uiMessage.id)
72
134
 
73
135
  if (msg.role === 'system') {
74
136
  wire.push(
75
137
  toAnchor(
76
- msg,
138
+ uiMessage,
139
+ 'system',
77
140
  {
78
141
  content:
79
142
  parts.length > 0
@@ -81,6 +144,7 @@ export function uiMessagesToWire(
81
144
  : ((msg as { content?: string }).content ?? ''),
82
145
  },
83
146
  parts,
147
+ includeSnapshotStructuredOutput,
84
148
  ),
85
149
  )
86
150
  continue
@@ -89,7 +153,8 @@ export function uiMessagesToWire(
89
153
  if (msg.role === 'user') {
90
154
  wire.push(
91
155
  toAnchor(
92
- msg,
156
+ uiMessage,
157
+ 'user',
93
158
  {
94
159
  content:
95
160
  parts.length > 0
@@ -97,6 +162,7 @@ export function uiMessagesToWire(
97
162
  : ((msg as { content?: string }).content ?? ''),
98
163
  },
99
164
  parts,
165
+ includeSnapshotStructuredOutput,
100
166
  ),
101
167
  )
102
168
  continue
@@ -105,9 +171,9 @@ export function uiMessagesToWire(
105
171
  // assistant: emit reasoning fan-outs first, then anchor, then tool fan-outs
106
172
  for (const part of parts) {
107
173
  if (part.type === 'thinking') {
108
- const reasoning: AGUIReasoningMessage = {
174
+ const reasoning: WireReasoningMessage = {
109
175
  role: 'reasoning',
110
- id: deriveReasoningId(msg.id, part),
176
+ id: uniqueWireId(deriveReasoningId(uiMessage.id, part), usedWireIds),
111
177
  content: part.content,
112
178
  }
113
179
  if (part.signature) {
@@ -121,26 +187,79 @@ export function uiMessagesToWire(
121
187
  const toolCalls = collectToolCalls(parts)
122
188
  wire.push(
123
189
  toAnchor(
124
- msg,
190
+ uiMessage,
191
+ 'assistant',
125
192
  {
126
193
  ...(text !== '' && { content: text }),
127
194
  ...(toolCalls && { toolCalls }),
128
195
  },
129
196
  parts,
197
+ includeSnapshotStructuredOutput,
130
198
  ),
131
199
  )
132
200
 
201
+ const explicitToolResults = new Set(
202
+ parts.flatMap((part) =>
203
+ part.type === 'tool-result' ? [part.toolCallId] : [],
204
+ ),
205
+ )
133
206
  for (const part of parts) {
134
207
  if (part.type === 'tool-result') {
208
+ const id = uniqueToolWireId(
209
+ part.id ?? deriveToolMessageId(part.toolCallId),
210
+ usedWireIds,
211
+ )
212
+ const metadata = rebuiltToolMetadata(
213
+ part.metadata,
214
+ part.createdAt,
215
+ part.id,
216
+ part.content,
217
+ true,
218
+ )
135
219
  wire.push({
136
220
  role: 'tool',
137
- id: deriveToolMessageId(part.toolCallId),
221
+ id,
138
222
  toolCallId: part.toolCallId,
223
+ ...(part.name !== undefined && { name: part.name }),
139
224
  content:
140
225
  typeof part.content === 'string'
141
226
  ? part.content
142
227
  : JSON.stringify(part.content),
143
228
  ...(part.error !== undefined && { error: part.error }),
229
+ ...(metadata !== undefined && { metadata }),
230
+ })
231
+ } else if (part.type === 'tool-call') {
232
+ const approved = part.approval?.approved
233
+ if (
234
+ explicitToolResults.has(part.id) ||
235
+ (part.output === undefined &&
236
+ (part.state !== 'approval-responded' || approved === undefined))
237
+ ) {
238
+ continue
239
+ }
240
+ const result =
241
+ part.output !== undefined
242
+ ? normalizeToolResult(part.output)
243
+ : JSON.stringify({
244
+ approved,
245
+ ...(approved && { pendingExecution: true }),
246
+ message: approved
247
+ ? 'User approved this action'
248
+ : 'User denied this action',
249
+ })
250
+ const content =
251
+ typeof result === 'string' ? result : JSON.stringify(result)
252
+ wire.push({
253
+ role: 'tool',
254
+ id: uniqueToolWireId(deriveToolMessageId(part.id), usedWireIds),
255
+ toolCallId: part.id,
256
+ content,
257
+ metadata: rebuiltToolMetadata(
258
+ undefined,
259
+ undefined,
260
+ undefined,
261
+ result,
262
+ ),
144
263
  })
145
264
  }
146
265
  }
@@ -151,33 +270,78 @@ export function uiMessagesToWire(
151
270
 
152
271
  function toAnchor(
153
272
  msg: UIMessage,
273
+ role: 'system',
274
+ extras: { content: string },
275
+ parts: ReadonlyArray<MessagePart>,
276
+ includeSnapshotStructuredOutput: boolean,
277
+ ): WireSystemMessage
278
+ function toAnchor(
279
+ msg: UIMessage,
280
+ role: 'user',
281
+ extras: { content: string | Array<InputContent> },
282
+ parts: ReadonlyArray<MessagePart>,
283
+ includeSnapshotStructuredOutput: boolean,
284
+ ): WireUserMessage
285
+ function toAnchor(
286
+ msg: UIMessage,
287
+ role: 'assistant',
154
288
  extras: {
155
- content?: string | Array<AGUIInputContent>
156
- toolCalls?: Array<AGUIToolCallMirror>
289
+ content?: string
290
+ toolCalls?: Array<ToolCall>
157
291
  },
158
292
  parts: ReadonlyArray<MessagePart>,
159
- ): WireAnchorMessage {
160
- const metadata = messageMetadata(msg, parts)
161
- const name = (msg as { name?: string }).name
162
- return {
293
+ includeSnapshotStructuredOutput: boolean,
294
+ ): WireAssistantMessage
295
+ function toAnchor(
296
+ msg: UIMessage,
297
+ role: UIMessage['role'],
298
+ extras: {
299
+ content?: string | Array<InputContent>
300
+ toolCalls?: Array<ToolCall>
301
+ },
302
+ parts: ReadonlyArray<MessagePart>,
303
+ includeSnapshotStructuredOutput: boolean,
304
+ ): WireSystemMessage | WireUserMessage | WireAssistantMessage {
305
+ const metadata = messageMetadata(msg, parts, includeSnapshotStructuredOutput)
306
+ const base = {
163
307
  id: msg.id,
164
- role: msg.role,
165
- ...(name !== undefined && { name }),
166
- ...extras,
308
+ ...(msg.name !== undefined && { name: msg.name }),
167
309
  ...(metadata !== undefined && { metadata }),
168
310
  }
311
+ if (role === 'system') {
312
+ return { ...base, role, content: String(extras.content ?? '') }
313
+ }
314
+ if (role === 'user') {
315
+ return { ...base, role, content: extras.content ?? '' }
316
+ }
317
+ return {
318
+ ...base,
319
+ role,
320
+ ...(typeof extras.content === 'string' && { content: extras.content }),
321
+ ...(extras.toolCalls !== undefined && { toolCalls: extras.toolCalls }),
322
+ }
169
323
  }
170
324
 
171
325
  function messageMetadata(
172
326
  msg: UIMessage,
173
327
  parts: ReadonlyArray<MessagePart>,
328
+ includeSnapshotStructuredOutput: boolean,
174
329
  ): MetadataRecord | undefined {
175
330
  const base: MetadataRecord = { ...(msg.metadata ?? {}) }
176
- const tanstack: MetadataRecord = { ...(tanstackMetadata(msg) ?? {}) }
177
- if (msg.createdAt) tanstack.createdAt = msg.createdAt.toISOString()
331
+ const previousTanstack = tanstackMetadata(msg)
332
+ const tanstack: TanStackMessageMetadata = {}
333
+ if (previousTanstack?.model !== undefined)
334
+ tanstack.model = previousTanstack.model
335
+ if (previousTanstack?.signature !== undefined)
336
+ tanstack.signature = previousTanstack.signature
337
+ const createdAt = coerceCreatedAt(msg.createdAt)
338
+ if (createdAt !== undefined) tanstack.createdAt = createdAt.toISOString()
178
339
 
179
- const leftover = unfinishedStructuredOutput(parts)
180
- if (leftover) tanstack.structuredOutput = leftover
340
+ const structuredOutput = serializedStructuredOutput(
341
+ parts,
342
+ includeSnapshotStructuredOutput,
343
+ )
344
+ if (structuredOutput) tanstack.structuredOutput = structuredOutput
181
345
 
182
346
  const toolCallMetadata: Record<string, unknown> = {}
183
347
  for (const part of parts) {
@@ -195,26 +359,41 @@ function messageMetadata(
195
359
  if (uiResources.length > 0) tanstack.uiResources = uiResources
196
360
 
197
361
  if (Object.keys(tanstack).length > 0) base.tanstack = tanstack
362
+ else delete base.tanstack
198
363
  return Object.keys(base).length > 0 ? base : undefined
199
364
  }
200
365
 
201
- function unfinishedStructuredOutput(
366
+ function serializedStructuredOutput(
202
367
  parts: ReadonlyArray<MessagePart>,
368
+ includeSnapshotStructuredOutput: boolean,
203
369
  ): TanStackMessageMetadata['structuredOutput'] | undefined {
204
370
  for (const p of parts) {
205
- if (p.type === 'structured-output' && p.status !== 'complete') {
206
- return structuredOutputLeftover(p)
371
+ if (
372
+ p.type === 'structured-output' &&
373
+ (includeSnapshotStructuredOutput || p.status !== 'complete')
374
+ ) {
375
+ return structuredOutputMetadata(p, includeSnapshotStructuredOutput)
207
376
  }
208
377
  }
209
378
  return undefined
210
379
  }
211
380
 
212
- function structuredOutputLeftover(
381
+ function structuredOutputMetadata(
213
382
  part: StructuredOutputPart,
383
+ includeSnapshotStructuredOutput: boolean,
214
384
  ): NonNullable<TanStackMessageMetadata['structuredOutput']> {
215
385
  return {
216
386
  status: part.status,
217
387
  raw: part.raw,
388
+ ...(includeSnapshotStructuredOutput && part.partial !== undefined
389
+ ? { partial: part.partial }
390
+ : {}),
391
+ ...(includeSnapshotStructuredOutput && part.data !== undefined
392
+ ? { data: part.data }
393
+ : {}),
394
+ ...(includeSnapshotStructuredOutput && part.reasoning
395
+ ? { reasoning: part.reasoning }
396
+ : {}),
218
397
  ...(part.errorMessage !== undefined && { errorMessage: part.errorMessage }),
219
398
  }
220
399
  }
@@ -247,7 +426,7 @@ function collectText(parts: ReadonlyArray<MessagePart>): string {
247
426
 
248
427
  function collectUserContent(
249
428
  parts: ReadonlyArray<MessagePart>,
250
- ): string | Array<AGUIInputContent> {
429
+ ): string | Array<InputContent> {
251
430
  const hasMultimodal = parts.some(
252
431
  (p) =>
253
432
  p.type === 'image' ||
@@ -258,7 +437,7 @@ function collectUserContent(
258
437
  if (!hasMultimodal) {
259
438
  return collectText(parts)
260
439
  }
261
- const out: Array<AGUIInputContent> = []
440
+ const out: Array<InputContent> = []
262
441
  for (const p of parts) {
263
442
  if (p.type === 'text') {
264
443
  out.push({ type: 'text', text: p.content })
@@ -289,8 +468,8 @@ function thoughtSignatureFromMetadata(metadata: unknown): string | undefined {
289
468
 
290
469
  function collectToolCalls(
291
470
  parts: ReadonlyArray<MessagePart>,
292
- ): Array<AGUIToolCallMirror> | undefined {
293
- const calls: Array<AGUIToolCallMirror> = []
471
+ ): Array<ToolCall> | undefined {
472
+ const calls: Array<ToolCall> = []
294
473
  for (const p of parts) {
295
474
  if (p.type === 'tool-call') {
296
475
  const encryptedValue = thoughtSignatureFromMetadata(p.metadata)
@@ -313,6 +492,32 @@ function deriveToolMessageId(toolCallId: string): string {
313
492
  return `tool-${toolCallId}`
314
493
  }
315
494
 
495
+ function uniqueToolWireId(id: string, used: Set<string>): string {
496
+ return uniqueWireId(id, used)
497
+ }
498
+
499
+ function uniqueWireId(id: string, used: Set<string>): string {
500
+ if (!used.has(id)) {
501
+ used.add(id)
502
+ return id
503
+ }
504
+ let suffix = 2
505
+ while (used.has(`${id}-${suffix}`)) suffix++
506
+ const unique = `${id}-${suffix}`
507
+ used.add(unique)
508
+ return unique
509
+ }
510
+
511
+ function toolWireId(
512
+ id: string | undefined,
513
+ toolCallId: string,
514
+ assistantIds: ReadonlySet<string>,
515
+ ): string {
516
+ const derived = deriveToolMessageId(toolCallId)
517
+ if (id === undefined || assistantIds.has(id)) return derived
518
+ return id
519
+ }
520
+
316
521
  function hashContent(s: string): string {
317
522
  // Cheap deterministic id suffix; collisions are tolerable since
318
523
  // reasoning ids only matter for AG-UI server consumers, not for our
@@ -1,5 +1,47 @@
1
1
  import type { StreamChunk } from '../types'
2
2
 
3
+ /**
4
+ * Thrown when a skills request exceeds a provider limit. Lives in core (rather
5
+ * than `@tanstack/ai-skills`) so the native tool factories in `ai-anthropic`
6
+ * and `openai-base` can throw it without depending on the skills package;
7
+ * `@tanstack/ai-skills` re-exports it for the portable path.
8
+ *
9
+ * `path` distinguishes the native provider cap (e.g. Anthropic's 8-skill
10
+ * limit) from a portable-path limit, so a portable user isn't sent chasing a
11
+ * cap that only applies to hosted skills.
12
+ */
13
+ export interface SkillLimitErrorInit {
14
+ provider: 'anthropic' | 'openai' | 'gemini' | 'other'
15
+ path: 'native' | 'portable'
16
+ limit: string
17
+ allowed: number
18
+ actual: number
19
+ offending: Array<string>
20
+ }
21
+
22
+ export class SkillLimitError extends Error {
23
+ readonly provider: 'anthropic' | 'openai' | 'gemini' | 'other'
24
+ readonly path: 'native' | 'portable'
25
+ readonly limit: string
26
+ readonly allowed: number
27
+ readonly actual: number
28
+ readonly offending: Array<string>
29
+
30
+ constructor(init: SkillLimitErrorInit) {
31
+ super(
32
+ `${init.provider} ${init.path} skills limit exceeded (${init.limit}): ` +
33
+ `${init.actual} > ${init.allowed}`,
34
+ )
35
+ this.name = 'SkillLimitError'
36
+ this.provider = init.provider
37
+ this.path = init.path
38
+ this.limit = init.limit
39
+ this.allowed = init.allowed
40
+ this.actual = init.actual
41
+ this.offending = init.offending
42
+ }
43
+ }
44
+
3
45
  /**
4
46
  * Best-effort extraction of a human-readable message from an unknown thrown
5
47
  * value, returning `undefined` when none can be found.