@ai-sdk/workflow 1.0.67 → 1.0.69

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/workflow",
3
- "version": "1.0.67",
3
+ "version": "1.0.69",
4
4
  "type": "module",
5
5
  "description": "WorkflowAgent for building AI agents with AI SDK",
6
6
  "license": "Apache-2.0",
@@ -29,7 +29,7 @@
29
29
  "ajv": "^8.20.0",
30
30
  "@ai-sdk/provider": "4.0.7",
31
31
  "@ai-sdk/provider-utils": "5.0.27",
32
- "ai": "7.0.66"
32
+ "ai": "7.0.68"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "22.19.19",
@@ -16,6 +16,7 @@ import {
16
16
  type ToolChoice,
17
17
  type ToolSet,
18
18
  } from 'ai';
19
+ import { prepareRetries } from 'ai/internal';
19
20
  import type { ProviderOptions } from './workflow-agent.js';
20
21
  import {
21
22
  resolveSerializableTools,
@@ -109,6 +110,8 @@ export type DoStreamStepResult =
109
110
  finish: StreamFinish | undefined;
110
111
  raw: DoStreamStepRawResult;
111
112
  providerExecutedToolResults: Map<string, ProviderExecutedToolResult>;
113
+ /** Present when the model stream emitted an error part. */
114
+ terminalError?: unknown;
112
115
  };
113
116
 
114
117
  export async function doStreamStep(
@@ -172,42 +175,52 @@ export async function doStreamStep(
172
175
  },
173
176
  };
174
177
 
175
- // streamModelCall handles: prompt standardization, tool preparation,
176
- // model.doStream(), retry logic, and stream part transformation
177
- // (tool call parsing, finish reason mapping, file wrapping).
178
- const modelStream = await streamModelCall({
179
- model,
180
- // streamModelCall expects Prompt (ModelMessage[]) but we pass the
181
- // pre-converted LanguageModelV4Prompt. standardizePrompt inside
182
- // streamModelCall handles both formats.
183
- messages: conversationPrompt as unknown as ModelMessage[],
184
- allowSystemInMessages: true,
185
- tools,
186
- toolChoice: options?.toolChoice,
187
- includeRawChunks: options?.includeRawChunks,
188
- providerOptions: options?.providerOptions,
178
+ // streamModelCall handles prompt standardization, tool preparation,
179
+ // model.doStream(), and stream part transformation. Retries are applied
180
+ // around the model dispatch because streamModelCall itself does not retry.
181
+ const { retry } = prepareRetries({
182
+ maxRetries: options?.maxRetries,
189
183
  abortSignal,
190
- headers: options?.headers,
191
- reasoning: options?.reasoning,
192
- output,
193
- maxOutputTokens: options?.maxOutputTokens,
194
- temperature: options?.temperature,
195
- topP: options?.topP,
196
- topK: options?.topK,
197
- presencePenalty: options?.presencePenalty,
198
- frequencyPenalty: options?.frequencyPenalty,
199
- stopSequences: options?.stopSequences,
200
- seed: options?.seed,
201
- repairToolCall: options?.repairToolCall,
202
- })
203
- .then(result => result.stream)
204
- .catch(error => {
184
+ });
185
+ const modelStream = await (async () => {
186
+ try {
187
+ const { stream } = await retry(() =>
188
+ streamModelCall({
189
+ model,
190
+ // streamModelCall expects Prompt (ModelMessage[]) but we pass the
191
+ // pre-converted LanguageModelV4Prompt. standardizePrompt inside
192
+ // streamModelCall handles both formats.
193
+ messages: conversationPrompt as unknown as ModelMessage[],
194
+ allowSystemInMessages: true,
195
+ tools,
196
+ toolChoice: options?.toolChoice,
197
+ includeRawChunks: options?.includeRawChunks,
198
+ providerOptions: options?.providerOptions,
199
+ abortSignal,
200
+ headers: options?.headers,
201
+ reasoning: options?.reasoning,
202
+ output,
203
+ maxOutputTokens: options?.maxOutputTokens,
204
+ temperature: options?.temperature,
205
+ topP: options?.topP,
206
+ topK: options?.topK,
207
+ presencePenalty: options?.presencePenalty,
208
+ frequencyPenalty: options?.frequencyPenalty,
209
+ stopSequences: options?.stopSequences,
210
+ seed: options?.seed,
211
+ repairToolCall: options?.repairToolCall,
212
+ }),
213
+ );
214
+
215
+ return stream;
216
+ } catch (error) {
205
217
  if (abortSignal?.aborted && isAbortError(error)) {
206
218
  return undefined;
207
219
  }
208
220
 
209
221
  throw error;
210
- });
222
+ }
223
+ })();
211
224
 
