@tanstack/ai 0.20.0 → 0.21.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 (98) hide show
  1. package/dist/esm/activities/chat/adapter.js +3 -1
  2. package/dist/esm/activities/chat/adapter.js.map +1 -1
  3. package/dist/esm/activities/chat/index.d.ts +4 -4
  4. package/dist/esm/activities/chat/index.js +403 -276
  5. package/dist/esm/activities/chat/index.js.map +1 -1
  6. package/dist/esm/activities/chat/messages.js +1 -1
  7. package/dist/esm/activities/chat/messages.js.map +1 -1
  8. package/dist/esm/activities/chat/middleware/compose.d.ts +10 -1
  9. package/dist/esm/activities/chat/middleware/compose.js +57 -0
  10. package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
  11. package/dist/esm/activities/chat/middleware/index.d.ts +1 -1
  12. package/dist/esm/activities/chat/middleware/types.d.ts +40 -8
  13. package/dist/esm/activities/chat/stream/processor.d.ts +8 -8
  14. package/dist/esm/activities/chat/stream/processor.js +29 -21
  15. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  16. package/dist/esm/activities/chat/stream/strategies.d.ts +3 -3
  17. package/dist/esm/activities/chat/stream/strategies.js +4 -4
  18. package/dist/esm/activities/chat/stream/strategies.js.map +1 -1
  19. package/dist/esm/activities/chat/tools/lazy-tool-manager.js +5 -0
  20. package/dist/esm/activities/chat/tools/lazy-tool-manager.js.map +1 -1
  21. package/dist/esm/activities/chat/tools/schema-converter.d.ts +13 -2
  22. package/dist/esm/activities/chat/tools/schema-converter.js +47 -37
  23. package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
  24. package/dist/esm/activities/chat/tools/tool-calls.d.ts +2 -2
  25. package/dist/esm/activities/chat/tools/tool-calls.js +17 -9
  26. package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
  27. package/dist/esm/activities/chat/tools/tool-definition.js +1 -1
  28. package/dist/esm/activities/chat/tools/tool-definition.js.map +1 -1
  29. package/dist/esm/activities/generateAudio/adapter.js +3 -1
  30. package/dist/esm/activities/generateAudio/adapter.js.map +1 -1
  31. package/dist/esm/activities/generateAudio/index.d.ts +5 -1
  32. package/dist/esm/activities/generateAudio/index.js.map +1 -1
  33. package/dist/esm/activities/generateImage/adapter.js +3 -1
  34. package/dist/esm/activities/generateImage/adapter.js.map +1 -1
  35. package/dist/esm/activities/generateImage/index.js +5 -0
  36. package/dist/esm/activities/generateImage/index.js.map +1 -1
  37. package/dist/esm/activities/generateSpeech/adapter.js +3 -1
  38. package/dist/esm/activities/generateSpeech/adapter.js.map +1 -1
  39. package/dist/esm/activities/generateTranscription/adapter.js +3 -1
  40. package/dist/esm/activities/generateTranscription/adapter.js.map +1 -1
  41. package/dist/esm/activities/generateVideo/adapter.js +3 -1
  42. package/dist/esm/activities/generateVideo/adapter.js.map +1 -1
  43. package/dist/esm/activities/stream-generation-result.js +6 -2
  44. package/dist/esm/activities/stream-generation-result.js.map +1 -1
  45. package/dist/esm/activities/summarize/adapter.js +3 -1
  46. package/dist/esm/activities/summarize/adapter.js.map +1 -1
  47. package/dist/esm/activities/summarize/chat-stream-summarize.d.ts +1 -1
  48. package/dist/esm/activities/summarize/chat-stream-summarize.js +5 -0
  49. package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -1
  50. package/dist/esm/activities/summarize/index.js.map +1 -1
  51. package/dist/esm/extend-adapter.js.map +1 -1
  52. package/dist/esm/index.d.ts +3 -2
  53. package/dist/esm/index.js +4 -1
  54. package/dist/esm/index.js.map +1 -1
  55. package/dist/esm/logger/internal-logger.js +2 -0
  56. package/dist/esm/logger/internal-logger.js.map +1 -1
  57. package/dist/esm/middlewares/content-guard.js +5 -4
  58. package/dist/esm/middlewares/content-guard.js.map +1 -1
  59. package/dist/esm/middlewares/otel.js +25 -18
  60. package/dist/esm/middlewares/otel.js.map +1 -1
  61. package/dist/esm/realtime/index.d.ts +1 -1
  62. package/dist/esm/realtime/index.js.map +1 -1
  63. package/dist/esm/strip-to-spec-middleware.js.map +1 -1
  64. package/dist/esm/tools/provider-tool.d.ts +9 -0
  65. package/dist/esm/tools/provider-tool.js +7 -0
  66. package/dist/esm/tools/provider-tool.js.map +1 -0
  67. package/dist/esm/types.d.ts +6 -6
  68. package/dist/esm/utilities/ag-ui-wire.js +4 -1
  69. package/dist/esm/utilities/ag-ui-wire.js.map +1 -1
  70. package/dist/esm/utilities/chat-params.js.map +1 -1
  71. package/package.json +2 -2
  72. package/skills/ai-core/middleware/SKILL.md +124 -18
  73. package/skills/ai-core/structured-outputs/SKILL.md +13 -0
  74. package/src/activities/chat/index.ts +685 -394
  75. package/src/activities/chat/messages.ts +3 -2
  76. package/src/activities/chat/middleware/compose.ts +65 -5
  77. package/src/activities/chat/middleware/index.ts +1 -0
  78. package/src/activities/chat/middleware/types.ts +64 -11
  79. package/src/activities/chat/stream/processor.ts +21 -21
  80. package/src/activities/chat/stream/strategies.ts +3 -3
  81. package/src/activities/chat/tools/schema-converter.ts +98 -58
  82. package/src/activities/chat/tools/tool-calls.ts +18 -11
  83. package/src/activities/chat/tools/tool-definition.ts +6 -1
  84. package/src/activities/generateAudio/index.ts +5 -4
  85. package/src/activities/generateImage/index.ts +8 -3
  86. package/src/activities/stream-generation-result.ts +11 -2
  87. package/src/activities/summarize/chat-stream-summarize.ts +5 -2
  88. package/src/activities/summarize/index.ts +2 -2
  89. package/src/extend-adapter.ts +1 -1
  90. package/src/index.ts +6 -1
  91. package/src/middlewares/content-guard.ts +12 -4
  92. package/src/middlewares/otel.ts +32 -24
  93. package/src/realtime/index.ts +1 -1
  94. package/src/strip-to-spec-middleware.ts +1 -4
  95. package/src/tools/provider-tool.ts +14 -0
  96. package/src/types.ts +12 -8
  97. package/src/utilities/ag-ui-wire.ts +4 -3
  98. package/src/utilities/chat-params.ts +2 -2
