@tanstack/ai 0.18.0 → 0.19.1
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 +60 -11
- 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 +67 -19
- package/src/utilities/ag-ui-wire.ts +24 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
4
4
|
"description": "Core TanStack AI library - Open source AI SDK",
|
|
5
5
|
"author": "Tanner Linsley",
|
|
6
6
|
"license": "MIT",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@ag-ui/core": "^0.0.52",
|
|
57
57
|
"partial-json": "^0.1.7",
|
|
58
|
-
"@tanstack/ai-event-client": "0.3.
|
|
58
|
+
"@tanstack/ai-event-client": "0.3.4"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"@opentelemetry/api": ">=1.9.0"
|
|
@@ -1,23 +1,30 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: ai-core/structured-outputs
|
|
3
3
|
description: >
|
|
4
|
-
Type-safe JSON schema responses from LLMs using outputSchema on chat()
|
|
5
|
-
Supports Zod, ArkType, and Valibot schemas. The adapter
|
|
6
|
-
provider-specific strategies transparently — never configure
|
|
7
|
-
output at the provider level. Pass stream:true alongside
|
|
8
|
-
incremental JSON deltas + a terminal validated object
|
|
9
|
-
`structured-output.complete` event.
|
|
10
|
-
|
|
4
|
+
Type-safe JSON schema responses from LLMs using outputSchema on chat()
|
|
5
|
+
and useChat(). Supports Zod, ArkType, and Valibot schemas. The adapter
|
|
6
|
+
handles provider-specific strategies transparently — never configure
|
|
7
|
+
structured output at the provider level. Pass stream:true alongside
|
|
8
|
+
outputSchema for incremental JSON deltas + a terminal validated object
|
|
9
|
+
via the `structured-output.complete` event. Every assistant turn in
|
|
10
|
+
useChat carries its own typed `StructuredOutputPart` on
|
|
11
|
+
`messages[i].parts`, so multi-turn structured chats preserve history
|
|
12
|
+
automatically — partial/final derive from the latest assistant turn's
|
|
13
|
+
part. convertSchemaToJsonSchema() for manual schema conversion.
|
|
11
14
|
type: sub-skill
|
|
12
15
|
library: tanstack-ai
|
|
13
16
|
library_version: '0.10.0'
|
|
14
17
|
sources:
|
|
15
|
-
- 'TanStack/ai:docs/
|
|
18
|
+
- 'TanStack/ai:docs/structured-outputs/overview.md'
|
|
19
|
+
- 'TanStack/ai:docs/structured-outputs/one-shot.md'
|
|
20
|
+
- 'TanStack/ai:docs/structured-outputs/streaming.md'
|
|
21
|
+
- 'TanStack/ai:docs/structured-outputs/multi-turn.md'
|
|
22
|
+
- 'TanStack/ai:docs/structured-outputs/with-tools.md'
|
|
16
23
|
---
|
|
17
24
|
|
|
18
25
|
# Structured Outputs
|
|
19
26
|
|
|
20
|
-
> **Dependency note:** This skill builds on ai-core. Read it first for critical rules.
|
|
27
|
+
> **Dependency note:** This skill builds on ai-core. Read it first for critical rules. The `useChat` patterns below build on ai-core/chat-experience — read that for the base hook surface, then come back here for the structured-output specifics.
|
|
21
28
|
|
|
22
29
|
## Setup
|
|
23
30
|
|
|
@@ -26,29 +33,32 @@ import { chat } from '@tanstack/ai'
|
|
|
26
33
|
import { openaiText } from '@tanstack/ai-openai'
|
|
27
34
|
import { z } from 'zod'
|
|
28
35
|
|
|
29
|
-
const
|
|
36
|
+
const person = await chat({
|
|
30
37
|
adapter: openaiText('gpt-5.2'),
|
|
31
|
-
messages: [
|
|
32
|
-
{
|
|
33
|
-
role: 'user',
|
|
34
|
-
content: [
|
|
35
|
-
{
|
|
36
|
-
type: 'text',
|
|
37
|
-
content: 'Extract the person info from: John is 30 years old',
|
|
38
|
-
},
|
|
39
|
-
],
|
|
40
|
-
},
|
|
41
|
-
],
|
|
38
|
+
messages: [{ role: 'user', content: 'John Doe, 30' }],
|
|
42
39
|
outputSchema: z.object({
|
|
43
40
|
name: z.string(),
|
|
44
41
|
age: z.number(),
|
|
45
42
|
}),
|
|
46
43
|
})
|
|
44
|
+
|
|
45
|
+
person.name // string — fully typed, no cast
|
|
46
|
+
person.age // number
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
When `outputSchema` is provided, `chat()` returns `Promise<InferSchemaType<TSchema>>` instead of `AsyncIterable<StreamChunk>`. The result is fully typed
|
|
49
|
+
When `outputSchema` is provided, `chat()` returns `Promise<InferSchemaType<TSchema>>` instead of `AsyncIterable<StreamChunk>`. The result is fully typed.
|
|
50
|
+
|
|
51
|
+
Adding `stream: true` switches the return to `StructuredOutputStream<InferSchemaType<TSchema>>` — incremental JSON deltas plus a terminal validated object. See **Pattern 3** below for direct iteration, **Pattern 4** for the `useChat` shape on the client, and **Pattern 5** for multi-turn structured chats.
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
## Decision: which pattern fits
|
|
54
|
+
|
|
55
|
+
| Building this | Use |
|
|
56
|
+
| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
|
57
|
+
| One prompt in → one typed object out (script, server endpoint, CLI) | Pattern 1 (basic) or 2 (nested) |
|
|
58
|
+
| A UI that fills in field by field as the model streams (progressive form, live card) | Pattern 4 — `useChat({ outputSchema })` |
|
|
59
|
+
| Direct iteration of the stream in Node or tests | Pattern 3 — async iterable |
|
|
60
|
+
| Users iterate on a structured object across multiple turns (recipe builder, ticket refinement) | Pattern 5 — multi-turn structured chat |
|
|
61
|
+
| Tools that gather info, then return a typed object | Combine any of the above with `tools` — see ai-core/tool-calling |
|
|
52
62
|
|
|
53
63
|
## Core Patterns
|
|
54
64
|
|
|
@@ -132,9 +142,9 @@ console.log(company.employees[0].role)
|
|
|
132
142
|
console.log(company.financials?.revenue)
|
|
133
143
|
```
|
|
134
144
|
|
|
135
|
-
### Pattern 3:
|
|
145
|
+
### Pattern 3: Direct stream iteration
|
|
136
146
|
|
|
137
|
-
Pass `stream: true` alongside `outputSchema` to
|
|
147
|
+
Pass `stream: true` alongside `outputSchema` to get an async iterable of standard streaming chunks plus a terminal validated object. Use this when you're a single process end-to-end — Node script, CLI, test, or a server endpoint that responds with one JSON blob. For the in-browser progressive-UI case, jump to Pattern 4 instead.
|
|
138
148
|
|
|
139
149
|
```typescript
|
|
140
150
|
import { chat } from '@tanstack/ai'
|
|
@@ -156,15 +166,8 @@ const stream = chat({
|
|
|
156
166
|
stream: true,
|
|
157
167
|
})
|
|
158
168
|
|
|
159
|
-
let raw = ''
|
|
160
169
|
for await (const chunk of stream) {
|
|
161
|
-
if (chunk.type === '
|
|
162
|
-
// Partial JSON text — drive progress UI only. Do NOT JSON.parse.
|
|
163
|
-
raw += chunk.delta
|
|
164
|
-
} else if (
|
|
165
|
-
chunk.type === 'CUSTOM' &&
|
|
166
|
-
chunk.name === 'structured-output.complete'
|
|
167
|
-
) {
|
|
170
|
+
if (chunk.type === 'CUSTOM' && chunk.name === 'structured-output.complete') {
|
|
168
171
|
// Terminal event. `chunk.value.object` is fully validated and typed
|
|
169
172
|
// against the schema you passed in — no helper or cast required.
|
|
170
173
|
chunk.value.object.name // string
|
|
@@ -174,7 +177,7 @@ for await (const chunk of stream) {
|
|
|
174
177
|
}
|
|
175
178
|
```
|
|
176
179
|
|
|
177
|
-
The terminal event is a `CUSTOM` chunk: `{ type: 'CUSTOM', name: 'structured-output.complete', value: { object: T, raw: string, reasoning?: string } }`. The return type of `chat({ outputSchema, stream: true })` carries `T` through
|
|
180
|
+
The terminal event is a `CUSTOM` chunk: `{ type: 'CUSTOM', name: 'structured-output.complete', value: { object: T, raw: string, reasoning?: string } }`. The return type of `chat({ outputSchema, stream: true })` carries `T` through, so a plain discriminated narrow (`chunk.type === 'CUSTOM' && chunk.name === 'structured-output.complete'`) is enough — no type guard helper.
|
|
178
181
|
|
|
179
182
|
**Adapter coverage for streaming:**
|
|
180
183
|
|
|
@@ -186,13 +189,208 @@ The terminal event is a `CUSTOM` chunk: `{ type: 'CUSTOM', name: 'structured-out
|
|
|
186
189
|
| `@tanstack/ai-groq` | Native single-request stream (Chat Completions) |
|
|
187
190
|
| All other adapters (anthropic, gemini, ollama, …) | Fallback: runs non-streaming `structuredOutput`, emits one `structured-output.complete` event |
|
|
188
191
|
|
|
189
|
-
|
|
192
|
+
Consumer code is identical across providers — always read the final object off `structured-output.complete`. You only see incremental `TEXT_MESSAGE_CONTENT` deltas when the adapter implements `structuredOutputStream` natively.
|
|
193
|
+
|
|
194
|
+
### Pattern 4: useChat with outputSchema (progressive UI)
|
|
195
|
+
|
|
196
|
+
Pass `outputSchema` to `useChat` and you get a `partial` field that fills in as JSON streams in, plus a `final` field that snaps to the validated object on the terminal event. No `onChunk` ceremony, no manual JSON accumulation, no `parsePartialJSON` calls.
|
|
197
|
+
|
|
198
|
+
**Server** (same as Pattern 3, just behind an SSE endpoint):
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
// app/api/extract-person/route.ts (or your framework's equivalent)
|
|
202
|
+
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
|
|
203
|
+
import { openaiText } from '@tanstack/ai-openai'
|
|
204
|
+
import { z } from 'zod'
|
|
205
|
+
|
|
206
|
+
const PersonSchema = z.object({
|
|
207
|
+
name: z.string(),
|
|
208
|
+
age: z.number(),
|
|
209
|
+
email: z.string().email(),
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
export async function POST(request: Request) {
|
|
213
|
+
const { messages } = await request.json()
|
|
214
|
+
const stream = chat({
|
|
215
|
+
adapter: openaiText('gpt-5.2'),
|
|
216
|
+
messages,
|
|
217
|
+
outputSchema: PersonSchema,
|
|
218
|
+
stream: true,
|
|
219
|
+
})
|
|
220
|
+
return toServerSentEventsResponse(stream)
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**Client:**
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
|
|
228
|
+
import { z } from 'zod'
|
|
229
|
+
|
|
230
|
+
const PersonSchema = z.object({
|
|
231
|
+
name: z.string(),
|
|
232
|
+
age: z.number(),
|
|
233
|
+
email: z.string().email(),
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
function PersonExtractor() {
|
|
237
|
+
const { sendMessage, isLoading, partial, final } = useChat({
|
|
238
|
+
connection: fetchServerSentEvents('/api/extract-person'),
|
|
239
|
+
outputSchema: PersonSchema,
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<div>
|
|
244
|
+
<button
|
|
245
|
+
disabled={isLoading}
|
|
246
|
+
onClick={() => sendMessage('Extract: John Doe, 30, john@example.com')}
|
|
247
|
+
>
|
|
248
|
+
Extract
|
|
249
|
+
</button>
|
|
250
|
+
{/* `partial` fills in field by field while streaming. */}
|
|
251
|
+
<p>Name: {partial.name ?? '…'}</p>
|
|
252
|
+
<p>Age: {partial.age ?? '…'}</p>
|
|
253
|
+
<p>Email: {partial.email ?? '…'}</p>
|
|
254
|
+
{final && <pre>Validated: {JSON.stringify(final, null, 2)}</pre>}
|
|
255
|
+
</div>
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
- `partial` is `DeepPartial<z.infer<typeof PersonSchema>>` — every property optional, every nested array element optional. Updated from `TEXT_MESSAGE_CONTENT` deltas.
|
|
261
|
+
- `final` is `z.infer<typeof PersonSchema> | null` — populated when `structured-output.complete` arrives.
|
|
262
|
+
- `outputSchema` is for client-side type inference only. **Validation runs on the server** against the schema you pass to `chat({ outputSchema })` there.
|
|
263
|
+
- Same shape works for non-streaming adapters: the fallback path emits one whole-JSON `TEXT_MESSAGE_CONTENT` then the terminal event, so `partial` populates and `final` snaps in the same render tick — same consumer code as the native-streaming providers, just without an intermediate field-by-field reveal.
|
|
264
|
+
|
|
265
|
+
### Pattern 5: Multi-turn structured chat
|
|
266
|
+
|
|
267
|
+
Every assistant turn produced by `useChat({ outputSchema })` carries its own typed `StructuredOutputPart` on `messages[i].parts`. Old turns stay renderable; new turns produce new parts; history is preserved without manual state plumbing. This is what makes the recipe-builder shape ("now make it vegan") work.
|
|
268
|
+
|
|
269
|
+
```tsx
|
|
270
|
+
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
|
|
271
|
+
import type { StructuredOutputPart } from '@tanstack/ai-client'
|
|
272
|
+
import { z } from 'zod'
|
|
273
|
+
|
|
274
|
+
const RecipeSchema = z.object({
|
|
275
|
+
title: z.string(),
|
|
276
|
+
cuisine: z.string(),
|
|
277
|
+
servings: z.number(),
|
|
278
|
+
ingredients: z.array(z.object({ item: z.string(), amount: z.string() })),
|
|
279
|
+
steps: z.array(z.string()),
|
|
280
|
+
})
|
|
281
|
+
type Recipe = z.infer<typeof RecipeSchema>
|
|
282
|
+
type RecipePart = StructuredOutputPart<Recipe>
|
|
283
|
+
|
|
284
|
+
function RecipeBuilder() {
|
|
285
|
+
const { messages, sendMessage } = useChat({
|
|
286
|
+
outputSchema: RecipeSchema,
|
|
287
|
+
connection: fetchServerSentEvents('/api/recipes'),
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
<div>
|
|
292
|
+
{messages.map((m) => {
|
|
293
|
+
if (m.role === 'user') {
|
|
294
|
+
const text = m.parts
|
|
295
|
+
.filter((p) => p.type === 'text')
|
|
296
|
+
.map((p) => p.content)
|
|
297
|
+
.join('')
|
|
298
|
+
return <UserBubble key={m.id} text={text} />
|
|
299
|
+
}
|
|
300
|
+
if (m.role === 'assistant') {
|
|
301
|
+
// `data` is `Recipe` because the schema generic flows from
|
|
302
|
+
// `useChat({ outputSchema })` through `messages` to the part.
|
|
303
|
+
const part = m.parts.find(
|
|
304
|
+
(p): p is RecipePart => p.type === 'structured-output',
|
|
305
|
+
)
|
|
306
|
+
if (!part) return null
|
|
307
|
+
return <RecipeCard key={m.id} part={part} />
|
|
308
|
+
}
|
|
309
|
+
return null
|
|
310
|
+
})}
|
|
311
|
+
<button onClick={() => sendMessage('pasta for two')}>Cook</button>
|
|
312
|
+
<button onClick={() => sendMessage('now make it vegan')}>Modify</button>
|
|
313
|
+
</div>
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function RecipeCard({ part }: { part: RecipePart }) {
|
|
318
|
+
// `data` lands on complete, `partial` fills in while streaming.
|
|
319
|
+
// Both are typed against the schema. No casts.
|
|
320
|
+
const recipe = part.data ?? part.partial ?? ({} as Partial<Recipe>)
|
|
321
|
+
return <h3>{recipe.title ?? 'Plating up…'}</h3>
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Key behaviors:
|
|
326
|
+
|
|
327
|
+
- **Per-turn parts.** Each `sendMessage()` produces a new assistant message with its own `StructuredOutputPart`. The previous turn's part is untouched — `messages.map(...)` renders the whole history.
|
|
328
|
+
- **Typed by schema.** `messages[i].parts.find(p => p.type === 'structured-output').data` is typed as `Recipe` (no cast, no `unknown`). Works because `useChat<TSchema>` threads `InferSchemaType<TSchema>` down through `UIMessage<TTools, TData>` → `MessagePart<TTools, TData>` → `StructuredOutputPart<TData>`. **In `@tanstack/ai` core** the message types are single-generic (`UIMessage<TData>`); the tools generic lives in `@tanstack/ai-client` and the framework hook packages — import from your framework package or `ai-client`, not from `@tanstack/ai`.
|
|
329
|
+
- **`partial` / `final` are derived.** The hook-level `partial` and `final` are NOT singleton state — they're derived from the latest assistant message's part (the one after the most recent user message). Between `sendMessage()` and the first chunk, `partial` reads `{}` and `final` reads `null` because no new assistant turn exists yet.
|
|
330
|
+
- **Round-trip preserves history.** When the client sends turn N+1, each prior assistant turn's `structured-output` part is serialized back as `{ role: 'assistant', content: <part.raw> }` so the model sees its own prior structured response. Streaming / errored parts are dropped from the round-trip.
|
|
190
331
|
|
|
191
332
|
## Common Mistakes
|
|
192
333
|
|
|
334
|
+
### HIGH: Filtering `TextPart`s out of `useChat` renderers when using `outputSchema`
|
|
335
|
+
|
|
336
|
+
Earlier versions of the library routed structured-output JSON deltas through `TextPart`, so renderers had to filter them out:
|
|
337
|
+
|
|
338
|
+
```tsx
|
|
339
|
+
// OBSOLETE — this guard was needed only because JSON used to land in a TextPart
|
|
340
|
+
const last = messages.at(-1)
|
|
341
|
+
last?.parts.map((part) => {
|
|
342
|
+
if (part.type === 'text') return null // ❌ hides the structured JSON
|
|
343
|
+
// ...
|
|
344
|
+
})
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
That hack is **gone**. With `outputSchema` set, `TEXT_MESSAGE_CONTENT` deltas now route into a dedicated `StructuredOutputPart` (with `raw`, `partial`, `data`, `status`, optional `errorMessage`). Render the structured part directly; let real `TextPart`s through.
|
|
348
|
+
|
|
349
|
+
```tsx
|
|
350
|
+
// CORRECT — find the structured-output part directly; let actual TextParts render
|
|
351
|
+
last?.parts.map((part, i) => {
|
|
352
|
+
if (part.type === 'thinking')
|
|
353
|
+
return <ReasoningView key={i} text={part.content} />
|
|
354
|
+
if (part.type === 'tool-call') return <ToolCallView key={i} part={part} />
|
|
355
|
+
if (part.type === 'structured-output')
|
|
356
|
+
return <RecipeCard key={i} part={part} />
|
|
357
|
+
if (part.type === 'text') return <p key={i}>{part.content}</p> // ← real text, not JSON
|
|
358
|
+
return null
|
|
359
|
+
})
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
If you still have an `if (part.type === 'text') return null` line in a structured-output renderer specifically for "hiding the JSON," delete it.
|
|
363
|
+
|
|
364
|
+
Source: PR #577 — structured-output became a typed UIMessage part.
|
|
365
|
+
|
|
366
|
+
### HIGH: Treating `partial` / `final` as sticky state across turns
|
|
367
|
+
|
|
368
|
+
`partial` and `final` are **derived from the latest assistant message's `structured-output` part**, not a sticky hook-level slot. In a multi-turn chat:
|
|
369
|
+
|
|
370
|
+
- Between `sendMessage()` and the first chunk, `partial` reads `{}` and `final` reads `null` (no assistant message after the latest user yet).
|
|
371
|
+
- Once the latest turn completes, `partial === final`. Earlier turns' data is NOT in `partial` / `final` — it lives on the prior assistant messages' parts.
|
|
372
|
+
|
|
373
|
+
To render history, walk `messages` directly (see Pattern 5). Use `partial` / `final` for a sticky summary of the **most recent** turn only.
|
|
374
|
+
|
|
375
|
+
```tsx
|
|
376
|
+
// WRONG — `final` only reflects the latest turn; earlier recipes vanish from this view
|
|
377
|
+
{final && <RecipeCard recipe={final} />}
|
|
378
|
+
|
|
379
|
+
// CORRECT for history — walk messages, render every assistant's structured-output part
|
|
380
|
+
{messages.map((m) =>
|
|
381
|
+
m.role === 'assistant'
|
|
382
|
+
? m.parts.find((p) => p.type === 'structured-output')
|
|
383
|
+
? <RecipeCard key={m.id} part={...} />
|
|
384
|
+
: null
|
|
385
|
+
: null
|
|
386
|
+
)}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Source: PR #577 — partial/final derive from the latest assistant turn's part.
|
|
390
|
+
|
|
193
391
|
### HIGH: Parsing streaming JSON deltas yourself
|
|
194
392
|
|
|
195
|
-
When
|
|
393
|
+
When iterating `chat({ outputSchema, stream: true })` directly (Pattern 3), the `TEXT_MESSAGE_CONTENT` chunks contain _partial_ JSON fragments — they are not valid JSON until the stream completes. Always read the validated object from the terminal `structured-output.complete` event. Validation runs once, on the complete payload.
|
|
196
394
|
|
|
197
395
|
```typescript
|
|
198
396
|
// WRONG -- partial JSON, throws SyntaxError mid-stream, no schema validation
|
|
@@ -202,21 +400,15 @@ for await (const chunk of stream) {
|
|
|
202
400
|
}
|
|
203
401
|
}
|
|
204
402
|
|
|
205
|
-
// CORRECT --
|
|
206
|
-
let raw = ''
|
|
403
|
+
// CORRECT -- trust the terminal event
|
|
207
404
|
for await (const chunk of stream) {
|
|
208
|
-
if (chunk.type === '
|
|
209
|
-
raw += chunk.delta // optional: render a "streaming JSON" preview
|
|
210
|
-
} else if (
|
|
211
|
-
chunk.type === 'CUSTOM' &&
|
|
212
|
-
chunk.name === 'structured-output.complete'
|
|
213
|
-
) {
|
|
405
|
+
if (chunk.type === 'CUSTOM' && chunk.name === 'structured-output.complete') {
|
|
214
406
|
const result = chunk.value.object // ✅ typed and validated
|
|
215
407
|
}
|
|
216
408
|
}
|
|
217
409
|
```
|
|
218
410
|
|
|
219
|
-
If you need progressive
|
|
411
|
+
If you need progressive parsed state in a non-React environment, use a partial-JSON parser on the accumulated raw string at render time — but do NOT treat the result as schema-validated; only the terminal event is. In `useChat`, this is already done for you (`partial` field on Pattern 4).
|
|
220
412
|
|
|
221
413
|
Source: maintainer interview
|
|
222
414
|
|
|
@@ -290,5 +482,6 @@ Source: maintainer interview
|
|
|
290
482
|
|
|
291
483
|
## Cross-References
|
|
292
484
|
|
|
293
|
-
- See also: ai-core/
|
|
294
|
-
- See also: ai-core/
|
|
485
|
+
- See also: **ai-core/chat-experience/SKILL.md** — Base `useChat` surface; the structured-output additions documented here layer on top.
|
|
486
|
+
- See also: **ai-core/adapter-configuration/SKILL.md** — Adapter handles structured-output strategy transparently.
|
|
487
|
+
- See also: **ai-core/tool-calling/SKILL.md** — Combine `tools` with `outputSchema` for an agent loop that runs tools first and returns a typed object. Tool-approval and client-tool flows compose with structured runs without extra wiring; see [docs/structured-outputs/with-tools.md](https://github.com/TanStack/ai/blob/main/docs/structured-outputs/with-tools.md).
|
|
@@ -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,
|
|
@@ -2045,7 +2045,100 @@ async function* runStreamingStructuredOutputImpl<TSchema extends SchemaInput>(
|
|
|
2045
2045
|
outputSchema: jsonSchema,
|
|
2046
2046
|
})
|
|
2047
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
|
+
|
|
2048
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
|
+
|
|
2049
2142
|
if (
|
|
2050
2143
|
chunk.type === EventType.CUSTOM &&
|
|
2051
2144
|
chunk.name === 'structured-output.complete'
|
|
@@ -2065,10 +2158,15 @@ async function* runStreamingStructuredOutputImpl<TSchema extends SchemaInput>(
|
|
|
2065
2158
|
...chunk,
|
|
2066
2159
|
// Forward `reasoning` through schema validation so consumers that
|
|
2067
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.
|
|
2068
2163
|
value: {
|
|
2069
2164
|
object: validated,
|
|
2070
2165
|
raw: value.raw,
|
|
2071
2166
|
...(value.reasoning ? { reasoning: value.reasoning } : {}),
|
|
2167
|
+
...(structuredMessageId
|
|
2168
|
+
? { messageId: structuredMessageId }
|
|
2169
|
+
: {}),
|
|
2072
2170
|
},
|
|
2073
2171
|
}
|
|
2074
2172
|
continue
|
|
@@ -2100,6 +2198,18 @@ async function* runStreamingStructuredOutputImpl<TSchema extends SchemaInput>(
|
|
|
2100
2198
|
return
|
|
2101
2199
|
}
|
|
2102
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
|
+
}
|
|
2103
2213
|
yield chunk
|
|
2104
2214
|
continue
|
|
2105
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
|
|
@@ -284,6 +292,24 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
|
|
|
284
292
|
}
|
|
285
293
|
break
|
|
286
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
|
+
|
|
287
313
|
default:
|
|
288
314
|
break
|
|
289
315
|
}
|