212
225
  if (modelStream == null) {
213
226
  return { aborted: true };
@@ -228,6 +241,8 @@ export async function doStreamStep(
228
241
  | { id?: string; timestamp?: Date; modelId?: string }
229
242
  | undefined;
230
243
  let warnings: unknown[] | undefined;
244
+ let terminalError: unknown;
245
+ let hasTerminalError = false;
231
246
 
232
247
  // Acquire writer once before the loop to avoid per-chunk lock overhead
233
248
  const writer = writable?.getWriter();
@@ -305,6 +320,15 @@ export async function doStreamStep(
305
320
  if (writer) {
306
321
  await writer.write(part);
307
322
  }
323
+
324
+ if (part.type === 'error' && !hasTerminalError) {
325
+ // Retain the first model error as step data. Throwing here would make
326
+ // the durable workflow runtime retry the model step and normalize the
327
+ // original value before WorkflowAgent can surface it. Continue
328
+ // consuming so the existing finish reason and usage are preserved.
329
+ terminalError = part.error;
330
+ hasTerminalError = true;
331
+ }
308
332
  }
309
333
  } catch (error) {
310
334
  if (abortSignal?.aborted && isAbortError(error)) {
@@ -333,5 +357,10 @@ export async function doStreamStep(
333
357
  warnings,
334
358
  },
335
359
  providerExecutedToolResults,
360
+ ...(hasTerminalError ? { terminalError } : {}),
336
361
  };
337
362
  }
363
+
364
+ // Model-call retries are handled above so the workflow runtime must not add
365
+ // another retry layer around the durable step.
366
+ doStreamStep.maxRetries = 0;
@@ -66,20 +66,30 @@ class SerializableMockLanguageModel extends MockLanguageModelV4 {
66
66
  usage,
67
67
  },
68
68
  ]
69
- : [
70
- ...prefix,
71
- {
72
- type: 'tool-call',
73
- toolCallId: `call-${responseIndex + 1}`,
74
- toolName: response.toolName,
75
- input: response.input,
76
- },
77
- {
78
- type: 'finish',
79
- finishReason: { unified: 'tool-calls', raw: undefined },
80
- usage,
81
- },
82
- ];
69
+ : response.type === 'tool-call'
70
+ ? [
71
+ ...prefix,
72
+ {
73
+ type: 'tool-call',
74
+ toolCallId: `call-${responseIndex + 1}`,
75
+ toolName: response.toolName,
76
+ input: response.input,
77
+ },
78
+ {
79
+ type: 'finish',
80
+ finishReason: { unified: 'tool-calls', raw: undefined },
81
+ usage,
82
+ },
83
+ ]
84
+ : [
85
+ ...prefix,
86
+ { type: 'error', error: response.error },
87
+ {
88
+ type: 'finish',
89
+ finishReason: { unified: 'error', raw: 'error' },
90
+ usage,
91
+ },
92
+ ];
83
93
 
84
94
  return { stream: convertArrayToReadableStream(streamParts) };
85
95
  },
@@ -2,7 +2,8 @@ import { mockProvider } from './mock-function-wrapper.js';
2
2
 
3
3
  export type MockResponseDescriptor =
4
4
  | { type: 'text'; text: string }
5
- | { type: 'tool-call'; toolName: string; input: string };
5
+ | { type: 'tool-call'; toolName: string; input: string }
6
+ | { type: 'error'; error: unknown };
6
7
 