@@ -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'
@@ -95,14 +96,16 @@ export interface TextActivityOptions<
95
96
  *
96
97
  * 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).
97
98
  */
98
- messages?: Array<
99
- | UIMessage
100
- | ModelMessage
101
- | ConstrainedModelMessage<{
102
- inputModalities: TAdapter['~types']['inputModalities']
103
- messageMetadataByModality: TAdapter['~types']['messageMetadataByModality']
104
- }>
105
- >
99
+ messages?:
100
+ | Array<
101
+ | UIMessage
102
+ | ModelMessage
103
+ | ConstrainedModelMessage<{
104
+ inputModalities: TAdapter['~types']['inputModalities']
105
+ messageMetadataByModality: TAdapter['~types']['messageMetadataByModality']
106
+ }>
107
+ >
108
+ | undefined
106
109
  /**
107
110
  * System prompts to prepend to the conversation.
108
111
  *
@@ -112,9 +115,9 @@ export interface TextActivityOptions<
112
115
  * caching), providers without per-prompt metadata reject the field
113
116
  * entirely.
114
117
  */
115
- systemPrompts?: Array<
116
- SystemPrompt<TAdapter['~types']['systemPromptMetadata']>
117
- >
118
+ systemPrompts?:
119
+ | Array<SystemPrompt<TAdapter['~types']['systemPromptMetadata']>>
120
+ | undefined
118
121
  /**
119
122
  * Tools for function calling (auto-executed when called).
120
123
  *
@@ -125,10 +128,12 @@ export interface TextActivityOptions<
125
128
  * `supports.tools` list. Passing an unsupported tool produces a
126
129
  * compile-time error on the array element.
127
130
  */
128
- tools?: Array<
129
- | (Tool & { readonly '~toolKind'?: never })
130
- | ProviderTool<string, TAdapter['~types']['toolCapabilities'][number]>
131
- >
131
+ tools?:
132
+ | Array<
133
+ | (Tool & { readonly '~toolKind'?: never })
134
+ | ProviderTool<string, TAdapter['~types']['toolCapabilities'][number]>
135
+ >
136
+ | undefined
132
137
  /** Controls the randomness of the output. Higher values make output more random. Range: [0.0, 2.0] */
