@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.
- package/dist/esm/activities/chat/index.d.ts +1 -1
- package/dist/esm/activities/chat/index.js +357 -258
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/chat/messages.js.map +1 -1
- package/dist/esm/activities/chat/middleware/compose.d.ts +10 -1
- package/dist/esm/activities/chat/middleware/compose.js +55 -0
- package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
- package/dist/esm/activities/chat/middleware/index.d.ts +1 -1
- package/dist/esm/activities/chat/middleware/types.d.ts +35 -3
- package/dist/esm/activities/chat/stream/processor.js.map +1 -1
- package/dist/esm/activities/chat/tools/schema-converter.d.ts +12 -1
- package/dist/esm/activities/chat/tools/schema-converter.js +13 -4
- package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
- package/dist/esm/activities/generateImage/index.js.map +1 -1
- package/dist/esm/extend-adapter.js.map +1 -1
- package/dist/esm/index.d.ts +2 -2
- package/dist/esm/index.js +2 -1
- package/dist/esm/middlewares/content-guard.js.map +1 -1
- package/dist/esm/middlewares/otel.js +1 -4
- package/dist/esm/middlewares/otel.js.map +1 -1
- package/dist/esm/strip-to-spec-middleware.js.map +1 -1
- package/dist/esm/utilities/ag-ui-wire.js +4 -1
- package/dist/esm/utilities/ag-ui-wire.js.map +1 -1
- package/package.json +3 -3
- package/skills/ai-core/middleware/SKILL.md +124 -18
- package/skills/ai-core/structured-outputs/SKILL.md +13 -0
- package/src/activities/chat/index.ts +653 -370
- package/src/activities/chat/messages.ts +1 -1
- package/src/activities/chat/middleware/compose.ts +65 -5
- package/src/activities/chat/middleware/index.ts +1 -0
- package/src/activities/chat/middleware/types.ts +53 -2
- package/src/activities/chat/stream/processor.ts +2 -2
- package/src/activities/chat/tools/schema-converter.ts +22 -7
- package/src/activities/generateImage/index.ts +3 -3
- package/src/extend-adapter.ts +1 -1
- package/src/index.ts +5 -1
- package/src/middlewares/content-guard.ts +1 -1
- package/src/middlewares/otel.ts +7 -10
- package/src/strip-to-spec-middleware.ts +1 -4
- 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
|
-
|
|
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()
|
|
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'
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
this.
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
522
|
-
|
|
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
|
-
//
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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 === '
|
|
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
|
-
//
|
|
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
|
|
2264
|
+
// Consume the stream — chunks pipe through middleware but are not yielded externally
|
|
1754
2265
|
for await (const _chunk of engine.run()) {
|
|
1755
|
-
//
|
|
2266
|
+
// intentionally empty
|
|
1756
2267
|
}
|
|
1757
2268
|
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
throw
|
|
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
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1894
|
-
*
|
|
1895
|
-
*
|
|
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
|
-
*
|
|
1900
|
-
*
|
|
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).
|
|
1965
|
-
|
|
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
|
-
|
|
2035
|
-
model,
|
|
2036
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|