@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.
Files changed (63) hide show
  1. package/dist/esm/activities/chat/adapter.d.ts +14 -0
  2. package/dist/esm/activities/chat/adapter.js.map +1 -1
  3. package/dist/esm/activities/chat/index.d.ts +27 -8
  4. package/dist/esm/activities/chat/index.js +245 -14
  5. package/dist/esm/activities/chat/index.js.map +1 -1
  6. package/dist/esm/activities/chat/messages.js +26 -2
  7. package/dist/esm/activities/chat/messages.js.map +1 -1
  8. package/dist/esm/activities/chat/middleware/compose.js +1 -1
  9. package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
  10. package/dist/esm/activities/chat/middleware/types.d.ts +12 -1
  11. package/dist/esm/activities/chat/tools/schema-converter.js +5 -0
  12. package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
  13. package/dist/esm/activities/error-payload.d.ts +0 -8
  14. package/dist/esm/activities/error-payload.js +20 -2
  15. package/dist/esm/activities/error-payload.js.map +1 -1
  16. package/dist/esm/activities/generateImage/adapter.d.ts +2 -2
  17. package/dist/esm/activities/generateImage/adapter.js.map +1 -1
  18. package/dist/esm/activities/generateVideo/adapter.d.ts +2 -2
  19. package/dist/esm/activities/generateVideo/adapter.js.map +1 -1
  20. package/dist/esm/activities/index.d.ts +1 -0
  21. package/dist/esm/activities/index.js +2 -0
  22. package/dist/esm/activities/index.js.map +1 -1
  23. package/dist/esm/activities/stream-generation-result.js +0 -2
  24. package/dist/esm/activities/stream-generation-result.js.map +1 -1
  25. package/dist/esm/activities/summarize/adapter.d.ts +4 -4
  26. package/dist/esm/activities/summarize/adapter.js.map +1 -1
  27. package/dist/esm/activities/summarize/chat-stream-summarize.d.ts +45 -0
  28. package/dist/esm/activities/summarize/chat-stream-summarize.js +148 -0
  29. package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -0
  30. package/dist/esm/activities/summarize/index.d.ts +1 -0
  31. package/dist/esm/activities/summarize/index.js +4 -2
  32. package/dist/esm/activities/summarize/index.js.map +1 -1
  33. package/dist/esm/index.d.ts +3 -0
  34. package/dist/esm/index.js +6 -0
  35. package/dist/esm/index.js.map +1 -1
  36. package/dist/esm/types.d.ts +123 -11
  37. package/dist/esm/utilities/ag-ui-wire.d.ts +44 -0
  38. package/dist/esm/utilities/ag-ui-wire.js +96 -0
  39. package/dist/esm/utilities/ag-ui-wire.js.map +1 -0
  40. package/dist/esm/utilities/chat-params.d.ts +80 -0
  41. package/dist/esm/utilities/chat-params.js +96 -0
  42. package/dist/esm/utilities/chat-params.js.map +1 -0
  43. package/package.json +3 -3
  44. package/skills/ai-core/ag-ui-protocol/SKILL.md +46 -3
  45. package/skills/ai-core/structured-outputs/SKILL.md +92 -1
  46. package/src/activities/chat/adapter.ts +17 -0
  47. package/src/activities/chat/index.ts +401 -35
  48. package/src/activities/chat/messages.ts +44 -4
  49. package/src/activities/chat/middleware/compose.ts +1 -1
  50. package/src/activities/chat/middleware/types.ts +12 -1
  51. package/src/activities/chat/tools/schema-converter.ts +14 -0
  52. package/src/activities/error-payload.ts +31 -2
  53. package/src/activities/generateImage/adapter.ts +8 -2
  54. package/src/activities/generateVideo/adapter.ts +8 -2
  55. package/src/activities/index.ts +5 -0
  56. package/src/activities/stream-generation-result.ts +4 -6
  57. package/src/activities/summarize/adapter.ts +8 -4
  58. package/src/activities/summarize/chat-stream-summarize.ts +238 -0
  59. package/src/activities/summarize/index.ts +12 -9
  60. package/src/index.ts +11 -0
  61. package/src/types.ts +146 -11
  62. package/src/utilities/ag-ui-wire.ts +182 -0
  63. 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
- /** Conversation messages - content types are constrained by the adapter's input modalities and metadata */
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
- ConstrainedModelMessage<{
88
- inputModalities: TAdapter['~types']['inputModalities']
89
- messageMetadataByModality: TAdapter['~types']['messageMetadataByModality']
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: Promise<InferSchemaType<TSchema>>
230
- * - If stream is false: Promise<string>
231
- * - Otherwise (stream is true, default): AsyncIterable<StreamChunk>
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 = true,
260
+ TStream extends boolean = boolean,
236
261
  > = TSchema extends SchemaInput
237
- ? Promise<InferSchemaType<TSchema>>
238
- : TStream extends false
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
- this.threadId = config.params.threadId || this.createId('thread')
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
- conversationId: config.params.conversationId,
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
- conversationId: this.middlewareCtx.conversationId,
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
- conversationId: this.middlewareCtx.conversationId,
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 = true,
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
- // If outputSchema is provided, run agentic structured output
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 as unknown as TextActivityOptions<
1588
- AnyTextAdapter,
1589
- SchemaInput,
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 as unknown as TextActivityOptions<
1599
- AnyTextAdapter,
1600
- undefined,
1601
- false
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 as unknown as TextActivityOptions<AnyTextAdapter, undefined, true>,
1609
- ) as TextActivityResult<TSchema, TStream>
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 - convert to ModelMessages
83
+ // UIMessage anchor — existing fan-out path
70
84
  modelMessages.push(...uiMessageToModelMessages(msg))
71
- } else {
72
- // Already ModelMessage
73
- modelMessages.push(msg)
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
  }
@@ -26,7 +26,7 @@ function instrumentCtx(ctx: ChatMiddlewareContext) {
26
26
  return {
27
27
  requestId: ctx.requestId,
28
28
  streamId: ctx.streamId,
29
- clientId: ctx.conversationId,
29
+ clientId: ctx.threadId,
30
30
  timestamp: Date.now(),
31
31
  }
32
32
  }
@@ -28,7 +28,18 @@ export interface ChatMiddlewareContext {
28
28
  requestId: string
29
29
  /** Unique identifier for this stream */
30
30
  streamId: string
31
- /** Conversation identifier, if provided by the caller */
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