@tanstack/ai 0.17.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.d.ts +12 -3
- package/dist/esm/activities/chat/index.js +75 -7
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/chat/messages.js +41 -2
- package/dist/esm/activities/chat/messages.js.map +1 -1
- package/dist/esm/activities/chat/middleware/compose.js +1 -1
- package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
- package/dist/esm/activities/chat/middleware/types.d.ts +12 -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/activities/chat/tools/schema-converter.js +5 -0
- package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
- package/dist/esm/adapter-internals.d.ts +1 -0
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.js +8 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/types.d.ts +86 -16
- package/dist/esm/utilities/ag-ui-wire.d.ts +44 -0
- package/dist/esm/utilities/ag-ui-wire.js +104 -0
- package/dist/esm/utilities/ag-ui-wire.js.map +1 -0
- package/dist/esm/utilities/chat-params.d.ts +80 -0
- package/dist/esm/utilities/chat-params.js +96 -0
- package/dist/esm/utilities/chat-params.js.map +1 -0
- package/package.json +3 -3
- package/skills/ai-core/ag-ui-protocol/SKILL.md +46 -3
- package/skills/ai-core/structured-outputs/SKILL.md +240 -47
- package/src/activities/chat/index.ts +144 -10
- package/src/activities/chat/messages.ts +70 -4
- package/src/activities/chat/middleware/compose.ts +1 -1
- package/src/activities/chat/middleware/types.ts +12 -1
- package/src/activities/chat/stream/message-updaters.ts +171 -0
- package/src/activities/chat/stream/processor.ts +137 -2
- package/src/activities/chat/tools/schema-converter.ts +14 -0
- package/src/adapter-internals.ts +1 -0
- package/src/index.ts +11 -0
- package/src/types.ts +104 -15
- package/src/utilities/ag-ui-wire.ts +201 -0
- package/src/utilities/chat-params.ts +199 -0
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
parseWithStandardSchema,
|
|
23
23
|
} from './tools/schema-converter'
|
|
24
24
|
import { maxIterations as maxIterationsStrategy } from './agent-loop-strategies'
|
|
25
|
-
import { convertMessagesToModelMessages } from './messages'
|
|
25
|
+
import { convertMessagesToModelMessages, generateMessageId } from './messages'
|
|
26
26
|
import { MiddlewareRunner } from './middleware/compose'
|
|
27
27
|
import type {
|
|
28
28
|
ApprovalRequest,
|
|
@@ -48,6 +48,7 @@ import type {
|
|
|
48
48
|
ToolCallArgsEvent,
|
|
49
49
|
ToolCallEndEvent,
|
|
50
50
|
ToolCallStartEvent,
|
|
51
|
+
UIMessage,
|
|
51
52
|
} from '../../types'
|
|
52
53
|
import type {
|
|
53
54
|
ChatMiddleware,
|
|
@@ -85,12 +86,21 @@ export interface TextActivityOptions<
|
|
|
85
86
|
> {
|
|
86
87
|
/** The text adapter to use (created by a provider function like openaiText('gpt-4o')) */
|
|
87
88
|
adapter: TAdapter
|
|
88
|
-
/**
|
|
89
|
+
/**
|
|
90
|
+
* Conversation messages. Accepts:
|
|
91
|
+
* - `ConstrainedModelMessage` — content types constrained by the adapter's input modalities.
|
|
92
|
+
* - `ModelMessage` — unconstrained model message (e.g., forwarded from an AG-UI wire payload).
|
|
93
|
+
* - `UIMessage` — parts-based UI representation; converted internally via `convertMessagesToModelMessages`.
|
|
94
|
+
*
|
|
95
|
+
* The three shapes can be mixed in a single array (e.g., when forwarding a wire payload that includes both anchor UIMessages and AG-UI fan-out ModelMessages).
|
|
96
|
+
*/
|
|
89
97
|
messages?: Array<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
98
|
+
| UIMessage
|
|
99
|
+
| ModelMessage
|
|
100
|
+
| ConstrainedModelMessage<{
|
|
101
|
+
inputModalities: TAdapter['~types']['inputModalities']
|
|
102
|
+
messageMetadataByModality: TAdapter['~types']['messageMetadataByModality']
|
|
103
|
+
}>
|
|
94
104
|
>
|
|
95
105
|
/** System prompts to prepend to the conversation */
|
|
96
106
|
systemPrompts?: TextOptions['systemPrompts']
|
|
@@ -128,6 +138,8 @@ export interface TextActivityOptions<
|
|
|
128
138
|
threadId?: TextOptions['threadId']
|
|
129
139
|
/** Run ID override for AG-UI protocol. Auto-generated by adapter if not provided. */
|
|
130
140
|
runId?: TextOptions['runId']
|
|
141
|
+
/** Parent run ID for AG-UI protocol nested run correlation. */
|
|
142
|
+
parentRunId?: TextOptions['parentRunId']
|
|
131
143
|
/**
|
|
132
144
|
* Optional Standard Schema for structured output.
|
|
133
145
|
* When provided, the activity will:
|
|
@@ -313,6 +325,7 @@ class TextEngine<
|
|
|
313
325
|
// AG-UI protocol IDs
|
|
314
326
|
private threadId: string
|
|
315
327
|
private runIdOverride?: string
|
|
328
|
+
private parentRunIdOverride?: string
|
|
316
329
|
|
|
317
330
|
// Middleware support
|
|
318
331
|
private readonly middlewareRunner: MiddlewareRunner
|
|
@@ -364,8 +377,15 @@ class TextEngine<
|
|
|
364
377
|
? { signal: config.params.abortController.signal }
|
|
365
378
|
: undefined
|
|
366
379
|
this.effectiveSignal = config.params.abortController?.signal
|
|
367
|
-
|
|
380
|
+
// `conversationId` is the legacy alias of `threadId` — accept it
|
|
381
|
+
// as a fallback so `chat({ conversationId })` keeps working, with
|
|
382
|
+
// explicit `threadId` winning when both are set.
|
|
383
|
+
this.threadId =
|
|
384
|
+
config.params.threadId ||
|
|
385
|
+
config.params.conversationId ||
|
|
386
|
+
this.createId('thread')
|
|
368
387
|
this.runIdOverride = config.params.runId
|
|
388
|
+
this.parentRunIdOverride = config.params.parentRunId
|
|
369
389
|
|
|
370
390
|
// Initialize middleware — devtools first, strip-to-spec always last.
|
|
371
391
|
// handleStreamChunk processes raw chunks BEFORE middleware, so internal
|
|
@@ -381,7 +401,10 @@ class TextEngine<
|
|
|
381
401
|
this.middlewareCtx = {
|
|
382
402
|
requestId: this.requestId,
|
|
383
403
|
streamId: this.streamId,
|
|
384
|
-
|
|
404
|
+
threadId: this.threadId,
|
|
405
|
+
// Legacy alias kept on the ctx so middleware that reads
|
|
406
|
+
// `ctx.conversationId` keeps working. Always equals `threadId`.
|
|
407
|
+
conversationId: this.threadId,
|
|
385
408
|
phase: 'init' as ChatMiddlewarePhase,
|
|
386
409
|
iteration: 0,
|
|
387
410
|
chunkIndex: 0,
|
|
@@ -429,7 +452,7 @@ class TextEngine<
|
|
|
429
452
|
async *run(): AsyncGenerator<StreamChunk> {
|
|
430
453
|
this.beforeRun()
|
|
431
454
|
this.logger.agentLoop('run started', {
|
|
432
|
-
|
|
455
|
+
threadId: this.middlewareCtx.threadId,
|
|
433
456
|
})
|
|
434
457
|
|
|
435
458
|
try {
|
|
@@ -508,7 +531,7 @@ class TextEngine<
|
|
|
508
531
|
// Genuine error — call onError
|
|
509
532
|
this.logger.errors('chat run failed', {
|
|
510
533
|
error,
|
|
511
|
-
|
|
534
|
+
threadId: this.middlewareCtx.threadId,
|
|
512
535
|
})
|
|
513
536
|
await this.middlewareRunner.runOnError(this.middlewareCtx, {
|
|
514
537
|
error,
|
|
@@ -634,6 +657,7 @@ class TextEngine<
|
|
|
634
657
|
logger: this.logger,
|
|
635
658
|
threadId: this.threadId,
|
|
636
659
|
runId: this.runIdOverride,
|
|
660
|
+
parentRunId: this.parentRunIdOverride,
|
|
637
661
|
})) {
|
|
638
662
|
if (this.isCancelled()) {
|
|
639
663
|
break
|
|
@@ -2021,7 +2045,100 @@ async function* runStreamingStructuredOutputImpl<TSchema extends SchemaInput>(
|
|
|
2021
2045
|
outputSchema: jsonSchema,
|
|
2022
2046
|
})
|
|
2023
2047
|
|
|
2048
|
+
// Tag the start/complete events with the assistant messageId so the
|
|
2049
|
+
// client-side processor can route JSON deltas to (and snap) the right
|
|
2050
|
+
// StructuredOutputPart. Missing messageId is treated as a hard error
|
|
2051
|
+
// below to avoid silently rendering JSON as plain text.
|
|
2052
|
+
let structuredMessageId: string | null = null
|
|
2053
|
+
let startEmitted = false
|
|
2054
|
+
|
|
2055
|
+
const extractMessageId = (c: StreamChunk): string | null => {
|
|
2056
|
+
const id = (c as { messageId?: unknown }).messageId
|
|
2057
|
+
return typeof id === 'string' && id !== '' ? id : null
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
// Emit a `structured-output.start` (synthesizing a messageId if the
|
|
2061
|
+
// adapter hasn't picked one yet) so that the client processor can route
|
|
2062
|
+
// the forthcoming error chunk into a `structured-output` part on the
|
|
2063
|
+
// placeholder assistant message. Without this, a RUN_ERROR that fires
|
|
2064
|
+
// before the adapter has yielded any TEXT_MESSAGE_START leaves the
|
|
2065
|
+
// assistant message with zero parts — the structured-output UI surface
|
|
2066
|
+
// never sees the error.
|
|
2067
|
+
const emitStartIfNeeded = function* (
|
|
2068
|
+
referenceChunk: StreamChunk,
|
|
2069
|
+
): Generator<StreamChunk, void, void> {
|
|
2070
|
+
if (startEmitted) return
|
|
2071
|
+
const idForStart = structuredMessageId ?? generateMessageId()
|
|
2072
|
+
structuredMessageId = idForStart
|
|
2073
|
+
startEmitted = true
|
|
2074
|
+
yield {
|
|
2075
|
+
type: EventType.CUSTOM,
|
|
2076
|
+
name: 'structured-output.start',
|
|
2077
|
+
value: { messageId: idForStart },
|
|
2078
|
+
model:
|
|
2079
|
+
'model' in referenceChunk ? (referenceChunk.model ?? model) : model,
|
|
2080
|
+
timestamp:
|
|
2081
|
+
'timestamp' in referenceChunk
|
|
2082
|
+
? (referenceChunk.timestamp ?? Date.now())
|
|
2083
|
+
: Date.now(),
|
|
2084
|
+
runId,
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2024
2088
|
for await (const chunk of stream) {
|
|
2089
|
+
if (!structuredMessageId) {
|
|
2090
|
+
if (
|
|
2091
|
+
chunk.type === EventType.TEXT_MESSAGE_START ||
|
|
2092
|
+
chunk.type === EventType.TEXT_MESSAGE_CONTENT
|
|
2093
|
+
) {
|
|
2094
|
+
structuredMessageId = extractMessageId(chunk)
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
// RUN_ERROR before any text deltas: synthesize the structured-output.start
|
|
2099
|
+
// so the client snaps an errored part instead of a silent UI. The
|
|
2100
|
+
// synthesized messageId becomes the assistant message id the client
|
|
2101
|
+
// creates on its side (handleRunErrorEvent calls ensureAssistantMessage()
|
|
2102
|
+
// which picks up the same id from the structured-output.start above).
|
|
2103
|
+
if (chunk.type === EventType.RUN_ERROR && !startEmitted) {
|
|
2104
|
+
yield* emitStartIfNeeded(chunk)
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
// Adapter emitted content with no usable messageId. Routing JSON deltas
|
|
2108
|
+
// into a TextPart would silently render raw JSON in the user's chat, so
|
|
2109
|
+
// fail loudly here instead.
|
|
2110
|
+
if (!structuredMessageId && chunk.type === EventType.TEXT_MESSAGE_CONTENT) {
|
|
2111
|
+
yield {
|
|
2112
|
+
type: EventType.RUN_ERROR,
|
|
2113
|
+
runId,
|
|
2114
|
+
model,
|
|
2115
|
+
timestamp: Date.now(),
|
|
2116
|
+
message:
|
|
2117
|
+
'Structured-output stream produced text content without a messageId; ' +
|
|
2118
|
+
'adapter is not honoring the AG-UI contract.',
|
|
2119
|
+
code: 'structured-output-missing-message-id',
|
|
2120
|
+
}
|
|
2121
|
+
return
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
if (
|
|
2125
|
+
!startEmitted &&
|
|
2126
|
+
structuredMessageId &&
|
|
2127
|
+
(chunk.type === EventType.TEXT_MESSAGE_START ||
|
|
2128
|
+
chunk.type === EventType.TEXT_MESSAGE_CONTENT)
|
|
2129
|
+
) {
|
|
2130
|
+
startEmitted = true
|
|
2131
|
+
yield {
|
|
2132
|
+
type: EventType.CUSTOM,
|
|
2133
|
+
name: 'structured-output.start',
|
|
2134
|
+
value: { messageId: structuredMessageId },
|
|
2135
|
+
model: 'model' in chunk ? (chunk.model ?? model) : model,
|
|
2136
|
+
timestamp:
|
|
2137
|
+
'timestamp' in chunk ? (chunk.timestamp ?? Date.now()) : Date.now(),
|
|
2138
|
+
runId,
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2025
2142
|
if (
|
|
2026
2143
|
chunk.type === EventType.CUSTOM &&
|
|
2027
2144
|
chunk.name === 'structured-output.complete'
|
|
@@ -2041,10 +2158,15 @@ async function* runStreamingStructuredOutputImpl<TSchema extends SchemaInput>(
|
|
|
2041
2158
|
...chunk,
|
|
2042
2159
|
// Forward `reasoning` through schema validation so consumers that
|
|
2043
2160
|
// only listen for the terminal event don't lose chain-of-thought.
|
|
2161
|
+
// Tag with messageId so the client processor can snap the right
|
|
2162
|
+
// assistant message's structured-output part.
|
|
2044
2163
|
value: {
|
|
2045
2164
|
object: validated,
|
|
2046
2165
|
raw: value.raw,
|
|
2047
2166
|
...(value.reasoning ? { reasoning: value.reasoning } : {}),
|
|
2167
|
+
...(structuredMessageId
|
|
2168
|
+
? { messageId: structuredMessageId }
|
|
2169
|
+
: {}),
|
|
2048
2170
|
},
|
|
2049
2171
|
}
|
|
2050
2172
|
continue
|
|
@@ -2076,6 +2198,18 @@ async function* runStreamingStructuredOutputImpl<TSchema extends SchemaInput>(
|
|
|
2076
2198
|
return
|
|
2077
2199
|
}
|
|
2078
2200
|
}
|
|
2201
|
+
// No Standard schema (raw JSONSchema). Still tag the terminal event
|
|
2202
|
+
// with messageId so the client processor can snap the right part.
|
|
2203
|
+
if (structuredMessageId) {
|
|
2204
|
+
yield {
|
|
2205
|
+
...chunk,
|
|
2206
|
+
value: {
|
|
2207
|
+
...(chunk.value as Record<string, unknown>),
|
|
2208
|
+
messageId: structuredMessageId,
|
|
2209
|
+
},
|
|
2210
|
+
}
|
|
2211
|
+
continue
|
|
2212
|
+
}
|
|
2079
2213
|
yield chunk
|
|
2080
2214
|
continue
|
|
2081
2215
|
}
|
|
@@ -24,6 +24,14 @@ function isContentPart(part: MessagePart): part is ContentPart {
|
|
|
24
24
|
)
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function safeJsonStringify(value: unknown): string {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.stringify(value)
|
|
30
|
+
} catch {
|
|
31
|
+
return ''
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
/**
|
|
28
36
|
* Collapse an array of ContentParts into the most compact ModelMessage content:
|
|
29
37
|
* - Empty array → null
|
|
@@ -63,15 +71,55 @@ function getTextContent(content: string | null | Array<ContentPart>): string {
|
|
|
63
71
|
export function convertMessagesToModelMessages(
|
|
64
72
|
messages: Array<UIMessage | ModelMessage>,
|
|
65
73
|
): Array<ModelMessage> {
|
|
74
|
+
// Pre-pass: collect toolCallIds already represented in anchor UIMessage parts.
|
|
75
|
+
// Fan-out tool messages whose toolCallId matches an anchored ToolResultPart
|
|
76
|
+
// are AG-UI duplicates and must be dropped to avoid double-feeding the LLM.
|
|
77
|
+
const anchoredToolCallIds = new Set<string>()
|
|
78
|
+
for (const msg of messages) {
|
|
79
|
+
if ('parts' in msg) {
|
|
80
|
+
for (const part of msg.parts) {
|
|
81
|
+
if (part.type === 'tool-result') {
|
|
82
|
+
anchoredToolCallIds.add(part.toolCallId)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
66
88
|
const modelMessages: Array<ModelMessage> = []
|
|
67
89
|
for (const msg of messages) {
|
|
68
90
|
if ('parts' in msg) {
|
|
69
|
-
// UIMessage
|
|
91
|
+
// UIMessage anchor — existing fan-out path
|
|
70
92
|
modelMessages.push(...uiMessageToModelMessages(msg))
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const role = (msg as { role: string }).role
|
|
97
|
+
|
|
98
|
+
// AG-UI tool fan-out duplicate — drop if anchor already covers it
|
|
99
|
+
if (
|
|
100
|
+
role === 'tool' &&
|
|
101
|
+
msg.toolCallId &&
|
|
102
|
+
anchoredToolCallIds.has(msg.toolCallId)
|
|
103
|
+
) {
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// AG-UI reasoning and activity — no ModelMessage equivalent today
|
|
108
|
+
if (role === 'reasoning' || role === 'activity') {
|
|
109
|
+
continue
|
|
74
110
|
}
|
|
111
|
+
|
|
112
|
+
// AG-UI developer — collapse to system
|
|
113
|
+
if (role === 'developer') {
|
|
114
|
+
modelMessages.push({
|
|
115
|
+
role: 'system' as ModelMessage['role'],
|
|
116
|
+
content: (msg as { content: string }).content,
|
|
117
|
+
} as ModelMessage)
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Already a ModelMessage (user, assistant, system, tool with no anchor) — pass through
|
|
122
|
+
modelMessages.push(msg)
|
|
75
123
|
}
|
|
76
124
|
return modelMessages
|
|
77
125
|
}
|
|
@@ -244,6 +292,24 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
|
|
|
244
292
|
}
|
|
245
293
|
break
|
|
246
294
|
|
|
295
|
+
case 'structured-output':
|
|
296
|
+
// Only emit completed structured responses into history. Streaming or
|
|
297
|
+
// errored buffers would push malformed JSON into the next LLM turn's
|
|
298
|
+
// assistant content. `raw` is the source of truth; `data` is the
|
|
299
|
+
// defensive fallback for terminal-only completes that didn't ship raw.
|
|
300
|
+
if (part.status === 'complete') {
|
|
301
|
+
const serialized =
|
|
302
|
+
part.raw !== ''
|
|
303
|
+
? part.raw
|
|
304
|
+
: part.data !== undefined
|
|
305
|
+
? safeJsonStringify(part.data)
|
|
306
|
+
: ''
|
|
307
|
+
if (serialized !== '') {
|
|
308
|
+
current.contentParts.push({ type: 'text', content: serialized })
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
break
|
|
312
|
+
|
|
247
313
|
default:
|
|
248
314
|
break
|
|
249
315
|
}
|
|
@@ -28,7 +28,18 @@ export interface ChatMiddlewareContext {
|
|
|
28
28
|
requestId: string
|
|
29
29
|
/** Unique identifier for this stream */
|
|
30
30
|
streamId: string
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* AG-UI thread identifier — a stable per-conversation ID used to
|
|
33
|
+
* correlate client and server devtools events. Resolves to the
|
|
34
|
+
* caller-provided `threadId` (or legacy `conversationId`), or an
|
|
35
|
+
* auto-generated value when neither is supplied.
|
|
36
|
+
*/
|
|
37
|
+
threadId: string
|
|
38
|
+
/**
|
|
39
|
+
* @deprecated Use `threadId` instead. Retained as an alias of
|
|
40
|
+
* `threadId` so middleware written before the AG-UI rename keeps
|
|
41
|
+
* working unchanged. Will be removed in a future major release.
|
|
42
|
+
*/
|
|
32
43
|
conversationId?: string
|
|
33
44
|
/** Current lifecycle phase */
|
|
34
45
|
phase: ChatMiddlewarePhase
|
|
@@ -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.
|