@tanstack/ai 0.18.0 → 0.19.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.
- package/README.md +0 -4
- package/dist/esm/activities/chat/index.js +65 -2
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/chat/messages.js +15 -0
- package/dist/esm/activities/chat/messages.js.map +1 -1
- package/dist/esm/activities/chat/stream/message-updaters.d.ts +35 -0
- package/dist/esm/activities/chat/stream/message-updaters.js +95 -0
- package/dist/esm/activities/chat/stream/message-updaters.js.map +1 -1
- package/dist/esm/activities/chat/stream/processor.d.ts +1 -0
- package/dist/esm/activities/chat/stream/processor.js +90 -2
- package/dist/esm/activities/chat/stream/processor.js.map +1 -1
- package/dist/esm/adapter-internals.d.ts +1 -0
- package/dist/esm/index.js +2 -2
- package/dist/esm/types.d.ts +57 -8
- package/dist/esm/utilities/ag-ui-wire.js +9 -1
- package/dist/esm/utilities/ag-ui-wire.js.map +1 -1
- package/package.json +2 -2
- package/skills/ai-core/structured-outputs/SKILL.md +240 -47
- package/src/activities/chat/index.ts +111 -1
- package/src/activities/chat/messages.ts +26 -0
- package/src/activities/chat/stream/message-updaters.ts +171 -0
- package/src/activities/chat/stream/processor.ts +137 -2
- package/src/adapter-internals.ts +1 -0
- package/src/types.ts +65 -7
- package/src/utilities/ag-ui-wire.ts +24 -5
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* These are used by StreamProcessor to manage the message array.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { parsePartialJSON } from './json-parser'
|
|
8
9
|
import type {
|
|
10
|
+
StructuredOutputPart,
|
|
9
11
|
ThinkingPart,
|
|
10
12
|
ToolCallPart,
|
|
11
13
|
ToolResultPart,
|
|
@@ -251,6 +253,175 @@ export function updateToolCallApprovalResponse(
|
|
|
251
253
|
})
|
|
252
254
|
}
|
|
253
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Append a delta to the structured-output part on `messageId`, or create one
|
|
258
|
+
* if absent. Progressive parse of the accumulated buffer fills `partial`.
|
|
259
|
+
*
|
|
260
|
+
* Callers must only invoke this while the part is still in flight — the
|
|
261
|
+
* helper unconditionally writes `status: 'streaming'`, so feeding it a delta
|
|
262
|
+
* after a `complete`/`error` terminal would regress the part. In practice the
|
|
263
|
+
* processor gates calls via `structuredMessageIds`, which is dropped on
|
|
264
|
+
* terminal events.
|
|
265
|
+
*
|
|
266
|
+
* If the progressive parse returns null/undefined (the buffer is not yet a
|
|
267
|
+
* parseable JSON prefix), the previously-good `partial` is preserved so the
|
|
268
|
+
* UI doesn't flicker back to empty for a single render.
|
|
269
|
+
*/
|
|
270
|
+
export function appendStructuredOutputDelta(
|
|
271
|
+
messages: Array<UIMessage>,
|
|
272
|
+
messageId: string,
|
|
273
|
+
delta: string,
|
|
274
|
+
): Array<UIMessage> {
|
|
275
|
+
return messages.map((msg) => {
|
|
276
|
+
if (msg.id !== messageId) {
|
|
277
|
+
return msg
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const parts = [...msg.parts]
|
|
281
|
+
const existingIndex = parts.findIndex(
|
|
282
|
+
(p): p is StructuredOutputPart => p.type === 'structured-output',
|
|
283
|
+
)
|
|
284
|
+
const existing =
|
|
285
|
+
existingIndex >= 0 ? (parts[existingIndex] as StructuredOutputPart) : null
|
|
286
|
+
|
|
287
|
+
const nextRaw = (existing?.raw ?? '') + delta
|
|
288
|
+
const progressive = parsePartialJSON(nextRaw)
|
|
289
|
+
const nextPartial =
|
|
290
|
+
progressive !== undefined && progressive !== null
|
|
291
|
+
? progressive
|
|
292
|
+
: existing?.partial
|
|
293
|
+
|
|
294
|
+
const nextPart: StructuredOutputPart = {
|
|
295
|
+
type: 'structured-output',
|
|
296
|
+
status: 'streaming',
|
|
297
|
+
raw: nextRaw,
|
|
298
|
+
...(nextPartial !== undefined ? { partial: nextPartial } : {}),
|
|
299
|
+
...(existing?.reasoning !== undefined
|
|
300
|
+
? { reasoning: existing.reasoning }
|
|
301
|
+
: {}),
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (existingIndex >= 0) {
|
|
305
|
+
parts[existingIndex] = nextPart
|
|
306
|
+
} else {
|
|
307
|
+
parts.push(nextPart)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return { ...msg, parts }
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Snap the structured-output part on `messageId` to `complete` with the
|
|
316
|
+
* validated `data`. Picks the freshest available `raw` so the wire
|
|
317
|
+
* round-trip stays internally consistent:
|
|
318
|
+
*
|
|
319
|
+
* 1. Caller-supplied `raw` (the original streamed bytes from the model).
|
|
320
|
+
* 2. The existing part's `raw` (deltas accumulated before this terminal).
|
|
321
|
+
* 3. `JSON.stringify(data)` as a defensive fallback for terminal-only
|
|
322
|
+
* completes that never shipped raw — keeps the part self-consistent
|
|
323
|
+
* so downstream consumers never see a complete part with empty raw.
|
|
324
|
+
*/
|
|
325
|
+
export function completeStructuredOutputPart(
|
|
326
|
+
messages: Array<UIMessage>,
|
|
327
|
+
messageId: string,
|
|
328
|
+
data: unknown,
|
|
329
|
+
raw: string,
|
|
330
|
+
reasoning?: string,
|
|
331
|
+
): Array<UIMessage> {
|
|
332
|
+
return messages.map((msg) => {
|
|
333
|
+
if (msg.id !== messageId) {
|
|
334
|
+
return msg
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const parts = [...msg.parts]
|
|
338
|
+
const existingIndex = parts.findIndex(
|
|
339
|
+
(p): p is StructuredOutputPart => p.type === 'structured-output',
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
const existingRaw =
|
|
343
|
+
existingIndex >= 0
|
|
344
|
+
? (parts[existingIndex] as StructuredOutputPart).raw
|
|
345
|
+
: ''
|
|
346
|
+
let resolvedRaw = raw || existingRaw
|
|
347
|
+
if (resolvedRaw === '' && data !== undefined) {
|
|
348
|
+
try {
|
|
349
|
+
resolvedRaw = JSON.stringify(data)
|
|
350
|
+
} catch {
|
|
351
|
+
// Unserializable (circular, BigInt, throwing toJSON). Leave raw
|
|
352
|
+
// empty. Both downstream paths handle this: `ag-ui-wire.ts`
|
|
353
|
+
// `collectText` skips complete parts with empty raw entirely, and
|
|
354
|
+
// `uiMessageToModelMessages` falls back to a defensive
|
|
355
|
+
// `safeJsonStringify(data)` which itself returns `''` for the same
|
|
356
|
+
// unserializable inputs — so the turn is silently dropped from the
|
|
357
|
+
// next request rather than shipping garbage or crashing the stream.
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const nextPart: StructuredOutputPart = {
|
|
362
|
+
type: 'structured-output',
|
|
363
|
+
status: 'complete',
|
|
364
|
+
data,
|
|
365
|
+
partial: data,
|
|
366
|
+
raw: resolvedRaw,
|
|
367
|
+
...(reasoning !== undefined ? { reasoning } : {}),
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (existingIndex >= 0) {
|
|
371
|
+
parts[existingIndex] = nextPart
|
|
372
|
+
} else {
|
|
373
|
+
parts.push(nextPart)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return { ...msg, parts }
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Mark the structured-output part on `messageId` as errored. If no part
|
|
382
|
+
* exists yet — RUN_ERROR fired after `structured-output.start` but before
|
|
383
|
+
* any delta — create an empty errored placeholder so consumers have
|
|
384
|
+
* something renderable. Existing complete parts are left alone (an error
|
|
385
|
+
* after a successful complete should not retroactively un-complete it).
|
|
386
|
+
*/
|
|
387
|
+
export function errorStructuredOutputPart(
|
|
388
|
+
messages: Array<UIMessage>,
|
|
389
|
+
messageId: string,
|
|
390
|
+
errorMessage: string,
|
|
391
|
+
): Array<UIMessage> {
|
|
392
|
+
return messages.map((msg) => {
|
|
393
|
+
if (msg.id !== messageId) {
|
|
394
|
+
return msg
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const parts = [...msg.parts]
|
|
398
|
+
const existingIndex = parts.findIndex(
|
|
399
|
+
(p): p is StructuredOutputPart => p.type === 'structured-output',
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
if (existingIndex < 0) {
|
|
403
|
+
parts.push({
|
|
404
|
+
type: 'structured-output',
|
|
405
|
+
status: 'error',
|
|
406
|
+
raw: '',
|
|
407
|
+
errorMessage,
|
|
408
|
+
})
|
|
409
|
+
return { ...msg, parts }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const existing = parts[existingIndex] as StructuredOutputPart
|
|
413
|
+
if (existing.status === 'complete') {
|
|
414
|
+
return msg
|
|
415
|
+
}
|
|
416
|
+
parts[existingIndex] = {
|
|
417
|
+
...existing,
|
|
418
|
+
status: 'error',
|
|
419
|
+
errorMessage,
|
|
420
|
+
}
|
|
421
|
+
return { ...msg, parts }
|
|
422
|
+
})
|
|
423
|
+
}
|
|
424
|
+
|
|
254
425
|
/**
|
|
255
426
|
* Update or add a thinking part to a message, keyed by stepId.
|
|
256
427
|
* Each distinct stepId produces its own ThinkingPart.
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
import { generateMessageId, uiMessageToModelMessages } from '../messages.js'
|
|
21
21
|
import { defaultJSONParser } from './json-parser'
|
|
22
22
|
import {
|
|
23
|
+
appendStructuredOutputDelta,
|
|
24
|
+
completeStructuredOutputPart,
|
|
25
|
+
errorStructuredOutputPart,
|
|
23
26
|
updateTextPart,
|
|
24
27
|
updateThinkingPart,
|
|
25
28
|
updateToolCallApproval,
|
|
@@ -145,6 +148,8 @@ export class StreamProcessor {
|
|
|
145
148
|
private pendingManualMessageId: string | null = null
|
|
146
149
|
private pendingThinkingStepId: string | null = null
|
|
147
150
|
|
|
151
|
+
private structuredMessageIds: Set<string> = new Set()
|
|
152
|
+
|
|
148
153
|
// Run tracking (for concurrent run safety)
|
|
149
154
|
private activeRuns = new Set<string>()
|
|
150
155
|
|
|
@@ -383,6 +388,27 @@ export class StreamProcessor {
|
|
|
383
388
|
* Remove messages after a certain index (for reload/retry)
|
|
384
389
|
*/
|
|
385
390
|
removeMessagesAfter(index: number): void {
|
|
391
|
+
const keptIds = new Set(this.messages.slice(0, index + 1).map((m) => m.id))
|
|
392
|
+
// Drop routing state for messages that no longer exist; otherwise a
|
|
393
|
+
// resumed stream (`reload()` or a server that reuses messageIds across
|
|
394
|
+
// runs) could land deltas / tool args on stale map entries and corrupt
|
|
395
|
+
// the new assistant message's parts. Mirror the four routing maps that
|
|
396
|
+
// key on messageId: structuredMessageIds (custom-event routing),
|
|
397
|
+
// messageStates (per-message stream state), toolCallToMessage (tool
|
|
398
|
+
// args → message), and activeMessageIds (finalize / completeAllToolCalls
|
|
399
|
+
// iteration targets — must not include phantoms).
|
|
400
|
+
for (const id of this.structuredMessageIds) {
|
|
401
|
+
if (!keptIds.has(id)) this.structuredMessageIds.delete(id)
|
|
402
|
+
}
|
|
403
|
+
for (const id of this.messageStates.keys()) {
|
|
404
|
+
if (!keptIds.has(id)) this.messageStates.delete(id)
|
|
405
|
+
}
|
|
406
|
+
for (const [toolCallId, msgId] of this.toolCallToMessage) {
|
|
407
|
+
if (!keptIds.has(msgId)) this.toolCallToMessage.delete(toolCallId)
|
|
408
|
+
}
|
|
409
|
+
for (const id of this.activeMessageIds) {
|
|
410
|
+
if (!keptIds.has(id)) this.activeMessageIds.delete(id)
|
|
411
|
+
}
|
|
386
412
|
this.messages = this.messages.slice(0, index + 1)
|
|
387
413
|
this.emitMessagesChange()
|
|
388
414
|
}
|
|
@@ -395,6 +421,7 @@ export class StreamProcessor {
|
|
|
395
421
|
this.messageStates.clear()
|
|
396
422
|
this.activeMessageIds.clear()
|
|
397
423
|
this.toolCallToMessage.clear()
|
|
424
|
+
this.structuredMessageIds.clear()
|
|
398
425
|
this.pendingManualMessageId = null
|
|
399
426
|
this.emitMessagesChange()
|
|
400
427
|
}
|
|
@@ -841,6 +868,41 @@ export class StreamProcessor {
|
|
|
841
868
|
// Content arriving means all current tool calls for this message are complete
|
|
842
869
|
this.completeAllToolCallsForMessage(messageId)
|
|
843
870
|
|
|
871
|
+
if (this.structuredMessageIds.has(messageId)) {
|
|
872
|
+
// `chunk.delta` is incremental; `chunk.content` is sometimes cumulative
|
|
873
|
+
// (mirrors what the plain-text branch handles below). Reconcile against
|
|
874
|
+
// the existing raw buffer so adapters that emit cumulative content
|
|
875
|
+
// don't duplicate the JSON.
|
|
876
|
+
let delta = chunk.delta || ''
|
|
877
|
+
if (delta === '' && chunk.content !== undefined && chunk.content !== '') {
|
|
878
|
+
const existingRaw = (
|
|
879
|
+
this.messages
|
|
880
|
+
.find((m) => m.id === messageId)
|
|
881
|
+
?.parts.find(
|
|
882
|
+
(p): p is Extract<MessagePart, { type: 'structured-output' }> =>
|
|
883
|
+
p.type === 'structured-output',
|
|
884
|
+
) ?? { raw: '' }
|
|
885
|
+
).raw
|
|
886
|
+
if (chunk.content.startsWith(existingRaw)) {
|
|
887
|
+
delta = chunk.content.slice(existingRaw.length)
|
|
888
|
+
} else if (existingRaw.startsWith(chunk.content)) {
|
|
889
|
+
delta = ''
|
|
890
|
+
} else {
|
|
891
|
+
delta = chunk.content
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
if (delta !== '') {
|
|
895
|
+
this.messages = appendStructuredOutputDelta(
|
|
896
|
+
this.messages,
|
|
897
|
+
messageId,
|
|
898
|
+
delta,
|
|
899
|
+
)
|
|
900
|
+
state.totalTextContent += delta
|
|
901
|
+
this.emitMessagesChange()
|
|
902
|
+
}
|
|
903
|
+
return
|
|
904
|
+
}
|
|
905
|
+
|
|
844
906
|
const previousSegment = state.currentSegmentText
|
|
845
907
|
|
|
846
908
|
// Detect if this is a NEW text segment (after tool calls) vs continuation
|
|
@@ -1192,10 +1254,29 @@ export class StreamProcessor {
|
|
|
1192
1254
|
} else {
|
|
1193
1255
|
this.activeRuns.clear()
|
|
1194
1256
|
}
|
|
1195
|
-
this.ensureAssistantMessage()
|
|
1196
|
-
// Prefer spec field `message`; fall back to deprecated `error.message
|
|
1257
|
+
const { messageId } = this.ensureAssistantMessage()
|
|
1258
|
+
// Prefer spec field `message`; fall back to deprecated `error.message`.
|
|
1259
|
+
// If neither is set, the chunk still carries debug context (provider
|
|
1260
|
+
// error codes, request ids, etc.) — log it so the failure isn't silent.
|
|
1197
1261
|
const errorMessage =
|
|
1198
1262
|
chunk.message || chunk.error?.message || 'An error occurred'
|
|
1263
|
+
if (!chunk.message && !chunk.error?.message) {
|
|
1264
|
+
console.error(
|
|
1265
|
+
'[StreamProcessor] RUN_ERROR with no message; original chunk:',
|
|
1266
|
+
chunk,
|
|
1267
|
+
)
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
if (this.structuredMessageIds.has(messageId)) {
|
|
1271
|
+
this.messages = errorStructuredOutputPart(
|
|
1272
|
+
this.messages,
|
|
1273
|
+
messageId,
|
|
1274
|
+
errorMessage,
|
|
1275
|
+
)
|
|
1276
|
+
this.structuredMessageIds.delete(messageId)
|
|
1277
|
+
this.emitMessagesChange()
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1199
1280
|
this.events.onError?.(new Error(errorMessage))
|
|
1200
1281
|
}
|
|
1201
1282
|
|
|
@@ -1375,6 +1456,38 @@ export class StreamProcessor {
|
|
|
1375
1456
|
): void {
|
|
1376
1457
|
const messageId = this.getActiveAssistantMessageId()
|
|
1377
1458
|
|
|
1459
|
+
if (chunk.name === 'structured-output.start' && chunk.value) {
|
|
1460
|
+
const v = chunk.value as { messageId?: string }
|
|
1461
|
+
const targetId = v.messageId ?? messageId
|
|
1462
|
+
if (targetId) {
|
|
1463
|
+
this.ensureAssistantMessage(targetId)
|
|
1464
|
+
this.structuredMessageIds.add(targetId)
|
|
1465
|
+
}
|
|
1466
|
+
return
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
if (chunk.name === 'structured-output.complete' && chunk.value) {
|
|
1470
|
+
const v = chunk.value as {
|
|
1471
|
+
object: unknown
|
|
1472
|
+
raw?: string
|
|
1473
|
+
reasoning?: string
|
|
1474
|
+
messageId?: string
|
|
1475
|
+
}
|
|
1476
|
+
const targetId = v.messageId ?? messageId
|
|
1477
|
+
if (targetId) {
|
|
1478
|
+
this.messages = completeStructuredOutputPart(
|
|
1479
|
+
this.messages,
|
|
1480
|
+
targetId,
|
|
1481
|
+
v.object,
|
|
1482
|
+
v.raw ?? '',
|
|
1483
|
+
v.reasoning,
|
|
1484
|
+
)
|
|
1485
|
+
this.structuredMessageIds.delete(targetId)
|
|
1486
|
+
this.emitMessagesChange()
|
|
1487
|
+
}
|
|
1488
|
+
// Fall through so user `onCustomEvent` callbacks still observe the event.
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1378
1491
|
// Handle client tool input availability - trigger client-side execution
|
|
1379
1492
|
if (chunk.name === 'tool-input-available' && chunk.value) {
|
|
1380
1493
|
const { toolCallId, toolName, input } = chunk.value as {
|
|
@@ -1591,6 +1704,27 @@ export class StreamProcessor {
|
|
|
1591
1704
|
}
|
|
1592
1705
|
}
|
|
1593
1706
|
|
|
1707
|
+
// The stream closed but one or more structured-output runs never sent
|
|
1708
|
+
// their terminal `structured-output.complete`. Snap each lingering
|
|
1709
|
+
// streaming part to error so the UI doesn't appear to stream forever,
|
|
1710
|
+
// and drop the routing entries so a subsequent run on the same
|
|
1711
|
+
// processor instance (long-lived `subscribe()` mode) doesn't reuse
|
|
1712
|
+
// the stale ids.
|
|
1713
|
+
//
|
|
1714
|
+
// The iteration is unconditional w.r.t. `this.hasError` — RUN_ERROR
|
|
1715
|
+
// already removed its target messageId from `structuredMessageIds`
|
|
1716
|
+
// before reaching finalize, so anything still in the set is by
|
|
1717
|
+
// definition a non-errored, never-completed run (the multi-run case:
|
|
1718
|
+
// run-A errors, run-B is still streaming when finalize fires).
|
|
1719
|
+
for (const messageId of this.structuredMessageIds) {
|
|
1720
|
+
this.messages = errorStructuredOutputPart(
|
|
1721
|
+
this.messages,
|
|
1722
|
+
messageId,
|
|
1723
|
+
'Stream ended without structured-output.complete',
|
|
1724
|
+
)
|
|
1725
|
+
}
|
|
1726
|
+
this.structuredMessageIds.clear()
|
|
1727
|
+
|
|
1594
1728
|
this.activeMessageIds.clear()
|
|
1595
1729
|
|
|
1596
1730
|
// Remove whitespace-only assistant messages (handles models like Gemini
|
|
@@ -1719,6 +1853,7 @@ export class StreamProcessor {
|
|
|
1719
1853
|
this.activeMessageIds.clear()
|
|
1720
1854
|
this.activeRuns.clear()
|
|
1721
1855
|
this.toolCallToMessage.clear()
|
|
1856
|
+
this.structuredMessageIds.clear()
|
|
1722
1857
|
this.pendingManualMessageId = null
|
|
1723
1858
|
this.pendingThinkingStepId = null
|
|
1724
1859
|
this.finishReason = null
|
package/src/adapter-internals.ts
CHANGED
|
@@ -4,5 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
export type { ResolvedCategories } from './logger/internal-logger'
|
|
6
6
|
export { InternalLogger } from './logger/internal-logger'
|
|
7
|
+
export type { Logger } from './logger/types'
|
|
7
8
|
export { resolveDebugOption } from './logger/resolve'
|
|
8
9
|
export { toRunErrorPayload } from './activities/error-payload'
|
package/src/types.ts
CHANGED
|
@@ -365,7 +365,43 @@ export interface ThinkingPart {
|
|
|
365
365
|
signature?: string
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
-
|
|
368
|
+
/**
|
|
369
|
+
* Recursive `Partial` — every nested field becomes optional. Used as the
|
|
370
|
+
* `partial` type on a streaming structured-output part since the progressive
|
|
371
|
+
* JSON parse hands back objects whose fields are only filled in as bytes
|
|
372
|
+
* arrive. Defaulted in `DeepPartial<unknown>` → `unknown` so untyped parts
|
|
373
|
+
* keep their existing shape.
|
|
374
|
+
*/
|
|
375
|
+
export type DeepPartial<T> =
|
|
376
|
+
T extends ReadonlyArray<infer U>
|
|
377
|
+
? Array<DeepPartial<U>>
|
|
378
|
+
: T extends object
|
|
379
|
+
? { [K in keyof T]?: DeepPartial<T[K]> }
|
|
380
|
+
: T
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* StructuredOutputPart — a typed structured response attached to the assistant
|
|
384
|
+
* message that produced it. Generic over the schema-inferred data type so
|
|
385
|
+
* consumers can thread `useChat({ outputSchema })`'s schema all the way down
|
|
386
|
+
* to `messages[i].parts[j].data`. Defaults to `unknown` so untyped consumers
|
|
387
|
+
* (e.g. internal codepaths that don't know about TSchema) keep working.
|
|
388
|
+
*/
|
|
389
|
+
export interface StructuredOutputPart<TData = unknown> {
|
|
390
|
+
type: 'structured-output'
|
|
391
|
+
status: 'streaming' | 'complete' | 'error'
|
|
392
|
+
/** Progressive parse of `raw` via parsePartialJSON — populated while streaming and after complete. */
|
|
393
|
+
partial?: DeepPartial<TData>
|
|
394
|
+
/** Validated final object — only set when `status === 'complete'`. */
|
|
395
|
+
data?: TData
|
|
396
|
+
/** Accumulating JSON buffer. Source of truth for wire round-trip. */
|
|
397
|
+
raw: string
|
|
398
|
+
/** Optional chain-of-thought surfaced by reasoning models alongside the structured output. */
|
|
399
|
+
reasoning?: string
|
|
400
|
+
/** Populated when `status === 'error'`. */
|
|
401
|
+
errorMessage?: string
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export type MessagePart<TData = unknown> =
|
|
369
405
|
| TextPart
|
|
370
406
|
| ImagePart
|
|
371
407
|
| AudioPart
|
|
@@ -374,15 +410,19 @@ export type MessagePart =
|
|
|
374
410
|
| ToolCallPart
|
|
375
411
|
| ToolResultPart
|
|
376
412
|
| ThinkingPart
|
|
413
|
+
| StructuredOutputPart<TData>
|
|
377
414
|
|
|
378
415
|
/**
|
|
379
416
|
* UIMessage - Domain-specific message format optimized for building chat UIs
|
|
380
|
-
* Contains parts that can be text, tool calls, or tool results
|
|
417
|
+
* Contains parts that can be text, tool calls, or tool results. Generic over
|
|
418
|
+
* the structured-output data type so `useChat({ outputSchema })`'s schema
|
|
419
|
+
* narrows `parts.find(p => p.type === 'structured-output').data` on the
|
|
420
|
+
* consumer side without manual casts.
|
|
381
421
|
*/
|
|
382
|
-
export interface UIMessage {
|
|
422
|
+
export interface UIMessage<TData = unknown> {
|
|
383
423
|
id: string
|
|
384
424
|
role: 'system' | 'user' | 'assistant'
|
|
385
|
-
parts: Array<MessagePart
|
|
425
|
+
parts: Array<MessagePart<TData>>
|
|
386
426
|
createdAt?: Date
|
|
387
427
|
}
|
|
388
428
|
|
|
@@ -1114,11 +1154,28 @@ export interface StructuredOutputCompleteEvent<T = unknown> extends Omit<
|
|
|
1114
1154
|
value: { object: T; raw: string; reasoning?: string }
|
|
1115
1155
|
}
|
|
1116
1156
|
|
|
1157
|
+
/**
|
|
1158
|
+
* Emitted at the start of a streaming structured-output run, before the JSON
|
|
1159
|
+
* deltas. Tells consumers that the upcoming `TEXT_MESSAGE_CONTENT` deltas
|
|
1160
|
+
* belong to a structured response so they can route those bytes into a
|
|
1161
|
+
* `StructuredOutputPart` instead of building a `TextPart`. Carries the
|
|
1162
|
+
* `messageId` the deltas will be tagged with so the routing decision can be
|
|
1163
|
+
* made per-message rather than globally.
|
|
1164
|
+
*/
|
|
1165
|
+
export interface StructuredOutputStartEvent extends Omit<
|
|
1166
|
+
CustomEvent,
|
|
1167
|
+
'name' | 'value'
|
|
1168
|
+
> {
|
|
1169
|
+
name: 'structured-output.start'
|
|
1170
|
+
value: { messageId: string }
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1117
1173
|
/**
|
|
1118
1174
|
* Emitted when a server tool requires approval before execution. The agent
|
|
1119
1175
|
* loop yields this and pauses — `structured-output.complete` will not fire
|
|
1120
1176
|
* for that run. The shape is fixed by the orchestrator's tool-approval flow
|
|
1121
|
-
* (
|
|
1177
|
+
* (the agent-loop branch of `runStreamingStructuredOutputImpl` in
|
|
1178
|
+
* `activities/chat/index.ts` forwards CUSTOM events from `TextEngine.run()`).
|
|
1122
1179
|
*/
|
|
1123
1180
|
export interface ApprovalRequestedEvent extends Omit<
|
|
1124
1181
|
CustomEvent,
|
|
@@ -1136,8 +1193,8 @@ export interface ApprovalRequestedEvent extends Omit<
|
|
|
1136
1193
|
/**
|
|
1137
1194
|
* Emitted when a client tool is invoked. The agent loop yields this and
|
|
1138
1195
|
* pauses to let the caller run the tool client-side — `structured-output.complete`
|
|
1139
|
-
* will not fire for that run. Shape fixed by
|
|
1140
|
-
* `activities/chat/index.ts`.
|
|
1196
|
+
* will not fire for that run. Shape fixed by the agent-loop forwarding in
|
|
1197
|
+
* `runStreamingStructuredOutputImpl` in `activities/chat/index.ts`.
|
|
1141
1198
|
*/
|
|
1142
1199
|
export interface ToolInputAvailableEvent extends Omit<
|
|
1143
1200
|
CustomEvent,
|
|
@@ -1183,6 +1240,7 @@ export interface ToolInputAvailableEvent extends Omit<
|
|
|
1183
1240
|
*/
|
|
1184
1241
|
export type StructuredOutputStream<T = unknown> = AsyncIterable<
|
|
1185
1242
|
| Exclude<StreamChunk, CustomEvent>
|
|
1243
|
+
| StructuredOutputStartEvent
|
|
1186
1244
|
| StructuredOutputCompleteEvent<T>
|
|
1187
1245
|
| ApprovalRequestedEvent
|
|
1188
1246
|
| ToolInputAvailableEvent
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ContentPart, MessagePart,
|
|
1
|
+
import type { ContentPart, MessagePart, UIMessage } from '../types'
|
|
2
2
|
|
|
3
3
|
type AGUITextInputContent = { type: 'text'; text: string }
|
|
4
4
|
type AGUIInputContent =
|
|
@@ -113,10 +113,29 @@ export function uiMessagesToWire(
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
function collectText(parts: ReadonlyArray<MessagePart>): string {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
// The streamed JSON of a completed structured-output part is the source of
|
|
117
|
+
// truth for multi-turn coherence — emitting it back as assistant content
|
|
118
|
+
// lets the LLM see its own prior structured response. Streaming/errored
|
|
119
|
+
// parts are skipped: they'd ship malformed JSON fragments and confuse the
|
|
120
|
+
// model. `completeStructuredOutputPart` tries hard to populate `raw`
|
|
121
|
+
// (caller → existing buffer → `JSON.stringify(data)`), but the stringify
|
|
122
|
+
// fallback can leave it empty when `data` is unserializable (BigInt,
|
|
123
|
+
// circular). The `p.raw !== ''` guard below is what enforces "no malformed
|
|
124
|
+
// round-trip" in that case — without it we'd ship `''` and the model would
|
|
125
|
+
// see an empty assistant turn.
|
|
126
|
+
const out: Array<string> = []
|
|
127
|
+
for (const p of parts) {
|
|
128
|
+
if (p.type === 'text') {
|
|
129
|
+
out.push(p.content)
|
|
130
|
+
} else if (
|
|
131
|
+
p.type === 'structured-output' &&
|
|
132
|
+
p.status === 'complete' &&
|
|
133
|
+
p.raw !== ''
|
|
134
|
+
) {
|
|
135
|
+
out.push(p.raw)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return out.join('')
|
|
120
139
|
}
|
|
121
140
|
|
|
122
141
|
function collectUserContent(
|