@ai-sdk/workflow 2.0.19 → 2.0.21

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.
@@ -6,6 +6,7 @@ import type {
6
6
  } from '@ai-sdk/provider';
7
7
  import type { Context } from '@ai-sdk/provider-utils';
8
8
  import {
9
+ DefaultGeneratedFile,
9
10
  experimental_filterActiveTools as filterActiveTools,
10
11
  type ActiveTools,
11
12
  type Experimental_SandboxSession as SandboxSession,
@@ -27,7 +28,9 @@ import {
27
28
  type ParsedToolCall,
28
29
  type ProviderExecutedToolResult,
29
30
  type StreamFinish,
31
+ type ToolInputLifecycleEvent,
30
32
  } from './do-stream-step.js';
33
+ import { resolveToolContext } from './resolve-tool-context.js';
31
34
  import { serializeToolSet } from './serializable-schema.js';
32
35
  import type {
33
36
  GenerationSettings,
@@ -337,6 +340,7 @@ export async function* streamTextIterator({
337
340
  headers: currentGenerationSettings.headers,
338
341
  } as never);
339
342
 
343
+ const stepInputMessages = conversationPrompt as unknown as ModelMessage[];
340
344
  const streamStepResult = await doStreamStep(
341
345
  conversationPrompt,
342
346
  currentModel,
@@ -362,8 +366,22 @@ export async function* streamTextIterator({
362
366
  hasTerminalError = true;
363
367
  }
364
368
 
365
- const { toolCalls, finish, raw, providerExecutedToolResults } =
366
- streamStepResult;
369
+ const {
370
+ toolCalls,
371
+ finish,
372
+ raw,
373
+ providerExecutedToolResults,
374
+ toolInputLifecycleEvents,
375
+ } = streamStepResult;
376
+ await invokeToolInputLifecycleCallbacks({
377
+ events: toolInputLifecycleEvents ?? [],
378
+ toolCalls,
379
+ tools: effectiveTools,
380
+ messages: stepInputMessages,
381
+ abortSignal: currentGenerationSettings.abortSignal,
382
+ toolsContext: currentToolsContext,
383
+ experimental_sandbox: stepSandbox,
384
+ });
367
385
  // Reconstruct the full StepResult outside the step boundary so the
368
386
  // durable event log doesn't carry StepResult's redundant copies (or the
369
387
  // per-chunk snapshot the step used to return).
@@ -402,11 +420,16 @@ export async function* streamTextIterator({
402
420
  } else if (finishReason === 'tool-calls') {
403
421
  lastStepWasToolCalls = true;
404
422
 
405
- const textContent = step.content.filter(
406
- item => item.type === 'text',
407
- ) as Array<{ type: 'text'; text: string }>;
423
+ const assistantContent = getAssistantMessageContent(step);
424
+ const includedToolCallIds = new Set(
425
+ assistantContent.flatMap(part =>
426
+ part.type === 'tool-call' ? [part.toolCallId] : [],
427
+ ),
428
+ );
408
429
 
409
- // Add assistant message with text and tool calls to the conversation
430
+ // Add assistant message content in provider emission order. Invalid
431
+ // tool calls are not part of StepResult.content, so retain the previous
432
+ // behavior of appending them to the prompt.
410
433
  // Note: providerMetadata from the tool call is mapped to providerOptions
411
434
  // in the prompt format, following the AI SDK convention. This is critical
412
435
  // for providers like Gemini that require thoughtSignature to be preserved
@@ -414,24 +437,10 @@ export async function* streamTextIterator({
414
437
  conversationPrompt.push({
415
438
  role: 'assistant',
416
439
  content: [
417
- ...textContent,
418
- ...toolCalls.map(toolCall => {
419
- const sanitizedMetadata = sanitizeProviderMetadataForToolCall(
420
- toolCall.providerMetadata,
421
- );
422
- return {
423
- type: 'tool-call' as const,
424
- toolCallId: toolCall.toolCallId,
425
- toolName: toolCall.toolName,
426
- input: toolCall.input,
427
- ...(sanitizedMetadata != null
428
- ? {
429
- providerOptions:
430
- sanitizedMetadata as SharedV4ProviderOptions,
431
- }
432
- : {}),
433
- };
434
- }),
440
+ ...assistantContent,
441
+ ...toolCalls
442
+ .filter(toolCall => !includedToolCallIds.has(toolCall.toolCallId))
443
+ .map(toAssistantToolCallContent),
435
444
  ],
436
445
  });
437
446
 
@@ -462,15 +471,13 @@ export async function* streamTextIterator({
462
471
  }
463
472
  }
464
473
  } else if (finishReason === 'stop') {
465
- // Add assistant message with text content to the conversation
466
- const textContent = step.content.filter(
467
- item => item.type === 'text',
468
- ) as Array<{ type: 'text'; text: string }>;
474
+ // Add assistant response content to the conversation
475
+ const assistantContent = getAssistantMessageContent(step);
469
476
 
470
- if (textContent.length > 0) {
477
+ if (assistantContent.length > 0) {
471
478
  conversationPrompt.push({
472
479
  role: 'assistant',
473
- content: textContent,
480
+ content: assistantContent,
474
481
  });
475
482
  }
476
483
 
@@ -535,6 +542,89 @@ export async function* streamTextIterator({
535
542
  return conversationPrompt;
536
543
  }
537
544
 
545
+ async function invokeToolInputLifecycleCallbacks({
546
+ events,
547
+ toolCalls,
548
+ tools,
549
+ messages,
550
+ abortSignal,
551
+ toolsContext,
552
+ experimental_sandbox,
553
+ }: {
554
+ events: ToolInputLifecycleEvent[];
555
+ toolCalls: ParsedToolCall[];
556
+ tools: ToolSet;
557
+ messages: ModelMessage[];
558
+ abortSignal?: AbortSignal;
559
+ toolsContext: Record<string, Context | undefined>;
560
+ experimental_sandbox?: SandboxSession;
561
+ }) {
562
+ const toolNamesByCallId = new Map<string, string>();
563
+ const toolCallsById = new Map(
564
+ toolCalls.map(toolCall => [toolCall.toolCallId, toolCall]),
565
+ );
566
+ const resolvedContexts = new Map<string, Promise<unknown>>();
567
+
568
+ for (const event of events) {
569
+ const [type, toolCallId, value] = event;
570
+ if (type === 'start') {
571
+ toolNamesByCallId.set(toolCallId, value);
572
+ }
573
+
574
+ const toolName =
575
+ type === 'start' ? value : toolNamesByCallId.get(toolCallId);
576
+ if (toolName == null) {
577
+ continue;
578
+ }
579
+
580
+ const tool = tools[toolName];
581
+ if (tool == null) {
582
+ continue;
583
+ }
584
+
585
+ let resolvedContext = resolvedContexts.get(toolName);
586
+ if (resolvedContext == null) {
587
+ resolvedContext = resolveToolContext({
588
+ toolName,
589
+ tool,
590
+ toolsContext,
591
+ });
592
+ resolvedContexts.set(toolName, resolvedContext);
593
+ }
594
+
595
+ const options = {
596
+ toolCallId,
597
+ messages,
598
+ abortSignal,
599
+ context: await resolvedContext,
600
+ experimental_sandbox,
601
+ };
602
+
603
+ switch (type) {
604
+ case 'start':
605
+ await tool.onInputStart?.(options);
606
+ break;
607
+ case 'delta':
608
+ await tool.onInputDelta?.({
609
+ ...options,
610
+ inputTextDelta: value,
611
+ });
612
+ break;
613
+ case 'available': {
614
+ const toolCall = toolCallsById.get(toolCallId);
615
+ if (toolCall == null) {
616
+ break;
617
+ }
618
+ await tool.onInputAvailable?.({
619
+ ...options,
620
+ input: toolCall.input,
621
+ });
622
+ break;
623
+ }
624
+ }
625
+ }
626
+ }
627
+
538
628
  function getModelInfo(model: LanguageModel): {
539
629
  provider: string;
540
630
  modelId: string;
@@ -554,9 +644,10 @@ function normalizeStepForTelemetry(step: StepResult<any, any>) {
554
644
  /**
555
645
  * Reconstruct a full `StepResult` from the minimal aggregates returned by
556
646
  * `doStreamStep`. Runs outside the step boundary so StepResult's redundant
557
- * fields (duplicate tool-call lists, `content`, `reasoningText`, the
558
- * always-empty `*ToolResults` arrays) and the per-chunk snapshot don't cross
559
- * it. The shape matches what the AI SDK's `streamText` exposes to callers.
647
+ * fields (duplicate tool-call lists, `text`, `files`, `sources`, and
648
+ * `reasoningText`) and the per-chunk snapshot don't cross it. Tool-result
649
+ * arrays are initialized here and populated after execution. The shape matches
650
+ * what the AI SDK's `streamText` exposes to callers.
560
651
  */
561
652
  function buildStepResult(
562
653
  raw: DoStreamStepRawResult,
@@ -568,18 +659,89 @@ function buildStepResult(
568
659
  toolsContext: Record<string, Context | undefined>;
569
660
  },
570
661
  ): StepResult<ToolSet, any> {
571
- const { text, reasoning: reasoningParts, responseMetadata, warnings } = raw;
662
+ const {
663
+ content: rawContent,
664
+ reasoning: reasoningParts,
665
+ responseMetadata,
666
+ warnings,
667
+ } = raw;
572
668
  const reasoningText = reasoningParts.map(r => r.text).join('') || undefined;
573
-
574
- const validToolCalls = toolCalls
575
- .filter(tc => !tc.invalid)
576
- .map(tc => ({
577
- type: 'tool-call' as const,
578
- toolCallId: tc.toolCallId,
579
- toolName: tc.toolName,
580
- input: tc.input,
581
- ...(tc.dynamic ? { dynamic: true as const } : {}),
582
- }));
669
+ const validToolCallsByIndex = new Map(
670
+ toolCalls.flatMap((tc, index) =>
671
+ tc.invalid
672
+ ? []
673
+ : [
674
+ [
675
+ index,
676
+ {
677
+ type: 'tool-call' as const,
678
+ toolCallId: tc.toolCallId,
679
+ toolName: tc.toolName,
680
+ input: tc.input,
681
+ ...(tc.providerExecuted != null
682
+ ? { providerExecuted: tc.providerExecuted }
683
+ : {}),
684
+ ...(tc.title != null ? { title: tc.title } : {}),
685
+ ...(tc.toolMetadata != null
686
+ ? { toolMetadata: tc.toolMetadata }
687
+ : {}),
688
+ ...(tc.dynamic ? { dynamic: true as const } : {}),
689
+ ...(tc.providerExecuted ? { providerExecuted: true } : {}),
690
+ ...(tc.providerMetadata != null
691
+ ? { providerMetadata: tc.providerMetadata }
692
+ : {}),
693
+ },
694
+ ] as const,
695
+ ],
696
+ ),
697
+ );
698
+ const validToolCalls = [...validToolCallsByIndex.values()];
699
+ const content: StepResult<ToolSet, any>['content'] = [];
700
+ const files: StepResult<ToolSet, any>['files'] = [];
701
+ const sources: StepResult<ToolSet, any>['sources'] = [];
702
+ let text = '';
703
+
704
+ for (const part of rawContent) {
705
+ switch (part.type) {
706
+ case 'text':
707
+ text += part.text;
708
+ content.push({
709
+ type: 'text',
710
+ text: part.text,
711
+ ...(part.providerMetadata != null
712
+ ? { providerMetadata: part.providerMetadata }
713
+ : {}),
714
+ });
715
+ break;
716
+ case 'file': {
717
+ const file = new DefaultGeneratedFile({
718
+ data: part.data,
719
+ mediaType: part.mediaType,
720
+ providerMetadata: part.providerMetadata,
721
+ });
722
+ files.push(file);
723
+ content.push({
724
+ type: 'file',
725
+ file,
726
+ ...(part.providerMetadata != null
727
+ ? { providerMetadata: part.providerMetadata }
728
+ : {}),
729
+ });
730
+ break;
731
+ }
732
+ case 'source':
733
+ sources.push(part);
734
+ content.push(part);
735
+ break;
736
+ case 'tool-call': {
737
+ const toolCall = validToolCallsByIndex.get(part.toolCallIndex);
738
+ if (toolCall != null) {
739
+ content.push(toolCall);
740
+ }
741
+ break;
742
+ }
743
+ }
744
+ }
583
745
 
584
746
  return {
585
747
  callId: 'workflow-agent',
@@ -592,20 +754,17 @@ function buildStepResult(
592
754
  metadata: undefined,
593
755
  runtimeContext: opts.runtimeContext ?? {},
594
756
  toolsContext: opts.toolsContext ?? {},
595
- content: [
596
- ...(text ? [{ type: 'text' as const, text }] : []),
597
- ...validToolCalls,
598
- ],
757
+ content,
599
758
  text,
600
759
  reasoning: reasoningParts.map(r => ({
601
760
  type: 'reasoning' as const,
602
761
  text: r.text,
603
762
  })),
604
763
  reasoningText,
605
- files: [],
606
- sources: [],
764
+ files,
765
+ sources,
607
766
  toolCalls: validToolCalls,
608
- staticToolCalls: [],
767
+ staticToolCalls: validToolCalls.filter(tc => tc.dynamic !== true),
609
768
  dynamicToolCalls: validToolCalls.filter(tc => tc.dynamic),
610
769
  toolResults: [],
611
770
  staticToolResults: [],
@@ -653,6 +812,69 @@ function buildStepResult(
653
812
  } as StepResult<ToolSet, any>;
654
813
  }
655
814
 
815
+ function getAssistantMessageContent(
816
+ step: StepResult<any, any>,
817
+ ): Extract<LanguageModelV4Prompt[number], { role: 'assistant' }>['content'] {
818
+ const content: Extract<
819
+ LanguageModelV4Prompt[number],
820
+ { role: 'assistant' }
821
+ >['content'] = [];
822
+
823
+ for (const part of step.content) {
824
+ switch (part.type) {
825
+ case 'text':
826
+ if (part.text.length > 0) {
827
+ content.push({ type: 'text', text: part.text });
828
+ }
829
+ break;
830
+ case 'file':
831
+ content.push({
832
+ type: 'file',
833
+ data: { type: 'data', data: part.file.base64 },
834
+ mediaType: part.file.mediaType,
835
+ ...(part.providerMetadata != null
836
+ ? {
837
+ providerOptions:
838
+ part.providerMetadata as SharedV4ProviderOptions,
839
+ }
840
+ : {}),
841
+ });
842
+ break;
843
+ case 'tool-call':
844
+ content.push(toAssistantToolCallContent(part));
845
+ break;
846
+ }
847
+ }
848
+
849
+ return content;
850
+ }
851
+
852
+ function toAssistantToolCallContent(toolCall: {
853
+ toolCallId: string;
854
+ toolName: string;
855
+ input: unknown;
856
+ providerExecuted?: boolean;
857
+ providerMetadata?: unknown;
858
+ }) {
859
+ const sanitizedMetadata = sanitizeProviderMetadataForToolCall(
860
+ toolCall.providerMetadata,
861
+ );
862
+ return {
863
+ type: 'tool-call' as const,
864
+ toolCallId: toolCall.toolCallId,
865
+ toolName: toolCall.toolName,
866
+ input: toolCall.input,
867
+ ...(toolCall.providerExecuted != null
868
+ ? { providerExecuted: toolCall.providerExecuted }
869
+ : {}),
870
+ ...(sanitizedMetadata != null
871
+ ? {
872
+ providerOptions: sanitizedMetadata as SharedV4ProviderOptions,
873
+ }
874
+ : {}),
875
+ };
876
+ }
877
+
656
878
  /**
657
879
  * Strip OpenAI's itemId from providerMetadata (requires reasoning items we don't preserve).
658
880
  * Preserves all other provider metadata (e.g., Gemini's thoughtSignature).