@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.
- package/dist/esm/activities/chat/adapter.js +3 -1
- package/dist/esm/activities/chat/adapter.js.map +1 -1
- package/dist/esm/activities/chat/index.d.ts +4 -4
- package/dist/esm/activities/chat/index.js +403 -276
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/chat/messages.js +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 +57 -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 +40 -8
- package/dist/esm/activities/chat/stream/processor.d.ts +8 -8
- package/dist/esm/activities/chat/stream/processor.js +29 -21
- package/dist/esm/activities/chat/stream/processor.js.map +1 -1
- package/dist/esm/activities/chat/stream/strategies.d.ts +3 -3
- package/dist/esm/activities/chat/stream/strategies.js +4 -4
- package/dist/esm/activities/chat/stream/strategies.js.map +1 -1
- package/dist/esm/activities/chat/tools/lazy-tool-manager.js +5 -0
- package/dist/esm/activities/chat/tools/lazy-tool-manager.js.map +1 -1
- package/dist/esm/activities/chat/tools/schema-converter.d.ts +13 -2
- package/dist/esm/activities/chat/tools/schema-converter.js +47 -37
- package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
- package/dist/esm/activities/chat/tools/tool-calls.d.ts +2 -2
- package/dist/esm/activities/chat/tools/tool-calls.js +17 -9
- package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
- package/dist/esm/activities/chat/tools/tool-definition.js +1 -1
- package/dist/esm/activities/chat/tools/tool-definition.js.map +1 -1
- package/dist/esm/activities/generateAudio/adapter.js +3 -1
- package/dist/esm/activities/generateAudio/adapter.js.map +1 -1
- package/dist/esm/activities/generateAudio/index.d.ts +5 -1
- package/dist/esm/activities/generateAudio/index.js.map +1 -1
- package/dist/esm/activities/generateImage/adapter.js +3 -1
- package/dist/esm/activities/generateImage/adapter.js.map +1 -1
- package/dist/esm/activities/generateImage/index.js +5 -0
- package/dist/esm/activities/generateImage/index.js.map +1 -1
- package/dist/esm/activities/generateSpeech/adapter.js +3 -1
- package/dist/esm/activities/generateSpeech/adapter.js.map +1 -1
- package/dist/esm/activities/generateTranscription/adapter.js +3 -1
- package/dist/esm/activities/generateTranscription/adapter.js.map +1 -1
- package/dist/esm/activities/generateVideo/adapter.js +3 -1
- package/dist/esm/activities/generateVideo/adapter.js.map +1 -1
- package/dist/esm/activities/stream-generation-result.js +6 -2
- package/dist/esm/activities/stream-generation-result.js.map +1 -1
- package/dist/esm/activities/summarize/adapter.js +3 -1
- package/dist/esm/activities/summarize/adapter.js.map +1 -1
- package/dist/esm/activities/summarize/chat-stream-summarize.d.ts +1 -1
- package/dist/esm/activities/summarize/chat-stream-summarize.js +5 -0
- package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -1
- package/dist/esm/activities/summarize/index.js.map +1 -1
- package/dist/esm/extend-adapter.js.map +1 -1
- package/dist/esm/index.d.ts +3 -2
- package/dist/esm/index.js +4 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/logger/internal-logger.js +2 -0
- package/dist/esm/logger/internal-logger.js.map +1 -1
- package/dist/esm/middlewares/content-guard.js +5 -4
- package/dist/esm/middlewares/content-guard.js.map +1 -1
- package/dist/esm/middlewares/otel.js +25 -18
- package/dist/esm/middlewares/otel.js.map +1 -1
- package/dist/esm/realtime/index.d.ts +1 -1
- package/dist/esm/realtime/index.js.map +1 -1
- package/dist/esm/strip-to-spec-middleware.js.map +1 -1
- package/dist/esm/tools/provider-tool.d.ts +9 -0
- package/dist/esm/tools/provider-tool.js +7 -0
- package/dist/esm/tools/provider-tool.js.map +1 -0
- package/dist/esm/types.d.ts +6 -6
- package/dist/esm/utilities/ag-ui-wire.js +4 -1
- package/dist/esm/utilities/ag-ui-wire.js.map +1 -1
- package/dist/esm/utilities/chat-params.js.map +1 -1
- package/package.json +2 -2
- 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 +685 -394
- package/src/activities/chat/messages.ts +3 -2
- 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 +64 -11
- package/src/activities/chat/stream/processor.ts +21 -21
- package/src/activities/chat/stream/strategies.ts +3 -3
- package/src/activities/chat/tools/schema-converter.ts +98 -58
- package/src/activities/chat/tools/tool-calls.ts +18 -11
- package/src/activities/chat/tools/tool-definition.ts +6 -1
- package/src/activities/generateAudio/index.ts +5 -4
- package/src/activities/generateImage/index.ts +8 -3
- package/src/activities/stream-generation-result.ts +11 -2
- package/src/activities/summarize/chat-stream-summarize.ts +5 -2
- package/src/activities/summarize/index.ts +2 -2
- package/src/extend-adapter.ts +1 -1
- package/src/index.ts +6 -1
- package/src/middlewares/content-guard.ts +12 -4
- package/src/middlewares/otel.ts +32 -24
- package/src/realtime/index.ts +1 -1
- package/src/strip-to-spec-middleware.ts +1 -4
- package/src/tools/provider-tool.ts +14 -0
- package/src/types.ts +12 -8
- package/src/utilities/ag-ui-wire.ts +4 -3
- 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
|
-
|
|
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?:
|
|
99
|
-
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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?:
|
|
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?:
|
|
129
|
-
|
|
|
130
|
-
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
this.
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
515
|
-
|
|
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
|
-
//
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
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 === '
|
|
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
|
-
//
|
|
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
|
|
2264
|
+
// Consume the stream — chunks pipe through middleware but are not yielded externally
|
|
1746
2265
|
for await (const _chunk of engine.run()) {
|
|
1747
|
-
//
|
|
2266
|
+
// intentionally empty
|
|
1748
2267
|
}
|
|
1749
2268
|
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
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
|
|
1765
2284
|
}
|
|
1766
2285
|
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1886
|
-
*
|
|
1887
|
-
*
|
|
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
|
-
*
|
|
1892
|
-
*
|
|
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).
|
|
1957
|
-
|
|
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
|
-
|
|
2027
|
-
model,
|
|
2028
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|