@tanstack/ai 0.46.0 → 0.47.2
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/dist/esm/activities/chat/index.d.ts +36 -11
- package/dist/esm/activities/chat/index.js +440 -79
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/chat/messages.d.ts +1 -0
- package/dist/esm/activities/chat/messages.js +12 -7
- package/dist/esm/activities/chat/messages.js.map +1 -1
- package/dist/esm/activities/chat/middleware/builder.d.ts +7 -2
- package/dist/esm/activities/chat/middleware/builder.js.map +1 -1
- package/dist/esm/activities/chat/middleware/compose.d.ts +10 -3
- package/dist/esm/activities/chat/middleware/compose.js +55 -0
- package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
- package/dist/esm/activities/chat/middleware/define.d.ts +6 -3
- package/dist/esm/activities/chat/middleware/define.js.map +1 -1
- package/dist/esm/activities/chat/middleware/generic-interrupts.d.ts +13 -0
- package/dist/esm/activities/chat/middleware/generic-interrupts.js +8 -0
- package/dist/esm/activities/chat/middleware/generic-interrupts.js.map +1 -0
- package/dist/esm/activities/chat/middleware/index.d.ts +4 -1
- package/dist/esm/activities/chat/middleware/types.d.ts +54 -3
- package/dist/esm/activities/chat/middleware/types.js +16 -0
- package/dist/esm/activities/chat/middleware/types.js.map +1 -0
- package/dist/esm/activities/chat/stream/processor.js +18 -5
- package/dist/esm/activities/chat/stream/processor.js.map +1 -1
- package/dist/esm/adapter-internals.d.ts +6 -0
- package/dist/esm/adapter-internals.js +4 -1
- package/dist/esm/client.d.ts +4 -0
- package/dist/esm/client.js +3 -1
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/generic-interrupt-continuation.d.ts +45 -0
- package/dist/esm/generic-interrupt-continuation.js +80 -0
- package/dist/esm/generic-interrupt-continuation.js.map +1 -0
- package/dist/esm/index.d.ts +6 -1
- package/dist/esm/index.js +4 -1
- package/dist/esm/interrupt-definition.d.ts +113 -0
- package/dist/esm/interrupt-definition.js +169 -0
- package/dist/esm/interrupt-definition.js.map +1 -0
- package/dist/esm/interrupt-resume.d.ts +3 -0
- package/dist/esm/interrupt-resume.js +77 -16
- package/dist/esm/interrupt-resume.js.map +1 -1
- package/dist/esm/interrupts.d.ts +12 -3
- package/dist/esm/interrupts.js.map +1 -1
- package/dist/esm/types.d.ts +11 -3
- package/dist/esm/utilities/chat-params.js +10 -1
- package/dist/esm/utilities/chat-params.js.map +1 -1
- package/package.json +3 -3
- package/skills/ai-core/media-generation/SKILL.md +4 -1
- package/skills/ai-core/middleware/SKILL.md +53 -44
- package/skills/ai-core/structured-outputs/SKILL.md +59 -55
- package/skills/ai-core/tool-calling/SKILL.md +54 -1
- package/src/activities/chat/index.ts +1030 -211
- package/src/activities/chat/messages.ts +11 -3
- package/src/activities/chat/middleware/builder.ts +29 -4
- package/src/activities/chat/middleware/compose.ts +95 -5
- package/src/activities/chat/middleware/define.ts +13 -3
- package/src/activities/chat/middleware/generic-interrupts.ts +26 -0
- package/src/activities/chat/middleware/index.ts +15 -0
- package/src/activities/chat/middleware/types.ts +127 -2
- package/src/activities/chat/stream/processor.ts +21 -0
- package/src/adapter-internals.ts +20 -0
- package/src/client.ts +20 -0
- package/src/generic-interrupt-continuation.ts +162 -0
- package/src/index.ts +34 -0
- package/src/interrupt-definition.ts +581 -0
- package/src/interrupt-resume.ts +156 -25
- package/src/interrupts.ts +13 -3
- package/src/types.ts +11 -3
- package/src/utilities/chat-params.ts +16 -3
|
@@ -52,21 +52,21 @@ Every hook receives a `ChatMiddlewareContext` as its first argument, which provi
|
|
|
52
52
|
`requestId`, `streamId`, `phase`, `iteration`, `chunkIndex`, `model`, `provider`,
|
|
53
53
|
`signal`, `abort()`, `defer()`, and more.
|
|
54
54
|
|
|
55
|
-
| Hook | When
|
|
56
|
-
| -------------------------- |
|
|
57
|
-
| `onConfig` | Once at startup (`init`) + once per iteration (`beforeModel`) + once at
|
|
58
|
-
| `onStructuredOutputConfig` | Once at the
|
|
59
|
-
| `onStart` | Once after initial `onConfig`
|
|
60
|
-
| `onIteration` | Start of each agent loop iteration
|
|
61
|
-
| `onShouldContinue` | Whether to start another agent-loop iteration (AND with strategy; `false` stops)
|
|
62
|
-
| `onChunk` | Every streamed chunk
|
|
63
|
-
| `onBeforeToolCall` | Before each tool executes
|
|
64
|
-
| `onAfterToolCall` | After each tool executes
|
|
65
|
-
| `onToolPhaseComplete` | After all tool calls in an iteration
|
|
66
|
-
| `onUsage` | When `RUN_FINISHED` includes usage data
|
|
67
|
-
| `onFinish` | Run completed normally
|
|
68
|
-
| `onAbort` | Run was aborted
|
|
69
|
-
| `onError` | Unhandled error occurred
|
|
55
|
+
| Hook | When | Second Argument |
|
|
56
|
+
| -------------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
|
|
57
|
+
| `onConfig` | Once at startup (`init`) + once per iteration (`beforeModel`) + once at a separate-finalization boundary | `ChatMiddlewareConfig` (return partial to merge) |
|
|
58
|
+
| `onStructuredOutputConfig` | Once at the separate-finalization boundary | `StructuredOutputMiddlewareConfig` (return partial) |
|
|
59
|
+
| `onStart` | Once after initial `onConfig` | none |
|
|
60
|
+
| `onIteration` | Start of each agent loop iteration | `IterationInfo` |
|
|
61
|
+
| `onShouldContinue` | Whether to start another agent-loop iteration (AND with strategy; `false` stops) | `AgentLoopState` |
|
|
62
|
+
| `onChunk` | Every streamed chunk | `StreamChunk` (return void/chunk/chunk[]/null) |
|
|
63
|
+
| `onBeforeToolCall` | Before each tool executes | `ToolCallHookContext` (return decision or void) |
|
|
64
|
+
| `onAfterToolCall` | After each tool executes | `AfterToolCallInfo` |
|
|
65
|
+
| `onToolPhaseComplete` | After all tool calls in an iteration | `ToolPhaseCompleteInfo` |
|
|
66
|
+
| `onUsage` | When `RUN_FINISHED` includes usage data | `UsageInfo` |
|
|
67
|
+
| `onFinish` | Run completed normally | `FinishInfo` |
|
|
68
|
+
| `onAbort` | Run was aborted | `AbortInfo` |
|
|
69
|
+
| `onError` | Unhandled error occurred | `ErrorInfo` |
|
|
70
70
|
|
|
71
71
|
Terminal hooks (`onFinish`, `onAbort`, `onError`) are **mutually exclusive** -- exactly
|
|
72
72
|
one fires per `chat()` invocation.
|
|
@@ -82,31 +82,39 @@ one fires per `chat()` invocation.
|
|
|
82
82
|
|
|
83
83
|
`ctx.phase` is one of:
|
|
84
84
|
|
|
85
|
-
| Phase | When
|
|
86
|
-
| -------------------- |
|
|
87
|
-
| `'init'` | Initial setup (before the first `onConfig` snapshot is built).
|
|
88
|
-
| `'beforeModel'` | Right before each agent-loop adapter call (`onConfig` re-fires here).
|
|
89
|
-
| `'modelStream'` | During model streaming chunks within the agent loop.
|
|
90
|
-
| `'beforeTools'` | Before tool execution phase.
|
|
91
|
-
| `'afterTools'` | After tool execution phase.
|
|
92
|
-
| `'structuredOutput'` | During the
|
|
85
|
+
| Phase | When |
|
|
86
|
+
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
87
|
+
| `'init'` | Initial setup (before the first `onConfig` snapshot is built). |
|
|
88
|
+
| `'beforeModel'` | Right before each agent-loop adapter call (`onConfig` re-fires here). |
|
|
89
|
+
| `'modelStream'` | During model streaming chunks within the agent loop. |
|
|
90
|
+
| `'beforeTools'` | Before tool execution phase. |
|
|
91
|
+
| `'afterTools'` | After tool execution phase. |
|
|
92
|
+
| `'structuredOutput'` | During the separate-finalization adapter call (set for all chunks from `adapter.structuredOutputStream` or the synthesized fallback). Does not occur for native-combined output. |
|
|
93
93
|
|
|
94
|
-
**
|
|
94
|
+
**Separate-finalization path** (adapters without native-combined support):
|
|
95
95
|
|
|
96
96
|
- `onStructuredOutputConfig` fires **before** `onConfig` at the structured-output boundary.
|
|
97
97
|
- `onConfig` re-fires at the same boundary with `ctx.phase === 'structuredOutput'`, receiving the post-`onStructuredOutputConfig` view of the config (minus `outputSchema`).
|
|
98
98
|
- `onChunk` and `onUsage` fire for every chunk and usage event emitted by the structured-output call, with `ctx.phase === 'structuredOutput'`.
|
|
99
99
|
- `onIteration` does **not** fire for finalization — it is agent-loop-only.
|
|
100
|
-
- `onFinish` fires once at the end of the whole `chat()` invocation, **after** the structured-output finalization completes (not after the agent loop). Terminal-hook exclusivity still holds (one of `onFinish` / `onAbort` / `onError`).
|
|
101
100
|
- **Terminal `info` and structured-output:** `info.usage` / `info.finishReason` / `info.content` reflect the **agent loop's** terminal state, NOT the finalization step. Finalization state is intentionally segregated to keep agent-loop semantics clean. For a tools-less `chat({ outputSchema })` run, `info.usage` is `undefined` and `info.finishReason` is `null` (no agent-loop iteration produced `RUN_FINISHED`). To capture finalization tokens, use `onUsage` — it fires for both agent-loop iterations and the final call. For the structured-output result itself, observe the `structured-output.complete` CUSTOM event in `onChunk`.
|
|
102
101
|
|
|
102
|
+
**Native-combined output:**
|
|
103
|
+
|
|
104
|
+
- The schema-constrained JSON is produced by a normal agent-loop iteration. `onStructuredOutputConfig` does not fire, `ctx.phase` remains `'modelStream'`, and `onIteration` fires for that iteration.
|
|
105
|
+
- `info.content` includes the structured JSON because it is agent-loop text. Middleware observes the `structured-output.complete` event in `onChunk` during the same phase.
|
|
106
|
+
|
|
107
|
+
**Both paths:**
|
|
108
|
+
|
|
109
|
+
- On successful completion, `onFinish` fires once after the structured result completes. Terminal-hook exclusivity still holds.
|
|
110
|
+
- By `onFinish`, `ctx.messages` includes the completed terminal assistant messages. Native-combined output keeps the structured result on its terminal assistant message. The separate-finalization path can preserve the agent loop's plain-text message followed by a distinct structured-output message.
|
|
111
|
+
|
|
103
112
|
## onStructuredOutputConfig
|
|
104
113
|
|
|
105
|
-
A dedicated config hook that fires **only** at the
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
agent-loop adapter calls.
|
|
114
|
+
A dedicated config hook that fires **only** at the separate-finalization
|
|
115
|
+
boundary. Use it to transform the JSON Schema sent to the provider (inject
|
|
116
|
+
`$defs`, strip vendor-incompatible keywords) or to apply structured-output-
|
|
117
|
+
specific config changes that should not affect the agent-loop adapter calls.
|
|
110
118
|
|
|
111
119
|
**Signature:**
|
|
112
120
|
|
|
@@ -259,13 +267,14 @@ const toolGuard: ChatMiddleware = {
|
|
|
259
267
|
|
|
260
268
|
### Pattern 3: Structured-Output Middleware
|
|
261
269
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
`ctx.phase === 'structuredOutput'
|
|
265
|
-
middleware
|
|
266
|
-
not
|
|
270
|
+
On the separate-finalization path, the final structured-output adapter call
|
|
271
|
+
flows through the same middleware chain as the agent loop with
|
|
272
|
+
`ctx.phase === 'structuredOutput'`. Native-combined output has no separate
|
|
273
|
+
provider call: middleware observes its chunks during `modelStream`, and
|
|
274
|
+
`onStructuredOutputConfig` does not fire. Middleware cannot transform the
|
|
275
|
+
native-combined schema.
|
|
267
276
|
|
|
268
|
-
**Example A — Observability (tracing every chunk, including finalization):**
|
|
277
|
+
**Example A — Observability (tracing every chunk, including separate finalization):**
|
|
269
278
|
|
|
270
279
|
```typescript
|
|
271
280
|
import type { ChatMiddleware } from '@tanstack/ai'
|
|
@@ -278,10 +287,10 @@ const tracing: ChatMiddleware = {
|
|
|
278
287
|
}
|
|
279
288
|
```
|
|
280
289
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
290
|
+
On the separate-finalization path, this middleware observes every chunk from
|
|
291
|
+
the final structured-output call with `ctx.phase === 'structuredOutput'`. On
|
|
292
|
+
the native-combined path, it observes the structured stream with
|
|
293
|
+
`ctx.phase === 'modelStream'`.
|
|
285
294
|
|
|
286
295
|
**Example B — Schema rewriting (inject shared `$defs`):**
|
|
287
296
|
|
|
@@ -298,9 +307,9 @@ const injectDefs: ChatMiddleware = {
|
|
|
298
307
|
}
|
|
299
308
|
```
|
|
300
309
|
|
|
301
|
-
`onStructuredOutputConfig` is the right hook here
|
|
302
|
-
to `config.outputSchema
|
|
303
|
-
schema
|
|
310
|
+
`onStructuredOutputConfig` is the right hook here on the separate-finalization
|
|
311
|
+
path because it has direct access to `config.outputSchema`. Native-combined
|
|
312
|
+
schema transformation is not exposed through middleware.
|
|
304
313
|
|
|
305
314
|
### Pattern 4: Multiple Middleware Composition
|
|
306
315
|
|
|
@@ -779,6 +788,6 @@ Source: docs/advanced/middleware.md, `packages/ai/src/activities/chat/middleware
|
|
|
779
788
|
## Cross-References
|
|
780
789
|
|
|
781
790
|
- See also: **ai-core/chat-experience/SKILL.md** -- Middleware hooks into the chat lifecycle
|
|
782
|
-
- See also: **ai-core/structured-outputs/SKILL.md** --
|
|
791
|
+
- See also: **ai-core/structured-outputs/SKILL.md** -- Separate finalization uses `onStructuredOutputConfig` for JSON-Schema transforms; native-combined schema transformation is not exposed through middleware
|
|
783
792
|
- See also: **ai-core/ag-ui-protocol/SKILL.md** -- Reading the `sandbox.file` / `sandbox.file.diff` `CUSTOM` chunks the sandbox runtime emits alongside these `sandbox` hooks, via `ChatStream`'s typed `KnownCustomEvent` narrowing
|
|
784
793
|
- See also: **`@tanstack/ai-persistence` skills** (`skills/ai-persistence/SKILL.md` in that package) -- Full persistence suite (`withPersistence`, client storage, store contracts, adapter recipes, locks). This file only sketches server `withPersistence`.
|
|
@@ -5,12 +5,11 @@ description: >
|
|
|
5
5
|
and useChat(). Supports Zod, ArkType, and Valibot schemas. The adapter
|
|
6
6
|
handles provider-specific strategies transparently — never configure
|
|
7
7
|
structured output at the provider level. Pass stream:true alongside
|
|
8
|
-
outputSchema for incremental JSON deltas + a
|
|
9
|
-
via the `structured-output.complete` event.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
part. convertSchemaToJsonSchema() for manual schema conversion.
|
|
8
|
+
outputSchema for incremental JSON deltas + a completed typed object
|
|
9
|
+
via the `structured-output.complete` event. Each successfully completed
|
|
10
|
+
structured-output run adds a typed `StructuredOutputPart` to message
|
|
11
|
+
history. partial/final derive from the most recent structured-output part
|
|
12
|
+
after the latest user message. convertSchemaToJsonSchema() for manual schema conversion.
|
|
14
13
|
type: sub-skill
|
|
15
14
|
library: tanstack-ai
|
|
16
15
|
library_version: '0.42.0'
|
|
@@ -146,7 +145,7 @@ console.log(company.financials?.revenue)
|
|
|
146
145
|
|
|
147
146
|
### Pattern 3: Direct stream iteration
|
|
148
147
|
|
|
149
|
-
Pass `stream: true` alongside `outputSchema` to get an async iterable of standard streaming chunks plus a
|
|
148
|
+
Pass `stream: true` alongside `outputSchema` to get an async iterable of standard streaming chunks plus a completed typed 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.
|
|
150
149
|
|
|
151
150
|
```typescript
|
|
152
151
|
import { chat } from '@tanstack/ai'
|
|
@@ -170,8 +169,8 @@ const stream = chat({
|
|
|
170
169
|
|
|
171
170
|
for await (const chunk of stream) {
|
|
172
171
|
if (chunk.type === 'CUSTOM' && chunk.name === 'structured-output.complete') {
|
|
173
|
-
// Terminal event. `chunk.value.object` is
|
|
174
|
-
//
|
|
172
|
+
// Terminal event. `chunk.value.object` is complete and typed against the
|
|
173
|
+
// schema you passed in. Validate it in the consumer when required.
|
|
175
174
|
chunk.value.object.name // string
|
|
176
175
|
chunk.value.object.age // number
|
|
177
176
|
chunk.value.reasoning // string | undefined (thinking models only)
|
|
@@ -183,24 +182,24 @@ The terminal event is a `CUSTOM` chunk: `{ type: 'CUSTOM', name: 'structured-out
|
|
|
183
182
|
|
|
184
183
|
**Adapter coverage for streaming:**
|
|
185
184
|
|
|
186
|
-
| Adapter
|
|
187
|
-
|
|
|
188
|
-
| `@tanstack/ai-openai` (Responses + Chat Completions)
|
|
189
|
-
| `@tanstack/ai-anthropic` (Claude 4.5+ only)
|
|
190
|
-
| `@tanstack/ai-gemini` (Gemini 3.x only)
|
|
191
|
-
| `@tanstack/ai-grok`
|
|
192
|
-
| `@tanstack/ai-openrouter`
|
|
193
|
-
| `@tanstack/ai-groq`
|
|
194
|
-
| `@tanstack/ai-bedrock`
|
|
195
|
-
| `@tanstack/ai-byteplus`
|
|
196
|
-
| `@tanstack/ai-claude-code`
|
|
197
|
-
| `@tanstack/ai-codex`
|
|
198
|
-
| `@tanstack/ai-opencode`
|
|
199
|
-
| `@tanstack/ai-grok-build`
|
|
200
|
-
| `@tanstack/ai-acp` (`acpCompatible`)
|
|
201
|
-
| All other adapters (ollama, older Claude, Gemini 2.x
|
|
202
|
-
|
|
203
|
-
**Native
|
|
185
|
+
| Adapter | `outputSchema` + `stream: true` |
|
|
186
|
+
| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
187
|
+
| `@tanstack/ai-openai` (Responses + Chat Completions) | **Native combined mode (#605)** — schema wired into the regular `chatStream` call alongside `tools`; engine harvests JSON, no finalization round-trip |
|
|
188
|
+
| `@tanstack/ai-anthropic` (Claude 4.5+ only) | **Native combined mode (#605)** — `output_config.format` + `tools` in one beta Messages call. Older Claude models fall back |
|
|
189
|
+
| `@tanstack/ai-gemini` (Gemini 3.x only) | **Native combined mode (#605)** — `responseSchema` + `tools` in one `generateContentStream`. Gemini 2.x falls back |
|
|
190
|
+
| `@tanstack/ai-grok` | **Native combined mode (#605)** — OpenAI Responses `text.format` + `tools` for grok-4.6, grok-4.5, grok-4.3, and grok-build-0.1 |
|
|
191
|
+
| `@tanstack/ai-openrouter` | Native single-request stream (legacy `structuredOutputStream` path; per-call combined-mode lookup is a follow-up) |
|
|
192
|
+
| `@tanstack/ai-groq` | Legacy `structuredOutputStream` only (no tools — Groq's API rejects schema + tools + stream) |
|
|
193
|
+
| `@tanstack/ai-bedrock` | Separate native `structuredOutputStream` finalization through Converse or an OpenAI-compatible API |
|
|
194
|
+
| `@tanstack/ai-byteplus` | Native combined mode on supported models; unsupported models emit `RUN_ERROR` |
|
|
195
|
+
| `@tanstack/ai-claude-code` | Combined + event source — `--json-schema` on the same harness turn. Read `useChat().final`. See Pattern 6. |
|
|
196
|
+
| `@tanstack/ai-codex` | Combined + event source — `--output-schema` on the same harness turn. Read `useChat().final`. See Pattern 6. |
|
|
197
|
+
| `@tanstack/ai-opencode` | Combined + event source — prompt-and-parse. Read `useChat().final`. See Pattern 6. |
|
|
198
|
+
| `@tanstack/ai-grok-build` | Combined + event source — prompt-and-parse (ACP and streaming-json). Read `useChat().final` or the `structured-output` part. See Pattern 6. |
|
|
199
|
+
| `@tanstack/ai-acp` (`acpCompatible`) | Combined + event source — prompt-and-parse. Read `useChat().final` or the `structured-output` part. See Pattern 6. |
|
|
200
|
+
| All other adapters (ollama, older Claude, Gemini 2.x) | Fallback: runs non-streaming `structuredOutput`, emits one `structured-output.complete` event |
|
|
201
|
+
|
|
202
|
+
**Native-combined output vs separate finalization** is signaled by the adapter's
|
|
204
203
|
optional `supportsCombinedToolsAndSchema(modelOptions)` method. When
|
|
205
204
|
it returns `true`, the engine wires the JSON Schema into the regular
|
|
206
205
|
`chatStream` call and harvests the final-turn text — middleware sees
|
|
@@ -214,7 +213,7 @@ Consumer code is identical across providers — always read the final object off
|
|
|
214
213
|
|
|
215
214
|
### Pattern 4: useChat with outputSchema (progressive UI)
|
|
216
215
|
|
|
217
|
-
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
|
|
216
|
+
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 completed typed object on the terminal event. No `onChunk` ceremony, no manual JSON accumulation, no `parsePartialJSON` calls.
|
|
218
217
|
|
|
219
218
|
**Server** (same as Pattern 3, just behind an SSE endpoint):
|
|
220
219
|
|
|
@@ -272,7 +271,7 @@ function PersonExtractor() {
|
|
|
272
271
|
<p>Name: {partial.name ?? '…'}</p>
|
|
273
272
|
<p>Age: {partial.age ?? '…'}</p>
|
|
274
273
|
<p>Email: {partial.email ?? '…'}</p>
|
|
275
|
-
{final && <pre>
|
|
274
|
+
{final && <pre>Completed: {JSON.stringify(final, null, 2)}</pre>}
|
|
276
275
|
</div>
|
|
277
276
|
)
|
|
278
277
|
}
|
|
@@ -280,12 +279,12 @@ function PersonExtractor() {
|
|
|
280
279
|
|
|
281
280
|
- `partial` is `DeepPartial<z.infer<typeof PersonSchema>>` — every property optional, every nested array element optional. Updated from `TEXT_MESSAGE_CONTENT` deltas.
|
|
282
281
|
- `final` is `z.infer<typeof PersonSchema> | null` — populated when `structured-output.complete` arrives.
|
|
283
|
-
- `outputSchema` is for client-side type inference
|
|
282
|
+
- `outputSchema` in `useChat` is for client-side type inference. The streaming server path does not run Standard Schema validation; validate the completed object in the consumer when required.
|
|
284
283
|
- 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.
|
|
285
284
|
|
|
286
285
|
### Pattern 5: Multi-turn structured chat
|
|
287
286
|
|
|
288
|
-
|
|
287
|
+
Each successfully completed structured-output run adds a typed `StructuredOutputPart` to an assistant message in `messages`. Old responses stay renderable; new completed runs produce new parts; history is preserved without manual state plumbing. This is what makes the recipe-builder shape ("now make it vegan") work.
|
|
289
288
|
|
|
290
289
|
```tsx
|
|
291
290
|
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
|
|
@@ -345,10 +344,10 @@ function RecipeCard({ part }: { part: RecipePart }) {
|
|
|
345
344
|
|
|
346
345
|
Key behaviors:
|
|
347
346
|
|
|
348
|
-
- **Per-turn parts.** Each
|
|
347
|
+
- **Per-turn parts.** Each successfully completed structured-output run adds a structured-output assistant message with its own `StructuredOutputPart`. The separate-finalization path can also produce a plain-text assistant message before it. The previous turn's part is untouched — `messages.map(...)` renders the whole history.
|
|
349
348
|
- **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`.
|
|
350
|
-
- **`partial` / `final` are derived.** The hook-level `partial` and `final` are NOT singleton state — they're derived from the latest
|
|
351
|
-
- **Round-trip preserves history.**
|
|
349
|
+
- **`partial` / `final` are derived.** The hook-level `partial` and `final` are NOT singleton state — they're derived from the latest structured-output part after the most recent user message. Between `sendMessage()` and the first chunk, `partial` reads `{}` and `final` reads `null` because no new structured-output part exists yet.
|
|
350
|
+
- **Round-trip preserves history.** Completed structured-output parts remain on their UI messages and are mirrored into provider-facing assistant content using `part.raw`. Streaming and errored parts remain UI state but are excluded from model input.
|
|
352
351
|
|
|
353
352
|
### Pattern 6: Harness adapters (Claude Code, Codex, OpenCode, Grok Build, ACP)
|
|
354
353
|
|
|
@@ -410,6 +409,7 @@ final?.name
|
|
|
410
409
|
- `partial` stays empty until `structured-output.complete`.
|
|
411
410
|
- Client tools and `needsApproval` fail fast. The harness cannot pause for a browser round-trip.
|
|
412
411
|
- Render live work from `messages[].parts` (`thinking`, `tool-call`, `text`, `structured-output`). `final` is only the latest turn.
|
|
412
|
+
- `withPersistence` stores the structured-output part. Distinct event ids become two assistant messages. A reused text id stays on one message. Hydrate with `reconstructChat`.
|
|
413
413
|
- See [docs/structured-outputs/harnesses.md](https://github.com/TanStack/ai/blob/main/docs/structured-outputs/harnesses.md).
|
|
414
414
|
|
|
415
415
|
## Common Mistakes
|
|
@@ -448,9 +448,9 @@ Source: PR #577 — structured-output became a typed UIMessage part.
|
|
|
448
448
|
|
|
449
449
|
### HIGH: Treating `partial` / `final` as sticky state across turns
|
|
450
450
|
|
|
451
|
-
`partial` and `final` are **derived from the
|
|
451
|
+
`partial` and `final` are **derived from the most recent structured-output part after the latest user message**, not a sticky hook-level slot. In a multi-turn chat:
|
|
452
452
|
|
|
453
|
-
- Between `sendMessage()` and the first chunk, `partial` reads `{}` and `final` reads `null` (no
|
|
453
|
+
- Between `sendMessage()` and the first chunk, `partial` reads `{}` and `final` reads `null` (no structured-output part after the latest user message yet).
|
|
454
454
|
- Once the latest turn completes, `partial === final`. Earlier turns' data is NOT in `partial` / `final` — it lives on the prior assistant messages' parts.
|
|
455
455
|
|
|
456
456
|
To render history, walk `messages` directly (see Pattern 5). Use `partial` / `final` for a sticky summary of the **most recent** turn only.
|
|
@@ -459,7 +459,7 @@ To render history, walk `messages` directly (see Pattern 5). Use `partial` / `fi
|
|
|
459
459
|
// WRONG — `final` only reflects the latest turn; earlier recipes vanish from this view
|
|
460
460
|
{final && <RecipeCard recipe={final} />}
|
|
461
461
|
|
|
462
|
-
// CORRECT for history — walk messages, render
|
|
462
|
+
// CORRECT for history — walk messages, render each structured-output part
|
|
463
463
|
{messages.map((m) =>
|
|
464
464
|
m.role === 'assistant'
|
|
465
465
|
? m.parts.find((p) => p.type === 'structured-output')
|
|
@@ -469,11 +469,11 @@ To render history, walk `messages` directly (see Pattern 5). Use `partial` / `fi
|
|
|
469
469
|
)}
|
|
470
470
|
```
|
|
471
471
|
|
|
472
|
-
Source: PR #577 — partial/final derive from the
|
|
472
|
+
Source: PR #577 — partial/final derive from the most recent structured-output part after the latest user message.
|
|
473
473
|
|
|
474
474
|
### HIGH: Parsing streaming JSON deltas yourself
|
|
475
475
|
|
|
476
|
-
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.
|
|
476
|
+
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. Read the completed typed object from the terminal `structured-output.complete` event. Standard Schema validation remains the consumer's responsibility.
|
|
477
477
|
|
|
478
478
|
```typescript
|
|
479
479
|
// WRONG -- partial JSON, throws SyntaxError mid-stream, no schema validation
|
|
@@ -486,12 +486,12 @@ for await (const chunk of stream) {
|
|
|
486
486
|
// CORRECT -- trust the terminal event
|
|
487
487
|
for await (const chunk of stream) {
|
|
488
488
|
if (chunk.type === 'CUSTOM' && chunk.name === 'structured-output.complete') {
|
|
489
|
-
const result = chunk.value.object // ✅
|
|
489
|
+
const result = chunk.value.object // ✅ complete and typed
|
|
490
490
|
}
|
|
491
491
|
}
|
|
492
492
|
```
|
|
493
493
|
|
|
494
|
-
If you need progressive parsed state in a non-React environment, use a partial-JSON parser on the accumulated raw string at render time
|
|
494
|
+
If you need progressive parsed state in a non-React environment, use a partial-JSON parser on the accumulated raw string at render time. Neither that partial state nor the terminal streaming event is Standard Schema validated. In `useChat`, progressive parsing is already done for you through the `partial` field from Pattern 4.
|
|
495
495
|
|
|
496
496
|
Source: maintainer interview
|
|
497
497
|
|
|
@@ -528,7 +528,7 @@ of using the schema validation library already in the project (Zod, ArkType,
|
|
|
528
528
|
Valibot). Always check what the project uses and match it.
|
|
529
529
|
|
|
530
530
|
```typescript
|
|
531
|
-
// WRONG -- raw object, no
|
|
531
|
+
// WRONG -- raw schema object, no schema-library type inference
|
|
532
532
|
chat({
|
|
533
533
|
adapter,
|
|
534
534
|
messages,
|
|
@@ -556,24 +556,28 @@ chat({
|
|
|
556
556
|
})
|
|
557
557
|
```
|
|
558
558
|
|
|
559
|
-
Using the project's schema library gives you
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
559
|
+
Using the project's schema library gives you TypeScript type inference and
|
|
560
|
+
correct JSON Schema conversion automatically. The non-streaming
|
|
561
|
+
`await chat({ outputSchema })` path also runs Standard Schema validation; the
|
|
562
|
+
streaming path leaves validation to the consumer. Check `package.json` for
|
|
563
|
+
`zod`, `arktype`, or `valibot` and use whichever is already installed.
|
|
563
564
|
|
|
564
565
|
Source: maintainer interview
|
|
565
566
|
|
|
566
567
|
## Middleware coverage
|
|
567
568
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
`ctx.phase === 'structuredOutput'
|
|
571
|
-
|
|
572
|
-
after the structured-output result is available.
|
|
569
|
+
On the separate-finalization path, the final structured-output adapter call
|
|
570
|
+
runs through the middleware pipeline with
|
|
571
|
+
`ctx.phase === 'structuredOutput'`. Use `onStructuredOutputConfig` to transform
|
|
572
|
+
the JSON Schema or finalization config before that provider call.
|
|
573
573
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
574
|
+
Native-combined output stays in the regular agent loop. Its chunks use
|
|
575
|
+
`ctx.phase === 'modelStream'`, and `onStructuredOutputConfig` does not fire.
|
|
576
|
+
|
|
577
|
+
On both paths, `onChunk` observes the `structured-output.complete` event,
|
|
578
|
+
`onUsage` observes usage from the provider calls that ran, and `onFinish` fires
|
|
579
|
+
once after the structured-output result is available. See
|
|
580
|
+
[middleware skill](../middleware/SKILL.md).
|
|
577
581
|
|
|
578
582
|
## Cross-References
|
|
579
583
|
|
|
@@ -581,4 +585,4 @@ provider call, stripping system prompts), use the dedicated
|
|
|
581
585
|
- See also: **ai-core/adapter-configuration/SKILL.md** — Adapter handles structured-output strategy transparently.
|
|
582
586
|
- 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).
|
|
583
587
|
- See also: [docs/structured-outputs/harnesses.md](https://github.com/TanStack/ai/blob/main/docs/structured-outputs/harnesses.md) — dedicated harness adapters and `useChat().final`.
|
|
584
|
-
- See also: **ai-core/middleware/SKILL.md** — `onStructuredOutputConfig`
|
|
588
|
+
- See also: **ai-core/middleware/SKILL.md** — separate-finalization `onStructuredOutputConfig` / `structuredOutput` behavior and native-combined `modelStream` behavior.
|
|
@@ -4,7 +4,8 @@ description: >
|
|
|
4
4
|
Isomorphic tool system: toolDefinition() with Zod schemas,
|
|
5
5
|
.server() and .client() implementations, passing tools to both
|
|
6
6
|
chat() on server and useChat/clientTools on client, tool approval
|
|
7
|
-
flows with needsApproval and bound interrupts (resolveInterrupt),
|
|
7
|
+
flows with needsApproval and bound interrupts (resolveInterrupt), generic
|
|
8
|
+
middleware interrupts with defineInterrupt(), lazy tool
|
|
8
9
|
discovery with lazy:true, rendering ToolCallPart and ToolResultPart
|
|
9
10
|
in UI.
|
|
10
11
|
type: sub-skill
|
|
@@ -133,6 +134,58 @@ function ChatPage() {
|
|
|
133
134
|
|
|
134
135
|
## Core Patterns
|
|
135
136
|
|
|
137
|
+
### Generic middleware interrupts
|
|
138
|
+
|
|
139
|
+
Use `defineInterrupt()` when middleware needs typed data from the client. This
|
|
140
|
+
does not replace `needsApproval`. Tool approval asks whether a tool can run.
|
|
141
|
+
Generic interrupts ask for application data at a chat lifecycle boundary.
|
|
142
|
+
|
|
143
|
+
Define the interrupt once. Register it with both `chat({ interrupts })` and
|
|
144
|
+
`useChat({ interrupts })`. Emit it only from `onInterruptBoundary`, then read
|
|
145
|
+
the typed result in `onInterruptResolution`.
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
import { defineInterrupt, type ChatMiddleware } from '@tanstack/ai'
|
|
149
|
+
import { z } from 'zod'
|
|
150
|
+
|
|
151
|
+
const reviewPlan = defineInterrupt({
|
|
152
|
+
id: 'review-plan',
|
|
153
|
+
payloadSchema: z.object({ title: z.string() }),
|
|
154
|
+
responseSchema: z.object({ approved: z.boolean() }),
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const reviewMiddleware: ChatMiddleware<unknown, typeof reviewPlan> = {
|
|
158
|
+
onInterruptBoundary(ctx) {
|
|
159
|
+
if (ctx.phase !== 'beforeTools') return
|
|
160
|
+
return {
|
|
161
|
+
interrupts: [
|
|
162
|
+
reviewPlan.interrupt({
|
|
163
|
+
key: 'release-plan',
|
|
164
|
+
reason: 'review-required',
|
|
165
|
+
message: 'Approve this plan?',
|
|
166
|
+
payload: { title: 'Release plan' },
|
|
167
|
+
}),
|
|
168
|
+
],
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
onInterruptResolution(_ctx, resumedInterrupts) {
|
|
172
|
+
for (const result of resumedInterrupts.for(reviewPlan)) {
|
|
173
|
+
if (result.status === 'resolved' && !result.response.approved) {
|
|
174
|
+
return { toolResume: 'stop' }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Several middleware can request generic interrupts at one boundary. They share
|
|
182
|
+
one AG-UI interrupt batch with tool approvals. A continuation starts only after
|
|
183
|
+
the client resolves or cancels every bound item. `stop` is more restrictive than
|
|
184
|
+
`cancel`, which is more restrictive than `continue`.
|
|
185
|
+
|
|
186
|
+
Do not emit raw AG-UI interrupt events from middleware. Use the boundary hook
|
|
187
|
+
so the engine creates one terminal event and persistence records the batch.
|
|
188
|
+
|
|
136
189
|
### Pattern 1: Server-Only Tool
|
|
137
190
|
|
|
138
191
|
Define with `toolDefinition()`, implement with `.server()`, pass to `chat({ tools })`.
|