@tanstack/ai 0.20.1 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/esm/activities/chat/index.d.ts +1 -1
  2. package/dist/esm/activities/chat/index.js +357 -258
  3. package/dist/esm/activities/chat/index.js.map +1 -1
  4. package/dist/esm/activities/chat/messages.js.map +1 -1
  5. package/dist/esm/activities/chat/middleware/compose.d.ts +10 -1
  6. package/dist/esm/activities/chat/middleware/compose.js +55 -0
  7. package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
  8. package/dist/esm/activities/chat/middleware/index.d.ts +1 -1
  9. package/dist/esm/activities/chat/middleware/types.d.ts +35 -3
  10. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  11. package/dist/esm/activities/chat/tools/schema-converter.d.ts +12 -1
  12. package/dist/esm/activities/chat/tools/schema-converter.js +13 -4
  13. package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
  14. package/dist/esm/activities/generateImage/index.js.map +1 -1
  15. package/dist/esm/extend-adapter.js.map +1 -1
  16. package/dist/esm/index.d.ts +2 -2
  17. package/dist/esm/index.js +2 -1
  18. package/dist/esm/middlewares/content-guard.js.map +1 -1
  19. package/dist/esm/middlewares/otel.js +1 -4
  20. package/dist/esm/middlewares/otel.js.map +1 -1
  21. package/dist/esm/strip-to-spec-middleware.js.map +1 -1
  22. package/dist/esm/utilities/ag-ui-wire.js +4 -1
  23. package/dist/esm/utilities/ag-ui-wire.js.map +1 -1
  24. package/package.json +3 -3
  25. package/skills/ai-core/middleware/SKILL.md +124 -18
  26. package/skills/ai-core/structured-outputs/SKILL.md +13 -0
  27. package/src/activities/chat/index.ts +653 -370
  28. package/src/activities/chat/messages.ts +1 -1
  29. package/src/activities/chat/middleware/compose.ts +65 -5
  30. package/src/activities/chat/middleware/index.ts +1 -0
  31. package/src/activities/chat/middleware/types.ts +53 -2
  32. package/src/activities/chat/stream/processor.ts +2 -2
  33. package/src/activities/chat/tools/schema-converter.ts +22 -7
  34. package/src/activities/generateImage/index.ts +3 -3
  35. package/src/extend-adapter.ts +1 -1
  36. package/src/index.ts +5 -1
  37. package/src/middlewares/content-guard.ts +1 -1
  38. package/src/middlewares/otel.ts +7 -10
  39. package/src/strip-to-spec-middleware.ts +1 -4
  40. package/src/utilities/ag-ui-wire.ts +2 -1
@@ -35,6 +35,7 @@ import type {
35
35
  ConstrainedModelMessage,
36
36
  CustomEvent,
37
37
  InferSchemaType,
38
+ JSONSchema,
38
39
  ModelMessage,
39
40
  RunFinishedEvent,
40
41
  SchemaInput,
@@ -54,7 +55,7 @@ import type {
54
55
  ChatMiddleware,
55
56
  ChatMiddlewareConfig,
56
57
  ChatMiddlewareContext,
57
- ChatMiddlewarePhase,
58
+ StructuredOutputMiddlewareConfig,
58
59
  } from './middleware/types'
59
60
  import type { SystemPrompt } from '../../system-prompts'
60
61
  import type { InternalLogger } from '../../logger/internal-logger'
@@ -294,6 +295,29 @@ interface TextEngineConfig<
294
295
  params: TParams
295
296
  middleware?: Array<ChatMiddleware>
296
297
  context?: unknown
298
+ /**
299
+ * If set, after the agent loop finishes the engine runs a
300
+ * structured-output finalization step through the same middleware
301
+ * pipeline. See `runStructuredFinalization` for the flow.
302
+ *
303
+ * - jsonSchema: the JSON Schema to send to the provider
304
+ * - yieldChunks: when true, finalization chunks are yielded to the caller
305
+ * (used by runStreamingStructuredOutput). When false, chunks are
306
+ * consumed internally for middleware visibility but not yielded
307
+ * (used by runAgenticStructuredOutput).
308
+ * - validate: optional callback invoked AFTER the structured-output result
309
+ * is captured but BEFORE the terminal hook fires. If it throws, the
310
+ * engine records a `finalizationError` and fires `onError` instead of
311
+ * `onFinish` (per spec §7.3). On success, the returned value is stored
312
+ * as the validated result and retrievable via
313
+ * `getValidatedStructuredOutput()`. Used by `runAgenticStructuredOutput`
314
+ * to perform Standard Schema validation inside the engine.
315
+ */
316
+ finalStructuredOutput?: {
317
+ jsonSchema: JSONSchema
318
+ yieldChunks: boolean
319
+ validate?: (data: unknown) => unknown
320
+ }
297
321
  }
298
322
 
299
323
  type ToolPhaseResult = 'continue' | 'stop' | 'wait'
@@ -352,12 +376,32 @@ class TextEngine<
352
376
 
353
377
  private readonly logger: InternalLogger
354
378
 