7
8
  /**
8
9
  * Mock model that returns a fixed text response.
@@ -97,6 +97,11 @@ export interface StreamTextIteratorAbortedValue {
97
97
  messages: LanguageModelV4Prompt;
98
98
  }
99
99
 
100
+ export interface StreamTextIteratorErrorValue {
101
+ error: unknown;
102
+ messages: LanguageModelV4Prompt;
103
+ }
104
+
100
105
  // This runs in the workflow context
101
106
  export async function* streamTextIterator({
102
107
  prompt,
@@ -147,7 +152,9 @@ export async function* streamTextIterator({
147
152
  experimental_sandbox?: SandboxSession;
148
153
  }): AsyncGenerator<
149
154
  StreamTextIteratorYieldValue,
150
- LanguageModelV4Prompt | StreamTextIteratorAbortedValue,
155
+ | LanguageModelV4Prompt
156
+ | StreamTextIteratorAbortedValue
157
+ | StreamTextIteratorErrorValue,
151
158
  LanguageModelV4ToolResultPart[]
152
159
  > {
153
160
  let conversationPrompt = [...prompt]; // Create a mutable copy
@@ -166,6 +173,8 @@ export async function* streamTextIterator({
166
173
  let lastStep: StepResult<any, any> | undefined;
167
174
  let lastStepWasToolCalls = false;
168
175
  let wasAborted = false;
176
+ let terminalError: unknown;
177
+ let hasTerminalError = false;
169
178
 
170
179
  // TODO(#12164): replace this AI-core telemetry bridge with a
171
180
  // WorkflowAgent-specific typed dispatcher. `streamTextIterator` widens
@@ -347,6 +356,11 @@ export async function* streamTextIterator({
347
356
  break;
348
357
  }
349
358
 
359
+ if ('terminalError' in streamStepResult) {
360
+ terminalError = streamStepResult.terminalError;
361
+ hasTerminalError = true;
362
+ }
363
+
350
364
  const { toolCalls, finish, raw, providerExecutedToolResults } =
351
365
  streamStepResult;
352
366
  // Reconstruct the full StepResult outside the step boundary so the
@@ -379,7 +393,12 @@ export async function* streamTextIterator({
379
393
 
380
394
  const finishReason = finish?.finishReason;
381
395
 
382
- if (finishReason === 'tool-calls') {
396
+ if (hasTerminalError) {
397
+ // The error crossed the durable step boundary as data. End the loop
398
+ // without throwing so WorkflowAgent can preserve the existing
399
+ // resolved-result contract and expose the original value.
400
+ done = true;
401
+ } else if (finishReason === 'tool-calls') {
383
402
  lastStepWasToolCalls = true;
384
403
 
385
404
  const textContent = step.content.filter(
@@ -508,6 +527,10 @@ export async function* streamTextIterator({
508
527
  return { aborted: true, messages: conversationPrompt };
509
528
  }
510
529
 
530
+ if (hasTerminalError) {
531
+ return { error: terminalError, messages: conversationPrompt };
532
+ }
533
+
511
534
  return conversationPrompt;
512
535
  }
513
536
 
@@ -4,6 +4,7 @@
4
4
  import { tool } from 'ai';
5
5
  import { WorkflowAgent } from '../workflow-agent.js';
6
6
  import { mockTextModel, mockSequenceModel } from '../providers/mock.js';
7
+ import { retryingModel } from './retrying-model.js';
7
8
  import { createTestSandbox } from './test-sandbox.js';
8
9
  import { FatalError, getWritable } from 'workflow';
9
10
  import { z } from 'zod/v4';
@@ -47,6 +48,46 @@ export async function agentBasicE2e(prompt: string) {
47
48
  };
48
49
  }
49
50
 
51
+ export async function agentModelRetriesE2e() {
52
+ 'use workflow';
53
+ const agent = new WorkflowAgent({
54
+ model: retryingModel(),
55
+ maxRetries: 2,
56
+ });
57
+ const result = await agent.stream({
58
+ messages: [{ role: 'user', content: 'retry the model call' }],
59
+ writable: getWritable(),
60
+ });
61
+ return result.steps.at(-1)?.text;
62
+ }
63
+
64
+ export async function agentStreamErrorE2e() {
65
+ 'use workflow';
66
+ const terminal = {
67
+ type: 'credential',
68
+ code: 'safe-terminal-classification',
69
+ };
70
+ const callbackErrors: unknown[] = [];
71
+ const agent = new WorkflowAgent({
72
+ model: mockSequenceModel([{ type: 'error', error: terminal }]),
73
+ });
74
+
75
+ const result = await agent.stream({
76
+ messages: [{ role: 'user', content: 'trigger the terminal error' }],
77
+ writable: getWritable(),
78
+ onError: async ({ error }) => {
79
+ callbackErrors.push(error);
80
+ },
81
+ });
82
+
83
+ return {
84
+ error: result.error,
85
+ finishReason: result.finishReason,
86
+ stepCount: result.steps.length,
87
+ callbackErrors,
88
+ };
89
+ }
90
+
50
91
  export async function agentToolCallE2e(a: number, b: number) {
51
92
  'use workflow';
52
93
  const agent = new WorkflowAgent({
@@ -0,0 +1,79 @@
1
+ import { APICallError } from '@ai-sdk/provider';
2
+ import {
3
+ WORKFLOW_DESERIALIZE,
4
+ WORKFLOW_SERIALIZE,
5
+ } from '@ai-sdk/provider-utils';
6
+ import { convertArrayToReadableStream, MockLanguageModelV4 } from 'ai/test';
7
+ import { getStepMetadata } from 'workflow';
8
+
9
+ type MockStreamResult = Awaited<ReturnType<MockLanguageModelV4['doStream']>>;
10
+ type MockStreamPart = MockStreamResult extends {
11
+ stream: ReadableStream<infer PART>;
12
+ }
13
+ ? PART
14
+ : never;
15
+
16
+ class SerializableRetryingModel extends MockLanguageModelV4 {
17
+ static [WORKFLOW_SERIALIZE](model: SerializableRetryingModel) {
18
+ return { failuresBeforeSuccess: model.failuresBeforeSuccess };
19
+ }
20
+
21
+ static [WORKFLOW_DESERIALIZE](options: { failuresBeforeSuccess: number }) {
22
+ return new SerializableRetryingModel(options.failuresBeforeSuccess);
23
+ }
24
+
25
+ constructor(readonly failuresBeforeSuccess: number) {
26
+ let modelAttempts = 0;
27
+
28
+ super({
29
+ provider: 'workflow-retry-test',
30
+ modelId: 'workflow-retry-test-model',
31
+ doStream: async () => {
32
+ modelAttempts++;
33
+
34
+ if (modelAttempts <= failuresBeforeSuccess) {
35
+ throw new APICallError({
36
+ message: `model call failed on attempt ${modelAttempts}`,
37
+ url: 'https://example.com/model',
38
+ requestBodyValues: {},
39
+ statusCode: 500,
40
+ responseHeaders: { 'retry-after-ms': '0' },
41
+ });
42
+ }
43
+
44
+ const text = `model-attempts=${modelAttempts};step-attempt=${getStepMetadata().attempt}`;
45
+ const streamParts: MockStreamPart[] = [
46
+ { type: 'stream-start', warnings: [] },
47
+ { type: 'text-start', id: '1' },
48
+ { type: 'text-delta', id: '1', delta: text },
49
+ { type: 'text-end', id: '1' },
50
+ {
51
+ type: 'finish',
52
+ finishReason: { unified: 'stop', raw: 'stop' },
53
+ usage: {
54
+ inputTokens: {
55
+ total: 1,
56
+ noCache: 1,
57
+ cacheRead: undefined,
58
+ cacheWrite: undefined,
59
+ },
60
+ outputTokens: {
61
+ total: 1,
62
+ text: 1,
63
+ reasoning: undefined,
64
+ },
65
+ },
66
+ },
67
+ ];
68
+
69
+ return { stream: convertArrayToReadableStream(streamParts) };
70
+ },
71
+ });
72
+ }
73
+ }
74
+
75
+ // Keep construction outside the workflow module to avoid the SWC closure
76
+ // transformation issue tracked in https://github.com/vercel/workflow/issues/1365.
77
+ export function retryingModel(): MockLanguageModelV4 {
78
+ return new SerializableRetryingModel(2);
79
+ }
@@ -212,8 +212,7 @@ export interface GenerationSettings {
212
212
  seed?: number;
213
213
 
214
214
  /**
215
- * Maximum number of retries. Set to 0 to disable retries.
216
- * Note: In workflow context, retries are typically handled by the workflow step mechanism.
215
+ * Maximum number of retries for retryable model call failures. Set to 0 to disable retries.
217
216
  * @default 2
218
217
  */