133
138
  temperature?: TextOptions['temperature']
134
139
  /** Nucleus sampling parameter. The model considers tokens with topP probability mass. */
@@ -290,6 +295,29 @@ interface TextEngineConfig<
290
295
  params: TParams
291
296
  middleware?: Array<ChatMiddleware>
292
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
+ }
293
321
  }
294
322
 
295
323
  type ToolPhaseResult = 'continue' | 'stop' | 'wait'
@@ -323,7 +351,7 @@ class TextEngine<
323
351
  []
324
352
  private currentThinkingContent = ''
325
353
  private currentThinkingSignature = ''
326
- private eventOptions?: Record<string, unknown>
354
+ private eventOptions?: Record<string, unknown> | undefined
327
355
  private eventToolNames?: Array<string>
328
356
  private finishedEvent: RunFinishedEvent | null = null
329
357
  private earlyTermination = false
@@ -334,26 +362,46 @@ class TextEngine<
334
362
  private readonly initialClientToolResults: Map<string, any>
335
363
 
336
364
  // AG-UI protocol IDs
337
- private threadId: string
338
- private runIdOverride?: string
339
- private parentRunIdOverride?: string
365
+ private readonly threadId: string
366
+ private readonly runIdOverride?: string
367
+ private readonly parentRunIdOverride?: string
340
368
 
341
369
  // Middleware support
342
370
  private readonly middlewareRunner: MiddlewareRunner
343
371
  private readonly middlewareCtx: ChatMiddlewareContext
344
372
  private readonly deferredPromises: Array<Promise<unknown>> = []
345
373
  private abortReason?: string
346
- private middlewareAbortController?: AbortController
374
+ private readonly middlewareAbortController?: AbortController
347
375
  private terminalHookCalled = false
348
376
 
349
377
  private readonly logger: InternalLogger
350
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
+
351
398
  constructor(
352
399
  config: TextEngineConfig<TAdapter, TParams>,
353
400
  logger: InternalLogger,
354
401
  ) {
355
402
  this.logger = logger
356
403
  this.adapter = config.adapter
404
+ this.finalStructuredOutput = config.finalStructuredOutput
357
405
  this.params = config.params
358
406
  this.systemPrompts = config.params.systemPrompts || []
359
407
  this.loopStrategy =
@@ -371,9 +419,7 @@ class TextEngine<
371
419
 
372
420
  // Convert messages to ModelMessage format (handles both UIMessage and ModelMessage input)
373
421
  // This ensures consistent internal format regardless of what the client sends
374
- this.messages = convertMessagesToModelMessages(
375
- config.params.messages as Array<any>,
376
- )
422
+ this.messages = convertMessagesToModelMessages(config.params.messages)
377
423
 
378
424
  // Initialize lazy tool manager after messages are converted (needs message history for scanning)
379
425
  this.lazyToolManager = new LazyToolManager(
@@ -402,7 +448,10 @@ class TextEngine<
402
448
  // handleStreamChunk processes raw chunks BEFORE middleware, so internal
403
449
  // state management sees extended fields (finishReason, delta, toolCallName, etc.).
404
450
  // The strip middleware ensures the yielded public stream is AG-UI spec-compliant.
405
- const allMiddleware = [
451
+ // `devtoolsMiddleware()` returns a structurally compatible
452
+ // `DevtoolsChatMiddleware` (defined in `@tanstack/ai-event-client` to
453
+ // avoid a circular dep). Cast it to `ChatMiddleware` for the runner.
454
+ const allMiddleware: Array<ChatMiddleware> = [
406
455
  devtoolsMiddleware(),
407
456
  ...(config.middleware || []),
408
457
  stripToSpecMiddleware(),
@@ -416,7 +465,7 @@ class TextEngine<
416
465
  // Legacy alias kept on the ctx so middleware that reads
417
466
  // `ctx.conversationId` keeps working. Always equals `threadId`.
418
467
  conversationId: this.threadId,
419
- phase: 'init' as ChatMiddlewarePhase,
468
+ phase: 'init',
420
469
  iteration: 0,
421
470
  chunkIndex: 0,
422
471
  signal: this.effectiveSignal,
@@ -460,6 +509,33 @@ class TextEngine<
460
509
  return this.messages
461
510
  }
462
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
+
463
539
  async *run(): AsyncGenerator<StreamChunk> {
464
540
  this.beforeRun()
465
541
  this.logger.agentLoop('run started', {
@@ -484,49 +560,96 @@ class TextEngine<
484
560
  return
485
561
  }
486
562
 
487
- do {
488
- if (this.earlyTermination || this.isCancelled()) {
489
- return
490
- }
491
-
492
- this.logger.agentLoop(`iteration=${this.middlewareCtx.iteration}`, {
493
- iteration: this.middlewareCtx.iteration,
494
- })
495
-
496
- 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
+ }
497
575
 
498
- if (this.cyclePhase === 'processText') {
499
- // Run onConfig before each model call (phase = beforeModel)
500
- this.middlewareCtx.phase = 'beforeModel'
501
- this.middlewareCtx.iteration = this.iterationCount
502
- const iterConfig = this.buildMiddlewareConfig()
503
- const transformedConfig = await this.middlewareRunner.runOnConfig(
504
- this.middlewareCtx,
505
- iterConfig,
506
- )
507
- this.applyMiddlewareConfig(transformedConfig)
576
+ this.logger.agentLoop(`iteration=${this.middlewareCtx.iteration}`, {
577
+ iteration: this.middlewareCtx.iteration,
578
+ })
508
579
 
509
- yield* this.streamModelResponse()
510
- } else {
511
- yield* this.processToolCalls()
512
- }
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
+ }
513
597
 
514
- this.endCycle()
515
- } while (this.shouldContinue())
598
+ this.endCycle()
599
+ } while (this.shouldContinue())
600
+ }
516
601
 
517
602
  this.logger.agentLoop('run finished', {
518
603
  finishReason: this.lastFinishReason,
519
604
  })
520
605
 
521
- // Call terminal onFinish hook (skip when waiting for client — stream is paused, not finished)
522
- if (!this.terminalHookCalled && this.toolPhase !== 'wait') {
523
- this.terminalHookCalled = true
524
- await this.middlewareRunner.runOnFinish(this.middlewareCtx, {
525
- finishReason: this.lastFinishReason,
526
- duration: Date.now() - this.streamStartTime,
527
- content: this.accumulatedContent,
528
- usage: this.finishedEvent?.usage,
529
- })
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
+ }
530
653
  }