379
+ // Structured-output finalization state (populated by runStructuredFinalization)
380
+ private structuredOutputResult: { data: unknown; rawText: string } | null =
381
+ null
382
+ // Holds the validated value when `finalStructuredOutput.validate` is provided
383
+ // and succeeds. Distinct from `structuredOutputResult.data` (the raw,
384
+ // unvalidated payload from the structured-output.complete chunk).
385
+ private validatedStructuredOutput: unknown = undefined
386
+ private hasValidatedStructuredOutput = false
387
+ private finalizationError: {
388
+ message: string
389
+ code?: string
390
+ cause?: unknown
391
+ } | null = null
392
+ private readonly finalStructuredOutput?: {
393
+ jsonSchema: JSONSchema
394
+ yieldChunks: boolean
395
+ validate?: (data: unknown) => unknown
396
+ }
397
+
355
398
  constructor(
356
399
  config: TextEngineConfig<TAdapter, TParams>,
357
400
  logger: InternalLogger,
358
401
  ) {
359
402
  this.logger = logger
360
403
  this.adapter = config.adapter
404
+ this.finalStructuredOutput = config.finalStructuredOutput
361
405
  this.params = config.params
362
406
  this.systemPrompts = config.params.systemPrompts || []
363
407
  this.loopStrategy =
@@ -375,9 +419,7 @@ class TextEngine<
375
419
 
376
420
  // Convert messages to ModelMessage format (handles both UIMessage and ModelMessage input)
377
421
  // This ensures consistent internal format regardless of what the client sends
378
- this.messages = convertMessagesToModelMessages(
379
- config.params.messages as Array<any>,
380
- )
422
+ this.messages = convertMessagesToModelMessages(config.params.messages)
381
423
 
382
424
  // Initialize lazy tool manager after messages are converted (needs message history for scanning)
383
425
  this.lazyToolManager = new LazyToolManager(
@@ -410,7 +452,7 @@ class TextEngine<
410
452
  // `DevtoolsChatMiddleware` (defined in `@tanstack/ai-event-client` to
411
453
  // avoid a circular dep). Cast it to `ChatMiddleware` for the runner.
412
454
  const allMiddleware: Array<ChatMiddleware> = [
413
- devtoolsMiddleware() as ChatMiddleware,
455
+ devtoolsMiddleware(),
414
456
  ...(config.middleware || []),
415
457
  stripToSpecMiddleware(),
416
458
  ]
@@ -423,7 +465,7 @@ class TextEngine<
423
465
  // Legacy alias kept on the ctx so middleware that reads
424
466
  // `ctx.conversationId` keeps working. Always equals `threadId`.
425
467
  conversationId: this.threadId,
426
- phase: 'init' as ChatMiddlewarePhase,
468
+ phase: 'init',
427
469
  iteration: 0,
428
470
  chunkIndex: 0,
429
471
  signal: this.effectiveSignal,
@@ -467,6 +509,33 @@ class TextEngine<
467
509
  return this.messages
468
510
  }
469
511
 
512
+ /** Returns the structured-output result if finalization ran successfully. */
513
+ getStructuredOutputResult(): { data: unknown; rawText: string } | null {
514
+ return this.structuredOutputResult
515
+ }
516
+
517
+ /**
518
+ * Returns the validated structured-output value (the result of running
519
+ * `finalStructuredOutput.validate` against the raw structured-output data)
520
+ * wrapped in a `{ value }` object so callers can distinguish "no validation
521
+ * happened" from "validation produced undefined". Returns `null` when no
522
+ * validator was configured or validation hasn't been performed yet.
523
+ */
524
+ getValidatedStructuredOutput(): { value: unknown } | null {
525
+ return this.hasValidatedStructuredOutput
526
+ ? { value: this.validatedStructuredOutput }
527
+ : null
528
+ }
529
+
530
+ /** Returns the recorded finalization error, if any. */
531
+ getFinalizationError(): {
532
+ message: string
533
+ code?: string
534
+ cause?: unknown
535
+ } | null {
536
+ return this.finalizationError
537
+ }
538
+
470
539
  async *run(): AsyncGenerator<StreamChunk> {
471
540
  this.beforeRun()
472
541
  this.logger.agentLoop('run started', {
@@ -491,49 +560,96 @@ class TextEngine<
491
560
  return
492
561
  }
493
562
 
494
- do {
495
- if (this.earlyTermination || this.isCancelled()) {
496
- return
497
- }
498
-
499
- this.logger.agentLoop(`iteration=${this.middlewareCtx.iteration}`, {
500
- iteration: this.middlewareCtx.iteration,
501
- })
502
-
503
- await this.beginCycle()
563
+ // Skip the agent loop entirely when there are no tools AND a structured-
564
+ // output finalization will run. Without tools the model has nothing to
565
+ // do in the loop, so executing one iteration would burn an extra
566
+ // provider call before the finalization request.
567
+ const skipAgentLoop =
568
+ !!this.finalStructuredOutput && this.tools.length === 0
569
+
570
+ if (!skipAgentLoop) {
571
+ do {
572
+ if (this.earlyTermination || this.isCancelled()) {
573
+ return
574
+ }
504
575
 
505
- if (this.cyclePhase === 'processText') {
506
- // Run onConfig before each model call (phase = beforeModel)
507
- this.middlewareCtx.phase = 'beforeModel'
508
- this.middlewareCtx.iteration = this.iterationCount
509
- const iterConfig = this.buildMiddlewareConfig()
510
- const transformedConfig = await this.middlewareRunner.runOnConfig(
511
- this.middlewareCtx,
512
- iterConfig,
513
- )
514
- this.applyMiddlewareConfig(transformedConfig)
576
+ this.logger.agentLoop(`iteration=${this.middlewareCtx.iteration}`, {
577
+ iteration: this.middlewareCtx.iteration,
578
+ })
515
579
 
516
- yield* this.streamModelResponse()
517
- } else {
518
- yield* this.processToolCalls()
519
- }
580
+ await this.beginCycle()
581
+
582
+ if (this.cyclePhase === 'processText') {
583
+ // Run onConfig before each model call (phase = beforeModel)
584
+ this.middlewareCtx.phase = 'beforeModel'
585
+ this.middlewareCtx.iteration = this.iterationCount
586
+ const iterConfig = this.buildMiddlewareConfig()
587
+ const transformedConfig = await this.middlewareRunner.runOnConfig(
588
+ this.middlewareCtx,
589
+ iterConfig,
590
+ )
591
+ this.applyMiddlewareConfig(transformedConfig)
592
+
593
+ yield* this.streamModelResponse()
594
+ } else {
595
+ yield* this.processToolCalls()
596
+ }
520
597
 
521
- this.endCycle()
522
- } while (this.shouldContinue())
598
+ this.endCycle()
599
+ } while (this.shouldContinue())
600
+ }
523
601
 
524
602
  this.logger.agentLoop('run finished', {
525
603
  finishReason: this.lastFinishReason,
526
604
  })
527
605
 
528
- // Call terminal onFinish hook (skip when waiting for client — stream is paused, not finished)
529
- if (!this.terminalHookCalled && this.toolPhase !== 'wait') {
530
- this.terminalHookCalled = true
531
- await this.middlewareRunner.runOnFinish(this.middlewareCtx, {
532
- finishReason: this.lastFinishReason,
533
- duration: Date.now() - this.streamStartTime,
534
- content: this.accumulatedContent,
535
- usage: this.finishedEvent?.usage,
536
- })
606
+ // After the agent loop ends, if a structured-output finalization was
607
+ // requested AND the run hasn't already errored/aborted, run it through
608
+ // the middleware pipeline. The terminal hook fires once at the very
609
+ // end (after finalization), not after the agent loop.
610
+ if (
611
+ this.finalStructuredOutput &&
612
+ !this.isCancelled() &&
613
+ !this.finalizationError
614
+ ) {
615
+ yield* this.runStructuredFinalization()
616
+ }
617
+
618
+ // Call terminal hook (skip when waiting for client — stream is paused, not finished).
619
+ // Priority: finalizationError → onError; otherwise normal onFinish.
620
+ // Skip on cancellation — the finally block routes aborts to onAbort.
621
+ if (
622
+ !this.terminalHookCalled &&
623
+ this.toolPhase !== 'wait' &&
624
+ !this.isCancelled()
625
+ ) {
626
+ if (this.finalizationError) {
627
+ this.terminalHookCalled = true
628
+ const errForHook = new Error(
629
+ this.finalizationError.message,
630
+ this.finalizationError.cause !== undefined
631
+ ? { cause: this.finalizationError.cause }
632
+ : undefined,
633
+ )
634
+ if (this.finalizationError.code !== undefined) {
635
+ Object.defineProperty(errForHook, 'code', {
636
+ value: this.finalizationError.code,
637
+ enumerable: true,
638
+ })
639
+ }
640
+ await this.middlewareRunner.runOnError(this.middlewareCtx, {
641
+ error: errForHook,
642
+ duration: Date.now() - this.streamStartTime,
643
+ })
644
+ } else {
645
+ this.terminalHookCalled = true
646
+ await this.middlewareRunner.runOnFinish(this.middlewareCtx, {
647
+ finishReason: this.lastFinishReason,
648
+ duration: Date.now() - this.streamStartTime,
649
+ content: this.accumulatedContent,
650
+ usage: this.finishedEvent?.usage,
651
+ })
652
+ }
537
653
  }
538
654
  } catch (error: unknown) {
539
655
  if (!this.terminalHookCalled) {
@@ -692,7 +808,20 @@ class TextEngine<
692
808
  this.middlewareCtx,
693
809
  chunk,
694
810
  )
811
+ // When a streaming structured-output finalization step will run after
812
+ // the agent loop, suppress the agent-loop's RUN_STARTED/RUN_FINISHED
813
+ // here — the finalization step emits the single outer lifecycle pair
814
+ // that reaches the consumer.
815
+ const suppressAgentLifecycle =
816
+ !!this.finalStructuredOutput && this.finalStructuredOutput.yieldChunks
695
817
  for (const outputChunk of outputChunks) {
818
+ if (
819
+ suppressAgentLifecycle &&
820
+ (outputChunk.type === EventType.RUN_STARTED ||
821
+ outputChunk.type === EventType.RUN_FINISHED)
822
+ ) {
823
+ continue
824
+ }
696
825
  this.logger.output(`type=${outputChunk.type}`, { chunk: outputChunk })
697
826
  yield outputChunk
698
827
  this.middlewareCtx.chunkIndex++
@@ -1460,6 +1589,365 @@ class TextEngine<
1460
1589
  return this.isAborted() || this.isMiddlewareAborted()
1461
1590
  }
1462
1591
 
1592
+ /**
1593
+ * Run the final structured-output adapter call through the middleware
1594
+ * pipeline. Yields chunks to the caller only when
1595
+ * `this.finalStructuredOutput.yieldChunks` is true; otherwise consumes
1596
+ * silently while still piping through middleware.
1597
+ *
1598
+ * On success, populates this.structuredOutputResult.
1599
+ * On failure, populates this.finalizationError.
1600
+ */
1601
+ private async *runStructuredFinalization(): AsyncGenerator<StreamChunk> {
1602
+ if (!this.finalStructuredOutput) {
1603
+ throw new Error(
1604
+ 'runStructuredFinalization called without finalStructuredOutput config',
1605
+ )
1606
+ }
1607
+
1608
+ this.middlewareCtx.phase = 'structuredOutput'
1609
+
1610
+ // Build the structured-output config view. `tools` is intentionally
1611
+ // excluded from the type because it isn't forwarded to the structured-
1612
+ // output adapter call — including it here would be misleading API.
1613
+ const baseConfig = this.buildMiddlewareConfig()
1614
+ const { tools: _omitTools, ...baseWithoutTools } = baseConfig
1615
+ let structuredConfig: StructuredOutputMiddlewareConfig = {
1616
+ ...baseWithoutTools,
1617
+ outputSchema: this.finalStructuredOutput.jsonSchema,
1618
+ }
1619
+
1620
+ // 1) onStructuredOutputConfig — middleware can transform messages, options, outputSchema
1621
+ structuredConfig = await this.middlewareRunner.runOnStructuredOutputConfig(
1622
+ this.middlewareCtx,
1623
+ structuredConfig,
1624
+ )
1625
+
1626
+ // 2) onConfig — phase-aware general-purpose middleware re-runs at the
1627
+ // boundary. Re-attach the engine's current tools so onConfig observers
1628
+ // see the live tool set (they still won't be forwarded to the structured
1629
+ // call — same constraint applies — but the view is consistent with the
1630
+ // ChatMiddlewareConfig shape).
1631
+ const { outputSchema: pinnedSchema, ...chatConfigSlice } = structuredConfig
1632
+ const postOnConfig = await this.middlewareRunner.runOnConfig(
1633
+ this.middlewareCtx,
1634
+ { ...chatConfigSlice, tools: baseConfig.tools },
1635
+ )
1636
+
1637
+ // Apply merged config back to engine state
1638
+ this.applyMiddlewareConfig(postOnConfig)
1639
+
1640
+ // Build the StructuredOutputOptions the adapter expects.
1641
+ // `this.adapter` is already `TAdapter extends AnyTextAdapter` per the
1642
+ // class generics — no cast needed.
1643
+ const structuredCallOptions = {
1644
+ chatOptions: {
1645
+ model: this.params.model,
1646
+ messages: this.messages,
1647
+ temperature: postOnConfig.temperature,
1648
+ topP: postOnConfig.topP,
1649
+ maxTokens: postOnConfig.maxTokens,
1650
+ metadata: postOnConfig.metadata,
1651
+ modelOptions: postOnConfig.modelOptions,
1652
+ systemPrompts: postOnConfig.systemPrompts,
1653
+ logger: this.logger,
1654
+ threadId: this.threadId,
1655
+ runId: this.runIdOverride,
1656
+ parentRunId: this.parentRunIdOverride,
1657
+ ...(this.effectiveRequest ? { request: this.effectiveRequest } : {}),
1658
+ },
1659
+ outputSchema: pinnedSchema,
1660
+ }
1661
+
1662
+ // Select the provider call: native streaming if available, else synthesized fallback.
1663
+ // The fallback path captures the original adapter error so the engine can
1664
+ // attach it as `finalizationError.cause` (the RUN_ERROR wire shape only
1665
+ // carries `message` and `code`, losing stack/cause/provider properties).
1666
+ let fallbackAdapterError: unknown = undefined
1667
+ const providerStream = this.adapter.structuredOutputStream
1668
+ ? this.adapter.structuredOutputStream(structuredCallOptions)
1669
+ : fallbackStructuredOutputStream(
1670
+ this.adapter,
1671
+ structuredCallOptions,
1672
+ (err) => {
1673
+ fallbackAdapterError = err
1674
+ },
1675
+ )
1676
+
1677
+ // ============================================================
1678
+ // structured-output.start synthesis
1679
+ // ============================================================
1680
+ // The client-side StreamProcessor (PR #577) requires a CUSTOM
1681
+ // `structured-output.start` event BEFORE the JSON TEXT_MESSAGE_CONTENT
1682
+ // deltas — that's how it routes deltas into a `StructuredOutputPart`
1683
+ // rather than a plain `TextPart`. No adapter currently emits this,
1684
+ // so the engine synthesizes one (and tracks whether the adapter
1685
+ // emitted its own to avoid duplicating).
1686
+ //
1687
+ // Synthesis fires before the FIRST TEXT_MESSAGE_* event from the inner
1688
+ // stream, OR before a pre-delta RUN_ERROR (so the client can construct
1689
+ // an errored structured-output placeholder).
1690
+ let startEmitted = false
1691
+ let structuredMessageId: string | null = null
1692
+
1693
+ const extractMessageId = (c: StreamChunk): string | null => {
1694
+ if (
1695
+ c.type === EventType.TEXT_MESSAGE_START ||
1696
+ c.type === EventType.TEXT_MESSAGE_CONTENT ||
1697
+ c.type === EventType.TEXT_MESSAGE_END
1698
+ ) {
1699
+ return typeof c.messageId === 'string' && c.messageId !== ''
1700
+ ? c.messageId
1701
+ : null
1702
+ }
1703
+ return null
1704
+ }
1705
+
1706
+ const buildSynthesizedStart = (): StreamChunk => {
1707
+ const idForStart = structuredMessageId ?? generateMessageId()
1708
+ structuredMessageId = idForStart
1709
+ return {
1710
+ type: EventType.CUSTOM,
1711
+ name: 'structured-output.start',
1712
+ value: { messageId: idForStart },
1713
+ model: this.params.model,
1714
+ timestamp: Date.now(),
1715
+ threadId: this.threadId,
1716
+ ...(this.runIdOverride ? { runId: this.runIdOverride } : {}),
1717
+ }
1718
+ }
1719
+
1720
+ const pipeThroughMiddleware = async (
1721
+ synthChunk: StreamChunk,
1722
+ ): Promise<Array<StreamChunk>> =>
1723
+ this.middlewareRunner.runOnChunk(this.middlewareCtx, synthChunk)
1724
+
1725
+ // Track whether a RUN_ERROR has been yielded to streaming consumers so
1726
+ // we don't emit a duplicate synthetic one at the end.
1727
+ let runErrorYielded = false
1728
+
1729
+ // Pipe chunks through middleware; yield to consumer only when yieldChunks=true
1730
+ for await (const chunk of providerStream) {
1731
+ // Honor cancellation between chunks (mirrors streamModelResponse).
1732
+ if (this.isCancelled()) {
1733
+ break
1734
+ }
1735
+
1736
+ // Detect adapter-emitted structured-output.start so we don't duplicate
1737
+ if (
1738
+ !startEmitted &&
1739
+ chunk.type === EventType.CUSTOM &&
1740
+ chunk.name === 'structured-output.start'
1741
+ ) {
1742
+ startEmitted = true
1743
+ }
1744
+
1745
+ // Capture the assistant messageId off any text-message event so the
1746
+ // synthesized start (when needed) uses the SAME id the deltas carry
1747
+ if (!structuredMessageId) {
1748
+ const extracted = extractMessageId(chunk)
1749
+ if (extracted) structuredMessageId = extracted
1750
+ }
1751
+
1752
+ // Synthesis only matters for the streaming client path — the agentic
1753
+ // Promise path consumes chunks internally and returns a Promise, so
1754
+ // there's no client-side StreamProcessor to route deltas for.
1755
+ if (this.finalStructuredOutput.yieldChunks) {
1756
+ // Synthesize start before the FIRST TEXT_MESSAGE_* event
1757
+ if (
1758
+ !startEmitted &&
1759
+ (chunk.type === EventType.TEXT_MESSAGE_START ||
1760
+ chunk.type === EventType.TEXT_MESSAGE_CONTENT ||
1761
+ chunk.type === EventType.TEXT_MESSAGE_END)
1762
+ ) {
1763
+ startEmitted = true
1764
+ const synthStart = buildSynthesizedStart()
1765
+ const synthOutputs = await pipeThroughMiddleware(synthStart)
1766
+ for (const outputChunk of synthOutputs) {
1767
+ yield outputChunk
1768
+ this.middlewareCtx.chunkIndex++
1769
+ }
1770
+ }
1771
+
1772
+ // Synthesize start before a pre-delta RUN_ERROR so the client can
1773
+ // construct an errored placeholder structured-output part instead
1774
+ // of a silent UI.
1775
+ if (!startEmitted && chunk.type === EventType.RUN_ERROR) {
1776
+ startEmitted = true
1777
+ const synthStart = buildSynthesizedStart()
1778
+ const synthOutputs = await pipeThroughMiddleware(synthStart)
1779
+ for (const outputChunk of synthOutputs) {
1780
+ yield outputChunk
1781
+ this.middlewareCtx.chunkIndex++
1782
+ }
1783
+ }
1784
+ }
1785
+
1786
+ // 7a. Targeted state updates only.
1787
+ // We deliberately do NOT call `handleStreamChunk(chunk)` here — that
1788
+ // would mutate agent-loop state with finalization data:
1789
+ // - TEXT_MESSAGE_CONTENT deltas would pollute `accumulatedContent`
1790
+ // (raw JSON would leak into `info.content` on onFinish)
1791
+ // - RUN_FINISHED would overwrite `finishedEvent` + `lastFinishReason`
1792
+ // (finalization's 'stop' would overwrite the agent-loop's real
1793
+ // finish reason)
1794
+ // - STEP_FINISHED would pollute `currentThinkingContent`
1795
+ // Finalization is a separate phase from the agent loop; its state must
1796
+ // not cross-contaminate. The explicit branches below capture the only
1797
+ // bits we actually need from this stream.
1798
+ // All narrowing below is via the discriminated-union `chunk.type`
1799
+ // — no `as` casts.
1800
+
1801
+ if (
1802
+ chunk.type === EventType.CUSTOM &&
1803
+ chunk.name === 'structured-output.complete'
1804
+ ) {
1805
+ const parsed = readStructuredOutputCompleteValue(chunk.value)
1806
+ if (parsed) {
1807
+ this.structuredOutputResult = {
1808
+ data: parsed.object,
1809
+ rawText: parsed.raw,
1810
+ }
1811
+ }
1812
+ }
1813
+
1814
+ if (chunk.type === EventType.RUN_FINISHED && chunk.usage) {
1815
+ // RunFinishedEvent already exposes `usage` after type narrowing.
1816
+ await this.middlewareRunner.runOnUsage(this.middlewareCtx, chunk.usage)
1817
+ }
1818
+
1819
+ if (chunk.type === EventType.RUN_ERROR) {
1820
+ // RunErrorEvent already exposes `message` and `code` after narrowing.
1821
+ this.finalizationError = {
1822
+ message: chunk.message,
1823
+ ...(chunk.code ? { code: chunk.code } : {}),
1824
+ ...(fallbackAdapterError !== undefined
1825
+ ? { cause: fallbackAdapterError }
1826
+ : {}),
1827
+ }
1828
+ }
1829
+
1830
+ // 7b. Pipe through middleware
1831
+ const outputChunks = await this.middlewareRunner.runOnChunk(
1832
+ this.middlewareCtx,
1833
+ chunk,
1834
+ )
1835
+
1836
+ // 7c. Decide consumer visibility — only yieldChunks=true callers get them.
1837
+ // We do NOT strip the finalization stream's RUN_STARTED/RUN_FINISHED:
1838
+ // they are the single outer lifecycle pair the consumer sees (the
1839
+ // agent-loop's pair was suppressed in streamModelResponse when
1840
+ // finalStructuredOutput.yieldChunks is true).
1841
+ if (this.finalStructuredOutput.yieldChunks) {
1842
+ for (const outputChunk of outputChunks) {
1843
+ if (outputChunk.type === EventType.RUN_ERROR) {
1844
+ runErrorYielded = true
1845
+ }
1846
+ yield outputChunk
1847
+ this.middlewareCtx.chunkIndex++
1848
+ }
1849
+ }
1850
+
1851
+ // 7d. Terminate on error
1852
+ if (this.finalizationError) {
1853
+ break
1854
+ }
1855
+ }
1856
+
1857
+ // Mid-finalization abort: don't attribute a missing-result error.
1858
+ // Let the engine's `finally` block in `run()` route to `onAbort` instead
1859
+ // of mis-routing through `onError`.
1860
+ if (this.isCancelled()) {
1861
+ return
1862
+ }
1863
+
1864
+ // Empty stream / missing complete event
1865
+ if (!this.structuredOutputResult && !this.finalizationError) {
1866
+ this.finalizationError = {
1867
+ message: 'missing structured result',
1868
+ code: 'structured-output-missing-result',
1869
+ }
1870
+ }
1871
+
1872
+ // Run schema validation INSIDE the engine — before the terminal hook
1873
+ // chooser runs. Per spec §7.3, validation failures must route through
1874
+ // `onError`, not `onFinish`. We do this by writing to `finalizationError`
1875
+ // so the chooser in `run()` picks `onError`.
1876
+ if (
1877
+ this.structuredOutputResult &&
1878
+ !this.finalizationError &&
1879
+ this.finalStructuredOutput.validate
1880
+ ) {
1881
+ try {
1882
+ const validated = this.finalStructuredOutput.validate(
1883
+ this.structuredOutputResult.data,
1884
+ )
1885
+ this.validatedStructuredOutput = validated
1886
+ this.hasValidatedStructuredOutput = true
1887
+ } catch (err: unknown) {
1888
+ const message = err instanceof Error ? err.message : String(err)
1889
+ this.finalizationError = {
1890
+ message,
1891
+ code: 'structured-output-validation-failed',
1892
+ cause: err,
1893
+ }
1894
+ }
1895
+ }
1896
+
1897
+ // Streaming consumers must see a RUN_ERROR for finalization failures
1898
+ // (missing-result, validation-failed, or a finalizationError set after
1899
+ // a structured-output.complete already yielded). Without this synthetic
1900
+ // emission, the `for await` on the engine ends silently for the client.
1901
+ //
1902
+ // Skip when a RUN_ERROR was already yielded from the inner stream
1903
+ // (otherwise the consumer would see two error events for one failure).
1904
+ if (
1905
+ this.finalizationError &&
1906
+ this.finalStructuredOutput.yieldChunks &&
1907
+ !runErrorYielded
1908
+ ) {
1909
+ // Empty-stream case: no in-loop synthesis fired because no chunks
1910
+ // arrived. Synthesize `structured-output.start` here so the client-side
1911
+ // StreamProcessor can route the upcoming RUN_ERROR to a
1912
+ // `StructuredOutputPart` instead of dropping it as an orphan error.
1913
+ if (!startEmitted) {
1914
+ const synthStart = buildSynthesizedStart()
1915
+ const startOutputs = await pipeThroughMiddleware(synthStart)
1916
+ for (const outputChunk of startOutputs) {
1917
+ yield outputChunk
1918
+ this.middlewareCtx.chunkIndex++
1919
+ }
1920
+ startEmitted = true
1921
+ }
1922
+
1923
+ const errChunk: StreamChunk = {
1924
+ type: EventType.RUN_ERROR,
1925
+ runId: this.runIdOverride ?? this.requestId,
1926
+ model: this.params.model,
1927
+ timestamp: Date.now(),
1928
+ threadId: this.threadId,
1929
+ message: this.finalizationError.message,
1930
+ ...(this.finalizationError.code
1931
+ ? { code: this.finalizationError.code }
1932
+ : {}),
1933
+ error: {
1934
+ message: this.finalizationError.message,
1935
+ ...(this.finalizationError.code
1936
+ ? { code: this.finalizationError.code }
1937
+ : {}),
1938
+ },
1939
+ }
1940
+ const outputChunks = await this.middlewareRunner.runOnChunk(
1941
+ this.middlewareCtx,
1942
+ errChunk,
1943
+ )
1944
+ for (const outputChunk of outputChunks) {
1945
+ yield outputChunk
1946
+ this.middlewareCtx.chunkIndex++
1947
+ }
1948
+ }
1949
+ }
1950
+
1463
1951
  private buildMiddlewareConfig(): ChatMiddlewareConfig {
1464
1952
  return {
1465
1953
  messages: this.messages,
@@ -1587,7 +2075,7 @@ class TextEngine<
1587
2075
  * messages: [{ role: 'user', content: 'What is the weather?' }],
1588
2076
  * tools: [weatherTool]
1589
2077
  * })) {
1590
- * if (chunk.type === 'content') {
2078
+ * if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
1591
2079
  * console.log(chunk.delta)
1592
2080
  * }
1593
2081
  * }
@@ -1736,7 +2224,25 @@ async function runAgenticStructuredOutput<TSchema extends SchemaInput>(
1736
2224
  throw new Error('outputSchema is required for structured output')
1737
2225
  }
1738
2226
 
1739
- // Create the engine and run the agentic loop
2227
+ // Same strict-conversion as the streaming path (`forStructuredOutput: true`)
2228
+ // so the same Zod schema produces the same JSON Schema regardless of
2229
+ // stream mode — Promise<T> and stream:true must not diverge here.
2230
+ const jsonSchema = convertSchemaToJsonSchema(outputSchema, {
2231
+ forStructuredOutput: true,
2232
+ })
2233
+ if (!jsonSchema) {
2234
+ throw new Error('Failed to convert output schema to JSON Schema')
2235
+ }
2236
+
2237
+ // Validation runs INSIDE the engine (per spec §7.3) so validation failures
2238
+ // route through the engine's terminal-hook chooser as `onError`. We pass a
2239
+ // `validate` callback when the schema is a Standard Schema; otherwise we
2240
+ // pass through the raw data and the engine returns it unchanged.
2241
+ const validate = isStandardSchema(outputSchema)
2242
+ ? (data: unknown): unknown =>
2243
+ parseWithStandardSchema<InferSchemaType<TSchema>>(outputSchema, data)
2244
+ : undefined
2245
+
1740
2246
  const engine = new TextEngine(
1741
2247
  {
1742
2248
  adapter,
@@ -1746,80 +2252,100 @@ async function runAgenticStructuredOutput<TSchema extends SchemaInput>(
1746
2252
  >,
1747
2253
  middleware,
1748
2254
  context,
2255
+ finalStructuredOutput: {
2256
+ jsonSchema,
2257
+ yieldChunks: false,
2258
+ ...(validate ? { validate } : {}),
2259
+ },
1749
2260
  },
1750
2261
  logger,
1751
2262
  )
1752
2263
 
1753
- // Consume the stream to run the agentic loop
2264
+ // Consume the stream — chunks pipe through middleware but are not yielded externally
1754
2265
  for await (const _chunk of engine.run()) {
1755
- // Just consume the stream to execute the agentic loop
2266
+ // intentionally empty
1756
2267
  }
1757
2268
 
1758
- // Get the final messages from the engine (includes tool results)
1759
- const finalMessages = engine.getMessages()
1760
-
1761
- // Build text options for structured output, excluding tools since
1762
- // the agentic loop is complete and we only need the final response
1763
- const {
1764
- tools: _tools,
1765
- agentLoopStrategy: _als,
1766
- ...structuredTextOptions
1767
- } = textOptions
1768
-
1769
- // Convert the schema to JSON Schema before passing to the adapter
1770
- const jsonSchema = convertSchemaToJsonSchema(outputSchema)
1771
- if (!jsonSchema) {
1772
- throw new Error('Failed to convert output schema to JSON Schema')
2269
+ const finalizationError = engine.getFinalizationError()
2270
+ if (finalizationError) {
2271
+ const err = new Error(
2272
+ finalizationError.message,
2273
+ finalizationError.cause !== undefined
2274
+ ? { cause: finalizationError.cause }
2275
+ : undefined,
2276
+ )
2277
+ if (finalizationError.code !== undefined) {
2278
+ Object.defineProperty(err, 'code', {
2279
+ value: finalizationError.code,
2280
+ enumerable: true,
2281
+ })
2282
+ }
2283
+ throw err
1773
2284
  }
1774
2285
 
1775
- const providerName =
1776
- (adapter as { provider?: string }).provider ?? adapter.name
1777
- logger.request(
1778
- `activity=chat-structured provider=${providerName} model=${model} messages=${finalMessages.length}`,
1779
- {
1780
- provider: providerName,
1781
- model,
1782
- messageCount: finalMessages.length,
1783
- },
1784
- )
1785
-
1786
- // Call the adapter's structured output method with the conversation context
1787
- // The adapter receives JSON Schema and can apply vendor-specific patches
1788
- const result = await adapter.structuredOutput({
1789
- chatOptions: {
1790
- ...structuredTextOptions,
1791
- model,
1792
- messages: finalMessages,
1793
- logger,
1794
- },
1795
- outputSchema: jsonSchema,
1796
- })
1797
-
1798
- // Validate the result against the schema if it's a Standard Schema
1799
- if (isStandardSchema(outputSchema)) {
1800
- return parseWithStandardSchema<InferSchemaType<TSchema>>(
1801
- outputSchema,
1802
- result.data,
1803
- )
2286
+ // If a validator ran, return the validated value (typed by InferSchemaType
2287
+ // via the callback closure). Otherwise return the raw data.
2288
+ const validated = engine.getValidatedStructuredOutput()
2289
+ if (validated) {
2290
+ return validated.value as InferSchemaType<TSchema>
1804
2291
  }
1805
2292
 
1806
- // For plain JSON Schema, return the data as-is
2293
+ const result = engine.getStructuredOutputResult()
2294
+ if (!result) {
2295
+ throw new Error('structured output finalization produced no result')
2296
+ }
1807
2297
  return result.data as InferSchemaType<TSchema>
1808
2298
  }
1809
2299
 
2300
+ /**
2301
+ * Parse the `value` payload of a `structured-output.complete` CUSTOM event
2302
+ * into a typed shape, returning `null` if the runtime payload doesn't match.
2303
+ *
2304
+ * Uses an `unknown`-input runtime check rather than `as` casts so the engine
2305
+ * stays cast-free in its hot path.
2306
+ */
2307
+ function readStructuredOutputCompleteValue(
2308
+ value: unknown,
2309
+ ): { object: unknown; raw: string; reasoning?: string } | null {
2310
+ if (typeof value !== 'object' || value === null) return null
2311
+ if (!('object' in value) || !('raw' in value)) return null
2312
+ const raw = (value as { raw: unknown }).raw
2313
+ if (typeof raw !== 'string') return null
2314
+ const reasoningField = (value as { reasoning?: unknown }).reasoning
2315
+ const reasoning =
2316
+ typeof reasoningField === 'string' ? reasoningField : undefined
2317
+ return {
2318
+ object: (value as { object: unknown }).object,
2319
+ raw,
2320
+ ...(reasoning !== undefined ? { reasoning } : {}),
2321
+ }
2322
+ }
2323
+
1810
2324
  /**
1811
2325
  * Synthesize a streaming structured-output stream by wrapping a non-streaming
1812
2326
  * `structuredOutput` call. Used when an adapter doesn't implement
1813
2327
  * `structuredOutputStream` natively.
2328
+ *
2329
+ * `onAdapterError`, when provided, is invoked with the raw error from
2330
+ * `adapter.structuredOutput` before the synthesized RUN_ERROR is yielded.
2331
+ * The engine uses this to preserve the original error (stack, cause, custom
2332
+ * properties like provider `status`/`code`) as `finalizationError.cause`,
2333
+ * because the RUN_ERROR wire shape only carries `message` and `code`.
1814
2334
  */
1815
2335
  async function* fallbackStructuredOutputStream(
1816
2336
  adapter: AnyTextAdapter,
1817
2337
  options: StructuredOutputOptions<Record<string, unknown>>,
2338
+ onAdapterError?: (err: unknown) => void,
1818
2339
  ): AsyncIterable<StreamChunk> {
1819
2340
  const { chatOptions } = options
1820
- const runId = chatOptions.runId ?? `mock-${Date.now()}`
1821
- const threadId = chatOptions.threadId ?? `mock-${Date.now()}`
1822
- const messageId = `mock-${Date.now()}-${Math.random().toString(36).slice(2)}`
2341
+ // Synthesize run/thread/message IDs only when the caller didn't supply them.
2342
+ // Prefix `fallback-` (not `mock-`) because this is production fallback code
2343
+ // used by adapters without native `structuredOutputStream`, not test fixtures.
2344
+ const fallbackRand = Math.random().toString(36).slice(2)
2345
+ const runId = chatOptions.runId ?? `fallback-${Date.now()}-${fallbackRand}`
2346
+ const threadId =
2347
+ chatOptions.threadId ?? `fallback-${Date.now()}-${fallbackRand}`
2348
+ const messageId = `fallback-${Date.now()}-${fallbackRand}`
1823
2349
  const model = chatOptions.model
1824
2350
  const timestamp = Date.now()
1825
2351
 
@@ -1835,10 +2361,12 @@ async function* fallbackStructuredOutputStream(
1835
2361
  try {
1836
2362
  result = await adapter.structuredOutput(options)
1837
2363
  } catch (error) {
1838
- const message = error instanceof Error ? error.message : 'Unknown error'
2364
+ onAdapterError?.(error)
2365
+ const message = error instanceof Error ? error.message : String(error)
1839
2366
  yield {
1840
2367
  type: EventType.RUN_ERROR,
1841
2368
  runId,
2369
+ threadId,
1842
2370
  model,
1843
2371
  timestamp,
1844
2372
  message,
@@ -1889,15 +2417,21 @@ async function* fallbackStructuredOutputStream(
1889
2417
  }
1890
2418
 
1891
2419
  /**
1892
- * Run streaming structured output:
1893
- * - Without tools: call adapter.structuredOutputStream directly (single
1894
- * provider request emitting JSON deltas + a final CUSTOM event).
1895
- * - With tools: run the agent loop, yield its non-terminal chunks, then call
1896
- * structuredOutputStream on the final messages so the structured stream's
1897
- * own RUN_STARTED/RUN_FINISHED bracket the run.
2420
+ * Run streaming structured output via the TextEngine, with the engine's
2421
+ * `finalStructuredOutput.yieldChunks: true` mode. The agent loop's
2422
+ * RUN_STARTED/RUN_FINISHED are suppressed; the structured-output finalization
2423
+ * step's pair brackets the run for the consumer.
1898
2424
  *
1899
- * Validates the parsed object against the original Standard Schema (if
1900
- * applicable) when forwarding the final `structured-output.complete` event.
2425
+ * Schema validation is intentionally NOT run on this path — it is the
2426
+ * consumer's responsibility. The `structured-output.complete` CUSTOM event
2427
+ * is forwarded with the adapter-produced `value.object` as-is. This is a
2428
+ * deliberate asymmetry vs. `runAgenticStructuredOutput` (Promise<T> path),
2429
+ * which DOES run Standard Schema validation inside the engine and routes
2430
+ * validation failures through `onError`. The reason for the asymmetry:
2431
+ * streaming consumers typically render partial JSON progressively (via
2432
+ * `parsePartialJSON` or `useChat`'s `partial` slot) and validate downstream
2433
+ * after assembly. Running validation server-side would force a hard error
2434
+ * on partial-by-design payloads. See `docs/structured-outputs/overview.md`.
1901
2435
  *
1902
2436
  * Pre-flight validation (missing schema, unconvertible schema) throws
1903
2437
  * synchronously at call time rather than as a yielded RUN_ERROR mid-stream —
@@ -1958,282 +2492,31 @@ async function* runStreamingStructuredOutputImpl<TSchema extends SchemaInput>(
1958
2492
  options
1959
2493
  const model = adapter.model
1960
2494
  const logger = resolveDebugOption(debug)
1961
- const runId = textOptions.runId
1962
2495
 
1963
2496
  // Inputs may be UIMessages (from useChat) or ModelMessages (from server-side
1964
- // callers). The agent-loop branch converts via TextEngine; the no-tools
1965
- // branch must convert here so the adapter sees a uniform ModelMessage shape.
1966
- let finalMessages = convertMessagesToModelMessages(textOptions.messages ?? [])
1967
-
1968
- if (textOptions.tools?.length) {
1969
- const engine = new TextEngine(
1970
- {
1971
- adapter,
1972
- params: { ...textOptions, model, logger, messages: finalMessages },
1973
- middleware,
1974
- context,
1975
- },
1976
- logger,
1977
- )
1978
-
1979
- // The structured-output stream emits its own RUN_STARTED + RUN_FINISHED
1980
- // pair to bracket the run — drop both from the engine's output so
1981
- // consumers see exactly one terminal lifecycle pair.
1982
- let agentLoopErrored = false
1983
- try {
1984
- for await (const chunk of engine.run()) {
1985
- if (chunk.type === 'RUN_STARTED' || chunk.type === 'RUN_FINISHED') {
1986
- continue
1987
- }
1988
- if (chunk.type === 'RUN_ERROR') {
1989
- // The engine yielded RUN_ERROR without throwing (provider error mid
1990
- // agent loop). Forward it once and short-circuit before invoking
1991
- // structuredOutputStream — otherwise consumers would see a confusing
1992
- // RUN_ERROR → RUN_STARTED → structured-output.complete sequence and
1993
- // we would bill another provider call after a failed run.
1994
- agentLoopErrored = true
1995
- yield chunk
1996
- continue
1997
- }
1998
- yield chunk
1999
- }
2000
- } catch (engineError) {
2001
- const message = (engineError as Error).message || 'Agent loop failed'
2002
- logger.errors('runStreamingStructuredOutput agent loop failed', {
2003
- error: engineError,
2004
- source: 'runStreamingStructuredOutput',
2005
- })
2006
- yield {
2007
- type: EventType.RUN_ERROR,
2008
- runId,
2009
- model,
2010
- timestamp: Date.now(),
2011
- message,
2012
- code: 'agent-loop-failed',
2013
- error: { message, code: 'agent-loop-failed' },
2014
- }
2015
- return
2016
- }
2017
-
2018
- if (agentLoopErrored) {
2019
- return
2020
- }
2021
-
2022
- finalMessages = engine.getMessages()
2023
- }
2024
-
2025
- const {
2026
- tools: _tools,
2027
- agentLoopStrategy: _als,
2028
- ...structuredTextOptions
2029
- } = textOptions
2030
-
2031
- logger.request(
2032
- `activity=chat-structured-stream provider=${adapter.name} model=${model} messages=${finalMessages.length}`,
2497
+ // callers). TextEngine handles the conversion uniformly.
2498
+ const engine = new TextEngine(
2033
2499
  {
2034
- provider: adapter.name,
2035
- model,
2036
- messageCount: finalMessages.length,
2500
+ adapter,
2501
+ params: { ...textOptions, model, logger } as TextOptions<
2502
+ Record<string, unknown>,
2503
+ Record<string, unknown>
2504
+ >,
2505
+ middleware,
2506
+ context,
2507
+ finalStructuredOutput: { jsonSchema, yieldChunks: true },
2037
2508
  },
2038
- )
2039
-
2040
- // Adapters consume the abort signal via `chatOptions.request?.signal` and
2041
- // pass it to the underlying network call. Without this, aborting the SSE
2042
- // response never cancels the upstream provider request and a terminal
2043
- // structured-output.complete event still gets yielded after stop.
2044
- const structuredChatOptions = {
2045
- ...structuredTextOptions,
2046
- model,
2047
- messages: finalMessages,
2048
2509
  logger,
2049
- request: textOptions.abortController
2050
- ? { signal: textOptions.abortController.signal }
2051
- : undefined,
2052
- }
2053
-
2054
- // Adapters that don't implement structuredOutputStream natively fall back
2055
- // to wrapping the non-streaming `structuredOutput` — `fallbackStructuredOutputStream`
2056
- // synthesizes the AG-UI lifecycle events around it.
2057
- const stream = adapter.structuredOutputStream
2058
- ? adapter.structuredOutputStream({
2059
- chatOptions: structuredChatOptions,
2060
- outputSchema: jsonSchema,
2061
- })
2062
- : fallbackStructuredOutputStream(adapter, {
2063
- chatOptions: structuredChatOptions,
2064
- outputSchema: jsonSchema,
2065
- })
2066
-
2067
- // Tag the start/complete events with the assistant messageId so the
2068
- // client-side processor can route JSON deltas to (and snap) the right
2069
- // StructuredOutputPart. Missing messageId is treated as a hard error
2070
- // below to avoid silently rendering JSON as plain text.
2071
- let structuredMessageId: string | null = null
2072
- let startEmitted = false
2073
-
2074
- const extractMessageId = (c: StreamChunk): string | null => {
2075
- const id = (c as { messageId?: unknown }).messageId
2076
- return typeof id === 'string' && id !== '' ? id : null
2077
- }
2078
-
2079
- // Emit a `structured-output.start` (synthesizing a messageId if the
2080
- // adapter hasn't picked one yet) so that the client processor can route
2081
- // the forthcoming error chunk into a `structured-output` part on the
2082
- // placeholder assistant message. Without this, a RUN_ERROR that fires
2083
- // before the adapter has yielded any TEXT_MESSAGE_START leaves the
2084
- // assistant message with zero parts — the structured-output UI surface
2085
- // never sees the error.
2086
- const emitStartIfNeeded = function* (
2087
- referenceChunk: StreamChunk,
2088
- ): Generator<StreamChunk, void, void> {
2089
- if (startEmitted) return
2090
- const idForStart = structuredMessageId ?? generateMessageId()
2091
- structuredMessageId = idForStart
2092
- startEmitted = true
2093
- yield {
2094
- type: EventType.CUSTOM,
2095
- name: 'structured-output.start',
2096
- value: { messageId: idForStart },
2097
- model:
2098
- 'model' in referenceChunk ? (referenceChunk.model ?? model) : model,
2099
- timestamp:
2100
- 'timestamp' in referenceChunk
2101
- ? (referenceChunk.timestamp ?? Date.now())
2102
- : Date.now(),
2103
- runId,
2104
- }
2105
- }
2106
-
2107
- for await (const chunk of stream) {
2108
- if (!structuredMessageId) {
2109
- if (
2110
- chunk.type === EventType.TEXT_MESSAGE_START ||
2111
- chunk.type === EventType.TEXT_MESSAGE_CONTENT
2112
- ) {
2113
- structuredMessageId = extractMessageId(chunk)
2114
- }
2115
- }
2116
-
2117
- // RUN_ERROR before any text deltas: synthesize the structured-output.start
2118
- // so the client snaps an errored part instead of a silent UI. The
2119
- // synthesized messageId becomes the assistant message id the client
2120
- // creates on its side (handleRunErrorEvent calls ensureAssistantMessage()
2121
- // which picks up the same id from the structured-output.start above).
2122
- if (chunk.type === EventType.RUN_ERROR && !startEmitted) {
2123
- yield* emitStartIfNeeded(chunk)
2124
- }
2125
-
2126
- // Adapter emitted content with no usable messageId. Routing JSON deltas
2127
- // into a TextPart would silently render raw JSON in the user's chat, so
2128
- // fail loudly here instead.
2129
- if (!structuredMessageId && chunk.type === EventType.TEXT_MESSAGE_CONTENT) {
2130
- yield {
2131
- type: EventType.RUN_ERROR,
2132
- runId,
2133
- model,
2134
- timestamp: Date.now(),
2135
- message:
2136
- 'Structured-output stream produced text content without a messageId; ' +
2137
- 'adapter is not honoring the AG-UI contract.',
2138
- code: 'structured-output-missing-message-id',
2139
- }
2140
- return
2141
- }
2142
-
2143
- if (
2144
- !startEmitted &&
2145
- structuredMessageId &&
2146
- (chunk.type === EventType.TEXT_MESSAGE_START ||
2147
- chunk.type === EventType.TEXT_MESSAGE_CONTENT)
2148
- ) {
2149
- startEmitted = true
2150
- yield {
2151
- type: EventType.CUSTOM,
2152
- name: 'structured-output.start',
2153
- value: { messageId: structuredMessageId },
2154
- model: 'model' in chunk ? (chunk.model ?? model) : model,
2155
- timestamp:
2156
- 'timestamp' in chunk ? (chunk.timestamp ?? Date.now()) : Date.now(),
2157
- runId,
2158
- }
2159
- }
2510
+ )
2160
2511
 
2161
- if (
2162
- chunk.type === EventType.CUSTOM &&
2163
- chunk.name === 'structured-output.complete'
2164
- ) {
2165
- const value = chunk.value as {
2166
- object: unknown
2167
- raw: string
2168
- reasoning?: string
2169
- }
2170
- if (isStandardSchema(outputSchema)) {
2171
- try {
2172
- const validated = parseWithStandardSchema<InferSchemaType<TSchema>>(
2173
- outputSchema,
2174
- value.object,
2175
- )
2176
- yield {
2177
- ...chunk,
2178
- // Forward `reasoning` through schema validation so consumers that
2179
- // only listen for the terminal event don't lose chain-of-thought.
2180
- // Tag with messageId so the client processor can snap the right
2181
- // assistant message's structured-output part.
2182
- value: {
2183
- object: validated,
2184
- raw: value.raw,
2185
- ...(value.reasoning ? { reasoning: value.reasoning } : {}),
2186
- ...(structuredMessageId
2187
- ? { messageId: structuredMessageId }
2188
- : {}),
2189
- },
2190
- }
2191
- continue
2192
- } catch (err) {
2193
- const message = (err as Error).message || 'Schema validation failed'
2194
- logger.errors(
2195
- 'runStreamingStructuredOutput schema validation failed',
2196
- {
2197
- error: err,
2198
- source: 'runStreamingStructuredOutput',
2199
- // Include reasoning in error meta so post-mortems can recover
2200
- // what the model thought through before producing invalid JSON.
2201
- ...(value.reasoning ? { reasoning: value.reasoning } : {}),
2202
- },
2203
- )
2204
- yield {
2205
- type: EventType.RUN_ERROR,
2206
- runId,
2207
- model: chunk.model ?? model,
2208
- timestamp: chunk.timestamp ?? Date.now(),
2209
- message,
2210
- code: 'schema-validation',
2211
- error: {
2212
- message,
2213
- code: 'schema-validation',
2214
- ...(value.reasoning ? { reasoning: value.reasoning } : {}),
2215
- },
2216
- }
2217
- return
2218
- }
2219
- }
2220
- // No Standard schema (raw JSONSchema). Still tag the terminal event
2221
- // with messageId so the client processor can snap the right part.
2222
- if (structuredMessageId) {
2223
- yield {
2224
- ...chunk,
2225
- value: {
2226
- ...(chunk.value as Record<string, unknown>),
2227
- messageId: structuredMessageId,
2228
- },
2229
- }
2230
- continue
2231
- }
2232
- yield chunk
2233
- continue
2234
- }
2512
+ for await (const chunk of engine.run()) {
2235
2513
  yield chunk
2236
2514
  }
2515
+
2516
+ // Schema validation for the streaming variant remains the consumer's
2517
+ // responsibility — they read the CUSTOM 'structured-output.complete' from
2518
+ // the yielded stream. Matches pre-fix behavior.
2519
+ void outputSchema
2237
2520
  }
2238
2521
 
2239
2522
  // Re-export adapter types