@tanstack/ai 0.16.0 → 0.18.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/dist/esm/activities/chat/adapter.d.ts +14 -0
- package/dist/esm/activities/chat/adapter.js.map +1 -1
- package/dist/esm/activities/chat/index.d.ts +27 -8
- package/dist/esm/activities/chat/index.js +245 -14
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/chat/messages.js +26 -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/tools/schema-converter.js +5 -0
- package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
- package/dist/esm/activities/error-payload.d.ts +0 -8
- package/dist/esm/activities/error-payload.js +20 -2
- package/dist/esm/activities/error-payload.js.map +1 -1
- package/dist/esm/activities/generateImage/adapter.d.ts +2 -2
- package/dist/esm/activities/generateImage/adapter.js.map +1 -1
- package/dist/esm/activities/generateVideo/adapter.d.ts +2 -2
- package/dist/esm/activities/generateVideo/adapter.js.map +1 -1
- package/dist/esm/activities/index.d.ts +1 -0
- package/dist/esm/activities/index.js +2 -0
- package/dist/esm/activities/index.js.map +1 -1
- package/dist/esm/activities/stream-generation-result.js +0 -2
- package/dist/esm/activities/stream-generation-result.js.map +1 -1
- package/dist/esm/activities/summarize/adapter.d.ts +4 -4
- package/dist/esm/activities/summarize/adapter.js.map +1 -1
- package/dist/esm/activities/summarize/chat-stream-summarize.d.ts +45 -0
- package/dist/esm/activities/summarize/chat-stream-summarize.js +148 -0
- package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -0
- package/dist/esm/activities/summarize/index.d.ts +1 -0
- package/dist/esm/activities/summarize/index.js +4 -2
- package/dist/esm/activities/summarize/index.js.map +1 -1
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/types.d.ts +123 -11
- package/dist/esm/utilities/ag-ui-wire.d.ts +44 -0
- package/dist/esm/utilities/ag-ui-wire.js +96 -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 +92 -1
- package/src/activities/chat/adapter.ts +17 -0
- package/src/activities/chat/index.ts +401 -35
- package/src/activities/chat/messages.ts +44 -4
- package/src/activities/chat/middleware/compose.ts +1 -1
- package/src/activities/chat/middleware/types.ts +12 -1
- package/src/activities/chat/tools/schema-converter.ts +14 -0
- package/src/activities/error-payload.ts +31 -2
- package/src/activities/generateImage/adapter.ts +8 -2
- package/src/activities/generateVideo/adapter.ts +8 -2
- package/src/activities/index.ts +5 -0
- package/src/activities/stream-generation-result.ts +4 -6
- package/src/activities/summarize/adapter.ts +8 -4
- package/src/activities/summarize/chat-stream-summarize.ts +238 -0
- package/src/activities/summarize/index.ts +12 -9
- package/src/index.ts +11 -0
- package/src/types.ts +146 -11
- package/src/utilities/ag-ui-wire.ts +182 -0
- package/src/utilities/chat-params.ts +199 -0
|
@@ -9,6 +9,7 @@ import { devtoolsMiddleware } from '@tanstack/ai-event-client'
|
|
|
9
9
|
import { stripToSpecMiddleware } from '../../strip-to-spec-middleware'
|
|
10
10
|
import { streamToText } from '../../stream-to-response.js'
|
|
11
11
|
import { resolveDebugOption } from '../../logger/resolve'
|
|
12
|
+
import { EventType } from '../../types'
|
|
12
13
|
import { LazyToolManager } from './tools/lazy-tool-manager'
|
|
13
14
|
import {
|
|
14
15
|
MiddlewareAbortError,
|
|
@@ -28,7 +29,7 @@ import type {
|
|
|
28
29
|
ClientToolRequest,
|
|
29
30
|
ToolResult,
|
|
30
31
|
} from './tools/tool-calls'
|
|
31
|
-
import type { AnyTextAdapter } from './adapter'
|
|
32
|
+
import type { AnyTextAdapter, StructuredOutputOptions } from './adapter'
|
|
32
33
|
import type {
|
|
33
34
|
AgentLoopStrategy,
|
|
34
35
|
ConstrainedModelMessage,
|
|
@@ -38,6 +39,8 @@ import type {
|
|
|
38
39
|
RunFinishedEvent,
|
|
39
40
|
SchemaInput,
|
|
40
41
|
StreamChunk,
|
|
42
|
+
StructuredOutputCompleteEvent,
|
|
43
|
+
StructuredOutputStream,
|
|
41
44
|
TextMessageContentEvent,
|
|
42
45
|
TextOptions,
|
|
43
46
|
Tool,
|
|
@@ -45,6 +48,7 @@ import type {
|
|
|
45
48
|
ToolCallArgsEvent,
|
|
46
49
|
ToolCallEndEvent,
|
|
47
50
|
ToolCallStartEvent,
|
|
51
|
+
UIMessage,
|
|
48
52
|
} from '../../types'
|
|
49
53
|
import type {
|
|
50
54
|
ChatMiddleware,
|
|
@@ -82,12 +86,21 @@ export interface TextActivityOptions<
|
|
|
82
86
|
> {
|
|
83
87
|
/** The text adapter to use (created by a provider function like openaiText('gpt-4o')) */
|
|
84
88
|
adapter: TAdapter
|
|
85
|
-
/**
|
|
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
|
+
*/
|
|
86
97
|
messages?: Array<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
98
|
+
| UIMessage
|
|
99
|
+
| ModelMessage
|
|
100
|
+
| ConstrainedModelMessage<{
|
|
101
|
+
inputModalities: TAdapter['~types']['inputModalities']
|
|
102
|
+
messageMetadataByModality: TAdapter['~types']['messageMetadataByModality']
|
|
103
|
+
}>
|
|
91
104
|
>
|
|
92
105
|
/** System prompts to prepend to the conversation */
|
|
93
106
|
systemPrompts?: TextOptions['systemPrompts']
|
|
@@ -125,6 +138,8 @@ export interface TextActivityOptions<
|
|
|
125
138
|
threadId?: TextOptions['threadId']
|
|
126
139
|
/** Run ID override for AG-UI protocol. Auto-generated by adapter if not provided. */
|
|
127
140
|
runId?: TextOptions['runId']
|
|
141
|
+
/** Parent run ID for AG-UI protocol nested run correlation. */
|
|
142
|
+
parentRunId?: TextOptions['parentRunId']
|
|
128
143
|
/**
|
|
129
144
|
* Optional Standard Schema for structured output.
|
|
130
145
|
* When provided, the activity will:
|
|
@@ -226,16 +241,28 @@ export function createChatOptions<
|
|
|
226
241
|
|
|
227
242
|
/**
|
|
228
243
|
* Result type for the text activity.
|
|
229
|
-
* - If outputSchema is provided:
|
|
230
|
-
*
|
|
231
|
-
*
|
|
244
|
+
* - If outputSchema is provided AND stream is explicitly true:
|
|
245
|
+
* StructuredOutputStream<InferSchemaType<TSchema>> — yields raw JSON deltas
|
|
246
|
+
* via TEXT_MESSAGE_CONTENT plus a terminal StructuredOutputCompleteEvent
|
|
247
|
+
* carrying the validated object.
|
|
248
|
+
* - If outputSchema is provided without explicit stream:true:
|
|
249
|
+
* Promise<InferSchemaType<TSchema>>.
|
|
250
|
+
* - If stream is explicitly false (no schema): Promise<string>.
|
|
251
|
+
* - Otherwise (default): AsyncIterable<StreamChunk>.
|
|
252
|
+
*
|
|
253
|
+
* `[TStream] extends [true]` is used (not `TStream extends true`) so that the
|
|
254
|
+
* default `boolean` value of `TStream` does *not* match the streaming branch.
|
|
255
|
+
* Without this, plain `chat({ outputSchema })` would type as a stream while
|
|
256
|
+
* the runtime returns a Promise — see issue #526.
|
|
232
257
|
*/
|
|
233
258
|
export type TextActivityResult<
|
|
234
259
|
TSchema extends SchemaInput | undefined,
|
|
235
|
-
TStream extends boolean =
|
|
260
|
+
TStream extends boolean = boolean,
|
|
236
261
|
> = TSchema extends SchemaInput
|
|
237
|
-
?
|
|
238
|
-
|
|
262
|
+
? [TStream] extends [true]
|
|
263
|
+
? StructuredOutputStream<InferSchemaType<TSchema>>
|
|
264
|
+
: Promise<InferSchemaType<TSchema>>
|
|
265
|
+
: [TStream] extends [false]
|
|
239
266
|
? Promise<string>
|
|
240
267
|
: AsyncIterable<StreamChunk>
|
|
241
268
|
|
|
@@ -298,6 +325,7 @@ class TextEngine<
|
|
|
298
325
|
// AG-UI protocol IDs
|
|
299
326
|
private threadId: string
|
|
300
327
|
private runIdOverride?: string
|
|
328
|
+
private parentRunIdOverride?: string
|
|
301
329
|
|
|
302
330
|
// Middleware support
|
|
303
331
|
private readonly middlewareRunner: MiddlewareRunner
|
|
@@ -349,8 +377,15 @@ class TextEngine<
|
|
|
349
377
|
? { signal: config.params.abortController.signal }
|
|
350
378
|
: undefined
|
|
351
379
|
this.effectiveSignal = config.params.abortController?.signal
|
|
352
|
-
|
|
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')
|
|
353
387
|
this.runIdOverride = config.params.runId
|
|
388
|
+
this.parentRunIdOverride = config.params.parentRunId
|
|
354
389
|
|
|
355
390
|
// Initialize middleware — devtools first, strip-to-spec always last.
|
|
356
391
|
// handleStreamChunk processes raw chunks BEFORE middleware, so internal
|
|
@@ -366,7 +401,10 @@ class TextEngine<
|
|
|
366
401
|
this.middlewareCtx = {
|
|
367
402
|
requestId: this.requestId,
|
|
368
403
|
streamId: this.streamId,
|
|
369
|
-
|
|
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,
|
|
370
408
|
phase: 'init' as ChatMiddlewarePhase,
|
|
371
409
|
iteration: 0,
|
|
372
410
|
chunkIndex: 0,
|
|
@@ -414,7 +452,7 @@ class TextEngine<
|
|
|
414
452
|
async *run(): AsyncGenerator<StreamChunk> {
|
|
415
453
|
this.beforeRun()
|
|
416
454
|
this.logger.agentLoop('run started', {
|
|
417
|
-
|
|
455
|
+
threadId: this.middlewareCtx.threadId,
|
|
418
456
|
})
|
|
419
457
|
|
|
420
458
|
try {
|
|
@@ -493,7 +531,7 @@ class TextEngine<
|
|
|
493
531
|
// Genuine error — call onError
|
|
494
532
|
this.logger.errors('chat run failed', {
|
|
495
533
|
error,
|
|
496
|
-
|
|
534
|
+
threadId: this.middlewareCtx.threadId,
|
|
497
535
|
})
|
|
498
536
|
await this.middlewareRunner.runOnError(this.middlewareCtx, {
|
|
499
537
|
error,
|
|
@@ -619,6 +657,7 @@ class TextEngine<
|
|
|
619
657
|
logger: this.logger,
|
|
620
658
|
threadId: this.threadId,
|
|
621
659
|
runId: this.runIdOverride,
|
|
660
|
+
parentRunId: this.parentRunIdOverride,
|
|
622
661
|
})) {
|
|
623
662
|
if (this.isCancelled()) {
|
|
624
663
|
break
|
|
@@ -1575,38 +1614,46 @@ class TextEngine<
|
|
|
1575
1614
|
export function chat<
|
|
1576
1615
|
TAdapter extends AnyTextAdapter,
|
|
1577
1616
|
TSchema extends SchemaInput | undefined = undefined,
|
|
1578
|
-
TStream extends boolean =
|
|
1617
|
+
TStream extends boolean = boolean,
|
|
1579
1618
|
>(
|
|
1580
1619
|
options: TextActivityOptions<TAdapter, TSchema, TStream>,
|
|
1581
1620
|
): TextActivityResult<TSchema, TStream> {
|
|
1582
1621
|
const { outputSchema, stream } = options
|
|
1583
1622
|
|
|
1584
|
-
//
|
|
1623
|
+
// outputSchema + stream:true is the only branch that streams structured
|
|
1624
|
+
// output. Without an explicit `stream: true`, schema-bearing calls run the
|
|
1625
|
+
// agent loop and resolve to a typed Promise<InferSchemaType<TSchema>>.
|
|
1626
|
+
if (outputSchema && stream === true) {
|
|
1627
|
+
return runStreamingStructuredOutput({
|
|
1628
|
+
...options,
|
|
1629
|
+
outputSchema,
|
|
1630
|
+
stream,
|
|
1631
|
+
}) as TextActivityResult<TSchema, TStream>
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// If outputSchema is provided, run agentic structured output (Promise<T>)
|
|
1585
1635
|
if (outputSchema) {
|
|
1586
|
-
return runAgenticStructuredOutput(
|
|
1587
|
-
options
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
boolean
|
|
1591
|
-
>,
|
|
1592
|
-
) as TextActivityResult<TSchema, TStream>
|
|
1636
|
+
return runAgenticStructuredOutput({
|
|
1637
|
+
...options,
|
|
1638
|
+
outputSchema,
|
|
1639
|
+
}) as TextActivityResult<TSchema, TStream>
|
|
1593
1640
|
}
|
|
1594
1641
|
|
|
1595
1642
|
// If stream is explicitly false, run non-streaming text
|
|
1596
1643
|
if (stream === false) {
|
|
1597
|
-
return runNonStreamingText(
|
|
1598
|
-
options
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
>,
|
|
1603
|
-
) as TextActivityResult<TSchema, TStream>
|
|
1644
|
+
return runNonStreamingText({
|
|
1645
|
+
...options,
|
|
1646
|
+
outputSchema: undefined,
|
|
1647
|
+
stream,
|
|
1648
|
+
}) as TextActivityResult<TSchema, TStream>
|
|
1604
1649
|
}
|
|
1605
1650
|
|
|
1606
1651
|
// Otherwise, run streaming text (default)
|
|
1607
|
-
return runStreamingText(
|
|
1608
|
-
options
|
|
1609
|
-
|
|
1652
|
+
return runStreamingText({
|
|
1653
|
+
...options,
|
|
1654
|
+
outputSchema: undefined,
|
|
1655
|
+
stream,
|
|
1656
|
+
}) as TextActivityResult<TSchema, TStream>
|
|
1610
1657
|
}
|
|
1611
1658
|
|
|
1612
1659
|
/**
|
|
@@ -1741,6 +1788,325 @@ async function runAgenticStructuredOutput<TSchema extends SchemaInput>(
|
|
|
1741
1788
|
return result.data as InferSchemaType<TSchema>
|
|
1742
1789
|
}
|
|
1743
1790
|
|
|
1791
|
+
/**
|
|
1792
|
+
* Synthesize a streaming structured-output stream by wrapping a non-streaming
|
|
1793
|
+
* `structuredOutput` call. Used when an adapter doesn't implement
|
|
1794
|
+
* `structuredOutputStream` natively.
|
|
1795
|
+
*/
|
|
1796
|
+
async function* fallbackStructuredOutputStream(
|
|
1797
|
+
adapter: AnyTextAdapter,
|
|
1798
|
+
options: StructuredOutputOptions<Record<string, unknown>>,
|
|
1799
|
+
): AsyncIterable<StreamChunk> {
|
|
1800
|
+
const { chatOptions } = options
|
|
1801
|
+
const runId = chatOptions.runId ?? `mock-${Date.now()}`
|
|
1802
|
+
const threadId = chatOptions.threadId ?? `mock-${Date.now()}`
|
|
1803
|
+
const messageId = `mock-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
1804
|
+
const model = chatOptions.model
|
|
1805
|
+
const timestamp = Date.now()
|
|
1806
|
+
|
|
1807
|
+
yield {
|
|
1808
|
+
type: EventType.RUN_STARTED,
|
|
1809
|
+
runId,
|
|
1810
|
+
threadId,
|
|
1811
|
+
model,
|
|
1812
|
+
timestamp,
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
let result: { data: unknown; rawText: string }
|
|
1816
|
+
try {
|
|
1817
|
+
result = await adapter.structuredOutput(options)
|
|
1818
|
+
} catch (error) {
|
|
1819
|
+
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
1820
|
+
yield {
|
|
1821
|
+
type: EventType.RUN_ERROR,
|
|
1822
|
+
runId,
|
|
1823
|
+
model,
|
|
1824
|
+
timestamp,
|
|
1825
|
+
message,
|
|
1826
|
+
error: { message },
|
|
1827
|
+
}
|
|
1828
|
+
return
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
yield {
|
|
1832
|
+
type: EventType.TEXT_MESSAGE_START,
|
|
1833
|
+
messageId,
|
|
1834
|
+
role: 'assistant',
|
|
1835
|
+
model,
|
|
1836
|
+
timestamp,
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
yield {
|
|
1840
|
+
type: EventType.TEXT_MESSAGE_CONTENT,
|
|
1841
|
+
messageId,
|
|
1842
|
+
delta: result.rawText,
|
|
1843
|
+
model,
|
|
1844
|
+
timestamp,
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
yield {
|
|
1848
|
+
type: EventType.TEXT_MESSAGE_END,
|
|
1849
|
+
messageId,
|
|
1850
|
+
model,
|
|
1851
|
+
timestamp,
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
yield {
|
|
1855
|
+
type: EventType.CUSTOM,
|
|
1856
|
+
name: 'structured-output.complete',
|
|
1857
|
+
value: { object: result.data, raw: result.rawText },
|
|
1858
|
+
model,
|
|
1859
|
+
timestamp,
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
yield {
|
|
1863
|
+
type: EventType.RUN_FINISHED,
|
|
1864
|
+
runId,
|
|
1865
|
+
threadId,
|
|
1866
|
+
model,
|
|
1867
|
+
timestamp,
|
|
1868
|
+
finishReason: 'stop',
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
/**
|
|
1873
|
+
* Run streaming structured output:
|
|
1874
|
+
* - Without tools: call adapter.structuredOutputStream directly (single
|
|
1875
|
+
* provider request emitting JSON deltas + a final CUSTOM event).
|
|
1876
|
+
* - With tools: run the agent loop, yield its non-terminal chunks, then call
|
|
1877
|
+
* structuredOutputStream on the final messages so the structured stream's
|
|
1878
|
+
* own RUN_STARTED/RUN_FINISHED bracket the run.
|
|
1879
|
+
*
|
|
1880
|
+
* Validates the parsed object against the original Standard Schema (if
|
|
1881
|
+
* applicable) when forwarding the final `structured-output.complete` event.
|
|
1882
|
+
*
|
|
1883
|
+
* Pre-flight validation (missing schema, unconvertible schema) throws
|
|
1884
|
+
* synchronously at call time rather than as a yielded RUN_ERROR mid-stream —
|
|
1885
|
+
* those are programmer errors, not runtime conditions.
|
|
1886
|
+
*/
|
|
1887
|
+
function runStreamingStructuredOutput<TSchema extends SchemaInput>(
|
|
1888
|
+
options: TextActivityOptions<AnyTextAdapter, TSchema, true>,
|
|
1889
|
+
): StructuredOutputStream<InferSchemaType<TSchema>> {
|
|
1890
|
+
const { outputSchema } = options
|
|
1891
|
+
|
|
1892
|
+
if (!outputSchema) {
|
|
1893
|
+
throw new Error('outputSchema is required for streaming structured output')
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
// forStructuredOutput strict-converts the schema once at the activity
|
|
1897
|
+
// boundary. Adapters can re-convert if their wire format diverges, but the
|
|
1898
|
+
// default flow hands them a strict-ready schema.
|
|
1899
|
+
const jsonSchema = convertSchemaToJsonSchema(outputSchema, {
|
|
1900
|
+
forStructuredOutput: true,
|
|
1901
|
+
})
|
|
1902
|
+
if (!jsonSchema) {
|
|
1903
|
+
throw new Error('Failed to convert output schema to JSON Schema')
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
// The implementation generator yields the broader internal type
|
|
1907
|
+
// (`StreamChunk | StructuredOutputCompleteEvent<T>`) so agent-loop
|
|
1908
|
+
// CustomEvents can flow through; the public-facing type narrows to
|
|
1909
|
+
// `Exclude<StreamChunk, CustomEvent> | StructuredOutputCompleteEvent<T>`
|
|
1910
|
+
// which lets consumers narrow `chunk.value` cleanly. The widen→narrow
|
|
1911
|
+
// is contained here so consumers see only the strict type.
|
|
1912
|
+
return runStreamingStructuredOutputImpl(
|
|
1913
|
+
options,
|
|
1914
|
+
jsonSchema,
|
|
1915
|
+
) as StructuredOutputStream<InferSchemaType<TSchema>>
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
/**
|
|
1919
|
+
* Internal generator return type — broader than the public
|
|
1920
|
+
* `StructuredOutputStream<T>`. The public type pins three tagged `CUSTOM`
|
|
1921
|
+
* events (`structured-output.complete`, `approval-requested`,
|
|
1922
|
+
* `tool-input-available`) so consumers can narrow `chunk.value` cleanly by
|
|
1923
|
+
* literal `name`. At runtime, tools can also emit arbitrary user-defined
|
|
1924
|
+
* `CustomEvent`s through the `emitCustomEvent` context API; those flow
|
|
1925
|
+
* through this generator with `name: string` and are widened out at the
|
|
1926
|
+
* public boundary because keeping them would collapse the typed narrow back
|
|
1927
|
+
* to `any`. The cast inside `runStreamingStructuredOutput` is where that
|
|
1928
|
+
* widening happens.
|
|
1929
|
+
*/
|
|
1930
|
+
type StructuredOutputStreamInternal<T> = AsyncIterable<
|
|
1931
|
+
StreamChunk | StructuredOutputCompleteEvent<T>
|
|
1932
|
+
>
|
|
1933
|
+
|
|
1934
|
+
async function* runStreamingStructuredOutputImpl<TSchema extends SchemaInput>(
|
|
1935
|
+
options: TextActivityOptions<AnyTextAdapter, TSchema, true>,
|
|
1936
|
+
jsonSchema: NonNullable<ReturnType<typeof convertSchemaToJsonSchema>>,
|
|
1937
|
+
): StructuredOutputStreamInternal<InferSchemaType<TSchema>> {
|
|
1938
|
+
const { adapter, outputSchema, middleware, context, debug, ...textOptions } =
|
|
1939
|
+
options
|
|
1940
|
+
const model = adapter.model
|
|
1941
|
+
const logger = resolveDebugOption(debug)
|
|
1942
|
+
const runId = textOptions.runId
|
|
1943
|
+
|
|
1944
|
+
// Inputs may be UIMessages (from useChat) or ModelMessages (from server-side
|
|
1945
|
+
// callers). The agent-loop branch converts via TextEngine; the no-tools
|
|
1946
|
+
// branch must convert here so the adapter sees a uniform ModelMessage shape.
|
|
1947
|
+
let finalMessages = convertMessagesToModelMessages(textOptions.messages ?? [])
|
|
1948
|
+
|
|
1949
|
+
if (textOptions.tools?.length) {
|
|
1950
|
+
const engine = new TextEngine(
|
|
1951
|
+
{
|
|
1952
|
+
adapter,
|
|
1953
|
+
params: { ...textOptions, model, logger, messages: finalMessages },
|
|
1954
|
+
middleware,
|
|
1955
|
+
context,
|
|
1956
|
+
},
|
|
1957
|
+
logger,
|
|
1958
|
+
)
|
|
1959
|
+
|
|
1960
|
+
// The structured-output stream emits its own RUN_STARTED + RUN_FINISHED
|
|
1961
|
+
// pair to bracket the run — drop both from the engine's output so
|
|
1962
|
+
// consumers see exactly one terminal lifecycle pair.
|
|
1963
|
+
let agentLoopErrored = false
|
|
1964
|
+
try {
|
|
1965
|
+
for await (const chunk of engine.run()) {
|
|
1966
|
+
if (chunk.type === 'RUN_STARTED' || chunk.type === 'RUN_FINISHED') {
|
|
1967
|
+
continue
|
|
1968
|
+
}
|
|
1969
|
+
if (chunk.type === 'RUN_ERROR') {
|
|
1970
|
+
// The engine yielded RUN_ERROR without throwing (provider error mid
|
|
1971
|
+
// agent loop). Forward it once and short-circuit before invoking
|
|
1972
|
+
// structuredOutputStream — otherwise consumers would see a confusing
|
|
1973
|
+
// RUN_ERROR → RUN_STARTED → structured-output.complete sequence and
|
|
1974
|
+
// we would bill another provider call after a failed run.
|
|
1975
|
+
agentLoopErrored = true
|
|
1976
|
+
yield chunk
|
|
1977
|
+
continue
|
|
1978
|
+
}
|
|
1979
|
+
yield chunk
|
|
1980
|
+
}
|
|
1981
|
+
} catch (engineError) {
|
|
1982
|
+
const message = (engineError as Error).message || 'Agent loop failed'
|
|
1983
|
+
logger.errors('runStreamingStructuredOutput agent loop failed', {
|
|
1984
|
+
error: engineError,
|
|
1985
|
+
source: 'runStreamingStructuredOutput',
|
|
1986
|
+
})
|
|
1987
|
+
yield {
|
|
1988
|
+
type: EventType.RUN_ERROR,
|
|
1989
|
+
runId,
|
|
1990
|
+
model,
|
|
1991
|
+
timestamp: Date.now(),
|
|
1992
|
+
message,
|
|
1993
|
+
code: 'agent-loop-failed',
|
|
1994
|
+
error: { message, code: 'agent-loop-failed' },
|
|
1995
|
+
}
|
|
1996
|
+
return
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
if (agentLoopErrored) {
|
|
2000
|
+
return
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
finalMessages = engine.getMessages()
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
const {
|
|
2007
|
+
tools: _tools,
|
|
2008
|
+
agentLoopStrategy: _als,
|
|
2009
|
+
...structuredTextOptions
|
|
2010
|
+
} = textOptions
|
|
2011
|
+
|
|
2012
|
+
logger.request(
|
|
2013
|
+
`activity=chat-structured-stream provider=${adapter.name} model=${model} messages=${finalMessages.length}`,
|
|
2014
|
+
{
|
|
2015
|
+
provider: adapter.name,
|
|
2016
|
+
model,
|
|
2017
|
+
messageCount: finalMessages.length,
|
|
2018
|
+
},
|
|
2019
|
+
)
|
|
2020
|
+
|
|
2021
|
+
// Adapters consume the abort signal via `chatOptions.request?.signal` and
|
|
2022
|
+
// pass it to the underlying network call. Without this, aborting the SSE
|
|
2023
|
+
// response never cancels the upstream provider request and a terminal
|
|
2024
|
+
// structured-output.complete event still gets yielded after stop.
|
|
2025
|
+
const structuredChatOptions = {
|
|
2026
|
+
...structuredTextOptions,
|
|
2027
|
+
model,
|
|
2028
|
+
messages: finalMessages,
|
|
2029
|
+
logger,
|
|
2030
|
+
request: textOptions.abortController
|
|
2031
|
+
? { signal: textOptions.abortController.signal }
|
|
2032
|
+
: undefined,
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
// Adapters that don't implement structuredOutputStream natively fall back
|
|
2036
|
+
// to wrapping the non-streaming `structuredOutput` — `fallbackStructuredOutputStream`
|
|
2037
|
+
// synthesizes the AG-UI lifecycle events around it.
|
|
2038
|
+
const stream = adapter.structuredOutputStream
|
|
2039
|
+
? adapter.structuredOutputStream({
|
|
2040
|
+
chatOptions: structuredChatOptions,
|
|
2041
|
+
outputSchema: jsonSchema,
|
|
2042
|
+
})
|
|
2043
|
+
: fallbackStructuredOutputStream(adapter, {
|
|
2044
|
+
chatOptions: structuredChatOptions,
|
|
2045
|
+
outputSchema: jsonSchema,
|
|
2046
|
+
})
|
|
2047
|
+
|
|
2048
|
+
for await (const chunk of stream) {
|
|
2049
|
+
if (
|
|
2050
|
+
chunk.type === EventType.CUSTOM &&
|
|
2051
|
+
chunk.name === 'structured-output.complete'
|
|
2052
|
+
) {
|
|
2053
|
+
const value = chunk.value as {
|
|
2054
|
+
object: unknown
|
|
2055
|
+
raw: string
|
|
2056
|
+
reasoning?: string
|
|
2057
|
+
}
|
|
2058
|
+
if (isStandardSchema(outputSchema)) {
|
|
2059
|
+
try {
|
|
2060
|
+
const validated = parseWithStandardSchema<InferSchemaType<TSchema>>(
|
|
2061
|
+
outputSchema,
|
|
2062
|
+
value.object,
|
|
2063
|
+
)
|
|
2064
|
+
yield {
|
|
2065
|
+
...chunk,
|
|
2066
|
+
// Forward `reasoning` through schema validation so consumers that
|
|
2067
|
+
// only listen for the terminal event don't lose chain-of-thought.
|
|
2068
|
+
value: {
|
|
2069
|
+
object: validated,
|
|
2070
|
+
raw: value.raw,
|
|
2071
|
+
...(value.reasoning ? { reasoning: value.reasoning } : {}),
|
|
2072
|
+
},
|
|
2073
|
+
}
|
|
2074
|
+
continue
|
|
2075
|
+
} catch (err) {
|
|
2076
|
+
const message = (err as Error).message || 'Schema validation failed'
|
|
2077
|
+
logger.errors(
|
|
2078
|
+
'runStreamingStructuredOutput schema validation failed',
|
|
2079
|
+
{
|
|
2080
|
+
error: err,
|
|
2081
|
+
source: 'runStreamingStructuredOutput',
|
|
2082
|
+
// Include reasoning in error meta so post-mortems can recover
|
|
2083
|
+
// what the model thought through before producing invalid JSON.
|
|
2084
|
+
...(value.reasoning ? { reasoning: value.reasoning } : {}),
|
|
2085
|
+
},
|
|
2086
|
+
)
|
|
2087
|
+
yield {
|
|
2088
|
+
type: EventType.RUN_ERROR,
|
|
2089
|
+
runId,
|
|
2090
|
+
model: chunk.model ?? model,
|
|
2091
|
+
timestamp: chunk.timestamp ?? Date.now(),
|
|
2092
|
+
message,
|
|
2093
|
+
code: 'schema-validation',
|
|
2094
|
+
error: {
|
|
2095
|
+
message,
|
|
2096
|
+
code: 'schema-validation',
|
|
2097
|
+
...(value.reasoning ? { reasoning: value.reasoning } : {}),
|
|
2098
|
+
},
|
|
2099
|
+
}
|
|
2100
|
+
return
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
yield chunk
|
|
2104
|
+
continue
|
|
2105
|
+
}
|
|
2106
|
+
yield chunk
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
|
|
1744
2110
|
// Re-export adapter types
|
|
1745
2111
|
export type {
|
|
1746
2112
|
TextAdapter,
|
|
@@ -63,15 +63,55 @@ function getTextContent(content: string | null | Array<ContentPart>): string {
|
|
|
63
63
|
export function convertMessagesToModelMessages(
|
|
64
64
|
messages: Array<UIMessage | ModelMessage>,
|
|
65
65
|
): Array<ModelMessage> {
|
|
66
|
+
// Pre-pass: collect toolCallIds already represented in anchor UIMessage parts.
|
|
67
|
+
// Fan-out tool messages whose toolCallId matches an anchored ToolResultPart
|
|
68
|
+
// are AG-UI duplicates and must be dropped to avoid double-feeding the LLM.
|
|
69
|
+
const anchoredToolCallIds = new Set<string>()
|
|
70
|
+
for (const msg of messages) {
|
|
71
|
+
if ('parts' in msg) {
|
|
72
|
+
for (const part of msg.parts) {
|
|
73
|
+
if (part.type === 'tool-result') {
|
|
74
|
+
anchoredToolCallIds.add(part.toolCallId)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
66
80
|
const modelMessages: Array<ModelMessage> = []
|
|
67
81
|
for (const msg of messages) {
|
|
68
82
|
if ('parts' in msg) {
|
|
69
|
-
// UIMessage
|
|
83
|
+
// UIMessage anchor — existing fan-out path
|
|
70
84
|
modelMessages.push(...uiMessageToModelMessages(msg))
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const role = (msg as { role: string }).role
|
|
89
|
+
|
|
90
|
+
// AG-UI tool fan-out duplicate — drop if anchor already covers it
|
|
91
|
+
if (
|
|
92
|
+
role === 'tool' &&
|
|
93
|
+
msg.toolCallId &&
|
|
94
|
+
anchoredToolCallIds.has(msg.toolCallId)
|
|
95
|
+
) {
|
|
96
|
+
continue
|
|
74
97
|
}
|
|
98
|
+
|
|
99
|
+
// AG-UI reasoning and activity — no ModelMessage equivalent today
|
|
100
|
+
if (role === 'reasoning' || role === 'activity') {
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// AG-UI developer — collapse to system
|
|
105
|
+
if (role === 'developer') {
|
|
106
|
+
modelMessages.push({
|
|
107
|
+
role: 'system' as ModelMessage['role'],
|
|
108
|
+
content: (msg as { content: string }).content,
|
|
109
|
+
} as ModelMessage)
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Already a ModelMessage (user, assistant, system, tool with no anchor) — pass through
|
|
114
|
+
modelMessages.push(msg)
|
|
75
115
|
}
|
|
76
116
|
return modelMessages
|
|
77
117
|
}
|
|
@@ -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
|
|
@@ -254,6 +254,20 @@ export function convertSchemaToJsonSchema(
|
|
|
254
254
|
return result as JSONSchema
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
// Detect Standard Schema validators (Zod, ArkType, Valibot, …) that don't
|
|
258
|
+
// expose a `~standard.jsonSchema` converter. These would otherwise fall
|
|
259
|
+
// through to the JSONSchema pass-through below and ship `{ '~standard': … }`
|
|
260
|
+
// straight to the LLM provider, producing an opaque downstream error. Fail
|
|
261
|
+
// fast with actionable guidance instead.
|
|
262
|
+
if (isStandardSchema(schema)) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
'Schema is a Standard Schema validator but does not expose a JSON Schema ' +
|
|
265
|
+
'converter on `~standard.jsonSchema`. Use Zod v4.2+, ArkType v2.1.28+, ' +
|
|
266
|
+
'or wrap a Valibot schema with `toStandardJsonSchema()` from ' +
|
|
267
|
+
'`@valibot/to-json-schema` before passing it as `outputSchema`.',
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
257
271
|
// If it's not a Standard JSON Schema, assume it's already a JSONSchema and pass through
|
|
258
272
|
// Still apply structured output transformation if requested
|
|
259
273
|
|