531
654
  } catch (error: unknown) {
532
655
  if (!this.terminalHookCalled) {
@@ -685,7 +808,20 @@ class TextEngine<
685
808
  this.middlewareCtx,
686
809
  chunk,
687
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
688
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
+ }
689
825
  this.logger.output(`type=${outputChunk.type}`, { chunk: outputChunk })
690
826
  yield outputChunk
691
827
  this.middlewareCtx.chunkIndex++
@@ -703,6 +839,7 @@ class TextEngine<
703
839
  }
704
840
 
705
841
  private handleStreamChunk(chunk: StreamChunk): void {
842
+ // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- AG-UI EventType enum members vs string-literal case labels; default branch handles untraced events.
706
843
  switch (chunk.type) {
707
844
  // AG-UI Events
708
845
  case 'TEXT_MESSAGE_CONTENT':
@@ -1001,11 +1138,10 @@ class TextEngine<
1001
1138
  return true
1002
1139
  })
1003
1140
 
1004
- if (undiscoveredLazyResults.length > 0) {
1005
- const finishEvt = this.finishedEvent!
1141
+ if (undiscoveredLazyResults.length > 0 && this.finishedEvent) {
1006
1142
  for (const chunk of this.buildToolResultChunks(
1007
1143
  undiscoveredLazyResults,
1008
- finishEvt,
1144
+ this.finishedEvent,
1009
1145
  )) {
1010
1146
  yield* this.pipeThroughMiddleware(chunk)
1011
1147
  }
@@ -1453,6 +1589,365 @@ class TextEngine<
1453
1589
  return this.isAborted() || this.isMiddlewareAborted()
1454
1590
  }
1455
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
+
1456
1951
  private buildMiddlewareConfig(): ChatMiddlewareConfig {
1457
1952
  return {
1458
1953
  messages: this.messages,
@@ -1580,7 +2075,7 @@ class TextEngine<
1580
2075
  * messages: [{ role: 'user', content: 'What is the weather?' }],
1581
2076
  * tools: [weatherTool]
1582
2077
  * })) {
1583
- * if (chunk.type === 'content') {
2078
+ * if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
1584
2079
  * console.log(chunk.delta)
1585
2080
  * }
1586
2081
  * }