219
218
  maxRetries?: number;
@@ -1191,6 +1190,15 @@ export interface WorkflowAgentStreamResult<
1191
1190
  */
1192
1191
  finishReason: FinishReason;
1193
1192
 
1193
+ /**
1194
+ * The original value from a model stream error part.
1195
+ *
1196
+ * This property is present when the model emitted an error part, including
1197
+ * when the supplied value is `undefined`. Check with `'error' in result` to
1198
+ * distinguish that case from a result without a model stream error.
1199
+ */
1200
+ error?: unknown;
1201
+
1194
1202
  /**
1195
1203
  * The total token usage across all steps.
1196
1204
  */
@@ -2146,7 +2154,6 @@ export class WorkflowAgent<
2146
2154
  stopConditions: effectiveStopWhenFromPrepare,
2147
2155
  onStepEnd: mergedOnStepEnd as any,
2148
2156
  onStepStart: mergedOnStepStart as any,
2149
- onError: options.onError,
2150
2157
  prepareStep: (options.prepareStep ??
2151
2158
  (this.prepareStep as
2152
2159
  | PrepareStepCallback<ToolSet, TRuntimeContext>
@@ -2168,7 +2175,10 @@ export class WorkflowAgent<
2168
2175
  // Track the final conversation messages from the iterator
2169
2176
  let finalMessages: LanguageModelV4Prompt | undefined;
2170
2177
  let encounteredError: unknown;
2178
+ let hasEncounteredError = false;
2171
2179
  let wasAborted = false;
2180
+ let terminalError: unknown;
2181
+ let hasTerminalError = false;
2172
2182
 
2173
2183
  try {
2174
2184
  let result = await iterator.next();
@@ -2526,6 +2536,10 @@ export class WorkflowAgent<
2526
2536
  if (result.done) {
2527
2537
  if (Array.isArray(result.value)) {
2528
2538
  finalMessages = result.value;
2539
+ } else if ('error' in result.value) {
2540
+ finalMessages = result.value.messages;
2541
+ terminalError = result.value.error;
2542
+ hasTerminalError = true;
2529
2543
  } else {
2530
2544
  finalMessages = result.value.messages;
2531
2545
  wasAborted = true;
@@ -2536,6 +2550,7 @@ export class WorkflowAgent<
2536
2550
  }
2537
2551
  } catch (error) {
2538
2552
  encounteredError = error;
2553
+ hasEncounteredError = true;
2539
2554
  // Check if this is an abort error
2540
2555
  if (isAbortError(error)) {
2541
2556
  wasAborted = true;
@@ -2550,6 +2565,13 @@ export class WorkflowAgent<
2550
2565
  // Don't throw yet - we want to call onEnd first
2551
2566
  }
2552
2567
 
2568
+ if (hasTerminalError) {
2569
+ if (options.onError) {
2570
+ await options.onError({ error: terminalError });
2571
+ }
2572
+ await telemetryDispatcher.onError?.(terminalError);
2573
+ }
2574
+
2553
2575
  // Use the final messages from the iterator, or fall back to standardized messages
2554
2576
  const messages = (finalMessages ??
2555
2577
  prompt.messages) as unknown as ModelMessage[];
@@ -2573,8 +2595,9 @@ export class WorkflowAgent<
2573
2595
  } catch (parseError) {
2574
2596
  // If there's already an error, don't override it
2575
2597
  // If not, set this as the error
2576
- if (!encounteredError) {
2598
+ if (!hasEncounteredError) {
2577
2599
  encounteredError = parseError;
2600
+ hasEncounteredError = true;
2578
2601
  }
2579
2602
  }
2580
2603
  }
@@ -2610,7 +2633,7 @@ export class WorkflowAgent<
2610
2633
  }
2611
2634
 
2612
2635
  // Re-throw any error that occurred
2613
- if (encounteredError) {
2636
+ if (hasEncounteredError) {
2614
2637
  // Close the stream before throwing
2615
2638
  if (options.writable) {
2616
2639
  const sendFinish = options.sendFinish ?? true;
@@ -2639,6 +2662,7 @@ export class WorkflowAgent<
2639
2662
  finishReason,
2640
2663
  totalUsage,
2641
2664
  output: experimentalOutput,
2665
+ ...(hasTerminalError ? { error: terminalError } : {}),
2642
2666
  };
2643
2667
  }
2644
2668
  }