@@ -1702,8 +2197,9 @@ async function* runStreamingText(
1702
2197
  function runNonStreamingText(
1703
2198
  options: TextActivityOptions<AnyTextAdapter, undefined, false>,
1704
2199
  ): Promise<string> {
1705
- // Run the streaming text and collect all text using streamToText
2200
+ // Run the streaming text and collect all text using streamToText.
1706
2201
  const stream = runStreamingText(
2202
+ // eslint-disable-next-line no-restricted-syntax -- generic-stream remap: caller is non-streaming (false), but runStreamingText is invoked internally to collect text; concrete `false`→`true` literals don't structurally overlap.
1707
2203
  options as unknown as TextActivityOptions<AnyTextAdapter, undefined, true>,
1708
2204
  )
1709
2205
 
@@ -1728,7 +2224,25 @@ async function runAgenticStructuredOutput<TSchema extends SchemaInput>(
1728
2224
  throw new Error('outputSchema is required for structured output')
1729
2225
  }
1730
2226
 
1731
- // 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
+
1732
2246
  const engine = new TextEngine(
1733
2247
  {
1734
2248
  adapter,
@@ -1738,80 +2252,100 @@ async function runAgenticStructuredOutput<TSchema extends SchemaInput>(
1738
2252
  >,
1739
2253
  middleware,
1740
2254
  context,
2255
+ finalStructuredOutput: {
2256
+ jsonSchema,
2257
+ yieldChunks: false,
2258
+ ...(validate ? { validate } : {}),
2259
+ },
1741
2260
  },
1742
2261
  logger,
1743
2262
  )
1744
2263
 
1745
- // Consume the stream to run the agentic loop
2264
+ // Consume the stream — chunks pipe through middleware but are not yielded externally
1746
2265
  for await (const _chunk of engine.run()) {
1747
- // Just consume the stream to execute the agentic loop
2266
+ // intentionally empty
1748
2267
  }
1749
2268
 
1750
- // Get the final messages from the engine (includes tool results)
1751
- const finalMessages = engine.getMessages()
1752
-
1753
- // Build text options for structured output, excluding tools since
1754
- // the agentic loop is complete and we only need the final response
1755
- const {
1756
- tools: _tools,
1757
- agentLoopStrategy: _als,
1758
- ...structuredTextOptions
1759
- } = textOptions
1760
-
1761
- // Convert the schema to JSON Schema before passing to the adapter
1762
- const jsonSchema = convertSchemaToJsonSchema(outputSchema)
1763
- if (!jsonSchema) {
1764
- 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
1765
2284
  }
1766
2285
 
1767
- const providerName =
1768
- (adapter as { provider?: string }).provider ?? adapter.name
1769
- logger.request(
1770
- `activity=chat-structured provider=${providerName} model=${model} messages=${finalMessages.length}`,
1771
- {
1772
- provider: providerName,
1773
- model,
1774
- messageCount: finalMessages.length,
1775
- },
1776
- )
1777
-
1778
- // Call the adapter's structured output method with the conversation context
1779
- // The adapter receives JSON Schema and can apply vendor-specific patches
1780
- const result = await adapter.structuredOutput({
1781
- chatOptions: {
1782
- ...structuredTextOptions,
1783
- model,
1784
- messages: finalMessages,
1785
- logger,
1786
- },
1787
- outputSchema: jsonSchema,
1788
- })
1789
-
1790
- // Validate the result against the schema if it's a Standard Schema
1791
- if (isStandardSchema(outputSchema)) {
1792
- return parseWithStandardSchema<InferSchemaType<TSchema>>(
1793
- outputSchema,
1794
- result.data,
1795
- )
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>
1796
2291
  }
1797
2292
 
1798
- // 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
+ }
1799
2297
  return result.data as InferSchemaType<TSchema>
1800
2298
  }
1801
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
+
1802
2324
  /**
1803
2325
  * Synthesize a streaming structured-output stream by wrapping a non-streaming
1804
2326
  * `structuredOutput` call. Used when an adapter doesn't implement
1805
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`.
1806
2334
  */
1807
2335
  async function* fallbackStructuredOutputStream(
1808
2336
  adapter: AnyTextAdapter,
1809
2337
  options: StructuredOutputOptions<Record<string, unknown>>,
2338
+ onAdapterError?: (err: unknown) => void,
1810
2339
  ): AsyncIterable<StreamChunk> {
1811
2340
  const { chatOptions } = options
1812
- const runId = chatOptions.runId ?? `mock-${Date.now()}`
1813
- const threadId = chatOptions.threadId ?? `mock-${Date.now()}`
1814
- 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}`
1815
2349
  const model = chatOptions.model
1816
2350
  const timestamp = Date.now()
1817
2351
 
@@ -1827,10 +2361,12 @@ async function* fallbackStructuredOutputStream(
1827
2361
  try {
1828
2362
  result = await adapter.structuredOutput(options)
1829
2363
  } catch (error) {
1830
- const message = error instanceof Error ? error.message : 'Unknown error'
2364
+ onAdapterError?.(error)
2365
+ const message = error instanceof Error ? error.message : String(error)
1831
2366
  yield {
1832
2367
  type: EventType.RUN_ERROR,
1833
2368
  runId,
2369
+ threadId,
1834
2370
  model,
1835
2371
  timestamp,
1836
2372
  message,
@@ -1881,15 +2417,21 @@ async function* fallbackStructuredOutputStream(
1881
2417
  }
1882
2418
 
1883
2419
  /**
1884
- * Run streaming structured output:
1885
- * - Without tools: call adapter.structuredOutputStream directly (single
1886
- * provider request emitting JSON deltas + a final CUSTOM event).
1887
- * - With tools: run the agent loop, yield its non-terminal chunks, then call
1888
- * structuredOutputStream on the final messages so the structured stream's
1889
- * 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.
1890
2424
  *
1891
- * Validates the parsed object against the original Standard Schema (if
1892
- * 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`.
1893
2435
  *
1894
2436
  * Pre-flight validation (missing schema, unconvertible schema) throws
1895
2437
  * synchronously at call time rather than as a yielded RUN_ERROR mid-stream —
@@ -1950,282 +2492,31 @@ async function* runStreamingStructuredOutputImpl<TSchema extends SchemaInput>(
1950
2492
  options
1951
2493
  const model = adapter.model
1952
2494
  const logger = resolveDebugOption(debug)
1953
- const runId = textOptions.runId
1954
2495
 
1955
2496
  // Inputs may be UIMessages (from useChat) or ModelMessages (from server-side
1956
- // callers). The agent-loop branch converts via TextEngine; the no-tools
1957
- // branch must convert here so the adapter sees a uniform ModelMessage shape.
1958
- let finalMessages = convertMessagesToModelMessages(textOptions.messages ?? [])
1959
-
1960
- if (textOptions.tools?.length) {
1961
- const engine = new TextEngine(
1962
- {
1963
- adapter,
1964
- params: { ...textOptions, model, logger, messages: finalMessages },
1965
- middleware,
1966
- context,
1967
- },
1968
- logger,
1969
- )
1970
-
1971
- // The structured-output stream emits its own RUN_STARTED + RUN_FINISHED
1972
- // pair to bracket the run — drop both from the engine's output so
1973
- // consumers see exactly one terminal lifecycle pair.
1974
- let agentLoopErrored = false
1975
- try {
1976
- for await (const chunk of engine.run()) {
1977
- if (chunk.type === 'RUN_STARTED' || chunk.type === 'RUN_FINISHED') {
1978
- continue
1979
- }
1980
- if (chunk.type === 'RUN_ERROR') {
1981
- // The engine yielded RUN_ERROR without throwing (provider error mid
1982
- // agent loop). Forward it once and short-circuit before invoking
1983
- // structuredOutputStream — otherwise consumers would see a confusing
1984
- // RUN_ERROR → RUN_STARTED → structured-output.complete sequence and
1985
- // we would bill another provider call after a failed run.
1986
- agentLoopErrored = true
1987
- yield chunk
1988
- continue
1989
- }
1990
- yield chunk
1991
- }
1992
- } catch (engineError) {
1993
- const message = (engineError as Error).message || 'Agent loop failed'
1994
- logger.errors('runStreamingStructuredOutput agent loop failed', {
1995
- error: engineError,
1996
- source: 'runStreamingStructuredOutput',
1997
- })
1998
- yield {
1999
- type: EventType.RUN_ERROR,
2000
- runId,
2001
- model,
2002
- timestamp: Date.now(),
2003
- message,
2004
- code: 'agent-loop-failed',
2005
- error: { message, code: 'agent-loop-failed' },
2006
- }
2007
- return
2008
- }
2009
-
2010
- if (agentLoopErrored) {
2011
- return
2012
- }
2013
-
2014
- finalMessages = engine.getMessages()
2015
- }
2016
-
2017
- const {
2018
- tools: _tools,
2019
- agentLoopStrategy: _als,
2020
- ...structuredTextOptions
2021
- } = textOptions
2022
-
2023
- logger.request(
2024
- `activity=chat-structured-stream provider=${adapter.name} model=${model} messages=${finalMessages.length}`,
2497
+ // callers). TextEngine handles the conversion uniformly.
2498
+ const engine = new TextEngine(
2025
2499
  {
2026
- provider: adapter.name,
2027
- model,
2028
- 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 },
2029
2508
  },
2030
- )
2031
-
2032
- // Adapters consume the abort signal via `chatOptions.request?.signal` and
2033
- // pass it to the underlying network call. Without this, aborting the SSE
2034
- // response never cancels the upstream provider request and a terminal
2035
- // structured-output.complete event still gets yielded after stop.
2036
- const structuredChatOptions = {
2037
- ...structuredTextOptions,
2038
- model,
2039
- messages: finalMessages,
2040
2509
  logger,
2041
- request: textOptions.abortController
2042
- ? { signal: textOptions.abortController.signal }
2043
- : undefined,
2044
- }
2045
-
2046
- // Adapters that don't implement structuredOutputStream natively fall back
2047
- // to wrapping the non-streaming `structuredOutput` — `fallbackStructuredOutputStream`
2048
- // synthesizes the AG-UI lifecycle events around it.
2049
- const stream = adapter.structuredOutputStream
2050
- ? adapter.structuredOutputStream({
2051
- chatOptions: structuredChatOptions,
2052
- outputSchema: jsonSchema,
2053
- })
2054
- : fallbackStructuredOutputStream(adapter, {
2055
- chatOptions: structuredChatOptions,
2056
- outputSchema: jsonSchema,
2057
- })
2058
-
2059
- // Tag the start/complete events with the assistant messageId so the
2060
- // client-side processor can route JSON deltas to (and snap) the right
2061
- // StructuredOutputPart. Missing messageId is treated as a hard error
2062
- // below to avoid silently rendering JSON as plain text.
2063
- let structuredMessageId: string | null = null
2064
- let startEmitted = false
2065
-
2066
- const extractMessageId = (c: StreamChunk): string | null => {
2067
- const id = (c as { messageId?: unknown }).messageId
2068
- return typeof id === 'string' && id !== '' ? id : null
2069
- }
2070
-
2071
- // Emit a `structured-output.start` (synthesizing a messageId if the
2072
- // adapter hasn't picked one yet) so that the client processor can route
2073
- // the forthcoming error chunk into a `structured-output` part on the
2074
- // placeholder assistant message. Without this, a RUN_ERROR that fires
2075
- // before the adapter has yielded any TEXT_MESSAGE_START leaves the
2076
- // assistant message with zero parts — the structured-output UI surface
2077
- // never sees the error.
2078
- const emitStartIfNeeded = function* (
2079
- referenceChunk: StreamChunk,
2080
- ): Generator<StreamChunk, void, void> {
2081
- if (startEmitted) return
2082
- const idForStart = structuredMessageId ?? generateMessageId()
2083
- structuredMessageId = idForStart
2084
- startEmitted = true
2085
- yield {
2086
- type: EventType.CUSTOM,
2087
- name: 'structured-output.start',
2088
- value: { messageId: idForStart },
2089
- model:
2090
- 'model' in referenceChunk ? (referenceChunk.model ?? model) : model,
2091
- timestamp:
2092
- 'timestamp' in referenceChunk
2093
- ? (referenceChunk.timestamp ?? Date.now())
2094
- : Date.now(),
2095
- runId,
2096
- }
2097
- }
2098
-
2099
- for await (const chunk of stream) {
2100
- if (!structuredMessageId) {
2101
- if (
2102
- chunk.type === EventType.TEXT_MESSAGE_START ||
2103
- chunk.type === EventType.TEXT_MESSAGE_CONTENT
2104
- ) {
2105
- structuredMessageId = extractMessageId(chunk)
2106
- }
2107
- }
2108
-
2109
- // RUN_ERROR before any text deltas: synthesize the structured-output.start
2110
- // so the client snaps an errored part instead of a silent UI. The
2111
- // synthesized messageId becomes the assistant message id the client
2112
- // creates on its side (handleRunErrorEvent calls ensureAssistantMessage()
2113
- // which picks up the same id from the structured-output.start above).
2114
- if (chunk.type === EventType.RUN_ERROR && !startEmitted) {
2115
- yield* emitStartIfNeeded(chunk)
2116
- }
2117
-
2118
- // Adapter emitted content with no usable messageId. Routing JSON deltas
2119
- // into a TextPart would silently render raw JSON in the user's chat, so
2120
- // fail loudly here instead.
2121
- if (!structuredMessageId && chunk.type === EventType.TEXT_MESSAGE_CONTENT) {
2122
- yield {
2123
- type: EventType.RUN_ERROR,
2124
- runId,
2125
- model,
2126
- timestamp: Date.now(),
2127
- message:
2128
- 'Structured-output stream produced text content without a messageId; ' +
2129
- 'adapter is not honoring the AG-UI contract.',
2130
- code: 'structured-output-missing-message-id',
2131
- }
2132
- return
2133
- }
2134
-
2135
- if (
2136
- !startEmitted &&
2137
- structuredMessageId &&
2138
- (chunk.type === EventType.TEXT_MESSAGE_START ||
2139
- chunk.type === EventType.TEXT_MESSAGE_CONTENT)
2140
- ) {
2141
- startEmitted = true
2142
- yield {
2143
- type: EventType.CUSTOM,
2144
- name: 'structured-output.start',
2145
- value: { messageId: structuredMessageId },
2146
- model: 'model' in chunk ? (chunk.model ?? model) : model,
2147
- timestamp:
2148
- 'timestamp' in chunk ? (chunk.timestamp ?? Date.now()) : Date.now(),
2149
- runId,
2150
- }
2151
- }
2510
+ )
2152
2511
 
2153
- if (
2154
- chunk.type === EventType.CUSTOM &&
2155
- chunk.name === 'structured-output.complete'
2156
- ) {
2157
- const value = chunk.value as {
2158
- object: unknown
2159
- raw: string
2160
- reasoning?: string
2161
- }
2162
- if (isStandardSchema(outputSchema)) {
2163
- try {
2164
- const validated = parseWithStandardSchema<InferSchemaType<TSchema>>(
2165
- outputSchema,
2166
- value.object,
2167
- )
2168
- yield {
2169
- ...chunk,
2170
- // Forward `reasoning` through schema validation so consumers that
2171
- // only listen for the terminal event don't lose chain-of-thought.
2172
- // Tag with messageId so the client processor can snap the right
2173
- // assistant message's structured-output part.
2174
- value: {
2175
- object: validated,
2176
- raw: value.raw,
2177
- ...(value.reasoning ? { reasoning: value.reasoning } : {}),
2178
- ...(structuredMessageId
2179
- ? { messageId: structuredMessageId }
2180
- : {}),
2181
- },
2182
- }
2183
- continue
2184
- } catch (err) {
2185
- const message = (err as Error).message || 'Schema validation failed'
2186
- logger.errors(
2187
- 'runStreamingStructuredOutput schema validation failed',
2188
- {
2189
- error: err,
2190
- source: 'runStreamingStructuredOutput',
2191
- // Include reasoning in error meta so post-mortems can recover
2192
- // what the model thought through before producing invalid JSON.
2193
- ...(value.reasoning ? { reasoning: value.reasoning } : {}),
2194
- },
2195
- )
2196
- yield {
2197
- type: EventType.RUN_ERROR,
2198
- runId,
2199
- model: chunk.model ?? model,
2200
- timestamp: chunk.timestamp ?? Date.now(),
2201
- message,
2202
- code: 'schema-validation',
2203
- error: {
2204
- message,
2205
- code: 'schema-validation',
2206
- ...(value.reasoning ? { reasoning: value.reasoning } : {}),
2207
- },
2208
- }
2209
- return
2210
- }
2211
- }
2212
- // No Standard schema (raw JSONSchema). Still tag the terminal event
2213
- // with messageId so the client processor can snap the right part.
2214
- if (structuredMessageId) {
2215
- yield {
2216
- ...chunk,
2217
- value: {
2218
- ...(chunk.value as Record<string, unknown>),
2219
- messageId: structuredMessageId,
2220
- },
2221
- }
2222
- continue
2223
- }
2224
- yield chunk
2225
- continue
2226
- }
2512
+ for await (const chunk of engine.run()) {
2227
2513
  yield chunk
2228
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
2229
2520
  }
2230
2521
 
2231
2522
  // Re-export adapter types