@ai-sdk/workflow 1.0.66 → 1.0.68

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.66",
3
+ "version": "1.0.68",
4
4
  "type": "module",
5
5
  "description": "WorkflowAgent for building AI agents with AI SDK",
6
6
  "license": "Apache-2.0",
@@ -27,9 +27,9 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "ajv": "^8.20.0",
30
- "@ai-sdk/provider": "4.0.7",
31
30
  "@ai-sdk/provider-utils": "5.0.27",
32
- "ai": "7.0.66"
31
+ "@ai-sdk/provider": "4.0.7",
32
+ "ai": "7.0.67"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "22.19.19",
@@ -2,6 +2,7 @@ import type {
2
2
  LanguageModelV4CallOptions,
3
3
  LanguageModelV4Prompt,
4
4
  } from '@ai-sdk/provider';
5
+ import { isAbortError } from '@ai-sdk/provider-utils';
5
6
  import {
6
7
  experimental_streamLanguageModelCall as streamModelCall,
7
8
  gateway,
@@ -15,6 +16,7 @@ import {
15
16
  type ToolChoice,
16
17
  type ToolSet,
17
18
  } from 'ai';
19
+ import { prepareRetries } from 'ai/internal';
18
20
  import type { ProviderOptions } from './workflow-agent.js';
19
21
  import {
20
22
  resolveSerializableTools,
@@ -48,6 +50,7 @@ export interface DoStreamStepOptions {
48
50
  seed?: number;
49
51
  maxRetries?: number;
50
52
  abortSignal?: AbortSignal;
53
+ timeoutAt?: number;
51
54
  headers?: Record<string, string | undefined>;
52
55
  reasoning?: LanguageModelV4CallOptions['reasoning'];
53
56
  providerOptions?: ProviderOptions;
@@ -99,20 +102,44 @@ export interface DoStreamStepRawResult {
99
102
  warnings?: unknown[];
100
103
  }
101
104
 
105
+ export type DoStreamStepResult =
106
+ | { aborted: true }
107
+ | {
108
+ aborted?: false;
109
+ toolCalls: ParsedToolCall[];
110
+ finish: StreamFinish | undefined;
111
+ raw: DoStreamStepRawResult;
112
+ providerExecutedToolResults: Map<string, ProviderExecutedToolResult>;
113
+ /** Present when the model stream emitted an error part. */
114
+ terminalError?: unknown;
115
+ };
116
+
102
117
  export async function doStreamStep(
103
118
  conversationPrompt: LanguageModelV4Prompt,
104
119
  modelInit: LanguageModel,
105
120
  writable?: WritableStream<ModelCallStreamPart<ToolSet>>,
106
121
  serializedTools?: Record<string, SerializableToolDef>,
107
122
  options?: DoStreamStepOptions,
108
- ): Promise<{
109
- toolCalls: ParsedToolCall[];
110
- finish: StreamFinish | undefined;
111
- raw: DoStreamStepRawResult;
112
- providerExecutedToolResults: Map<string, ProviderExecutedToolResult>;
113
- }> {
123
+ ): Promise<DoStreamStepResult> {
114
124
  'use step';
115
125
 
126
+ const timeout =
127
+ options?.timeoutAt == null ? undefined : options.timeoutAt - Date.now();
128
+
129
+ // AbortSignal.timeout(0) does not abort synchronously. Check the deadline
130
+ // explicitly so an expired call never reaches the model, including when a
131
+ // durable step is retried after the original timeout has elapsed.
132
+ if (options?.abortSignal?.aborted || (timeout != null && timeout <= 0)) {
133
+ return { aborted: true };
134
+ }
135
+
136
+ const abortSignal =
137
+ timeout == null
138
+ ? options?.abortSignal
139
+ : options?.abortSignal == null
140
+ ? AbortSignal.timeout(timeout)
141
+ : AbortSignal.any([options.abortSignal, AbortSignal.timeout(timeout)]);
142
+
116
143
  // Resolve model inside step (must happen here for serialization boundary)
117
144
  const model: LanguageModel =
118
145
  typeof modelInit === 'string'
@@ -148,34 +175,56 @@ export async function doStreamStep(
148
175
  },
149
176
  };
150
177
 
151
- // streamModelCall handles: prompt standardization, tool preparation,
152
- // model.doStream(), retry logic, and stream part transformation
153
- // (tool call parsing, finish reason mapping, file wrapping).
154
- const { stream: modelStream } = await streamModelCall({
155
- model,
156
- // streamModelCall expects Prompt (ModelMessage[]) but we pass the
157
- // pre-converted LanguageModelV4Prompt. standardizePrompt inside
158
- // streamModelCall handles both formats.
159
- messages: conversationPrompt as unknown as ModelMessage[],
160
- allowSystemInMessages: true,
161
- tools,
162
- toolChoice: options?.toolChoice,
163
- includeRawChunks: options?.includeRawChunks,
164
- providerOptions: options?.providerOptions,
165
- abortSignal: options?.abortSignal,
166
- headers: options?.headers,
167
- reasoning: options?.reasoning,
168
- output,
169
- maxOutputTokens: options?.maxOutputTokens,
170
- temperature: options?.temperature,
171
- topP: options?.topP,
172
- topK: options?.topK,
173
- presencePenalty: options?.presencePenalty,
174
- frequencyPenalty: options?.frequencyPenalty,
175
- stopSequences: options?.stopSequences,
176
- seed: options?.seed,
177
- repairToolCall: options?.repairToolCall,
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,
183
+ abortSignal,
178
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) {
217
+ if (abortSignal?.aborted && isAbortError(error)) {
218
+ return undefined;
219
+ }
220
+
221
+ throw error;
222
+ }
223
+ })();
224
+
225
+ if (modelStream == null) {
226
+ return { aborted: true };
227
+ }
179
228
 
180
229
  // Consume the stream: capture data and write to writable in real-time
181
230
  const toolCalls: ParsedToolCall[] = [];
@@ -192,6 +241,8 @@ export async function doStreamStep(
192
241
  | { id?: string; timestamp?: Date; modelId?: string }
193
242
  | undefined;
194
243
  let warnings: unknown[] | undefined;
244
+ let terminalError: unknown;
245
+ let hasTerminalError = false;
195
246
 
196
247
  // Acquire writer once before the loop to avoid per-chunk lock overhead
197
248
  const writer = writable?.getWriter();
@@ -269,11 +320,33 @@ export async function doStreamStep(
269
320
  if (writer) {
270
321
  await writer.write(part);
271
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
+ }
332
+ }
333
+ } catch (error) {
334
+ if (abortSignal?.aborted && isAbortError(error)) {
335
+ return { aborted: true };
272
336
  }
337
+
338
+ throw error;
273
339
  } finally {
274
340
  writer?.releaseLock();
275
341
  }
276
342
 
343
+ if (
344
+ abortSignal?.aborted ||
345
+ (options?.timeoutAt != null && options.timeoutAt <= Date.now())
346
+ ) {
347
+ return { aborted: true };
348
+ }
349
+
277
350
  return {
278
351
  toolCalls,
279
352
  finish,
@@ -284,5 +357,10 @@ export async function doStreamStep(
284
357
  warnings,
285
358
  },
286
359
  providerExecutedToolResults,
360
+ ...(hasTerminalError ? { terminalError } : {}),
287
361
  };
288
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;
@@ -1,11 +1,108 @@
1
- import { MockLanguageModelV4 } from 'ai/test';
1
+ import {
2
+ WORKFLOW_DESERIALIZE,
3
+ WORKFLOW_SERIALIZE,
4
+ } from '@ai-sdk/provider-utils';
5
+ import { convertArrayToReadableStream, MockLanguageModelV4 } from 'ai/test';
6
+ import type { MockResponseDescriptor } from './mock.js';
7
+
8
+ type MockStreamOptions = Parameters<MockLanguageModelV4['doStream']>[0];
9
+ type MockStreamResult = Awaited<ReturnType<MockLanguageModelV4['doStream']>>;
10
+ type MockStreamPart = MockStreamResult extends {
11
+ stream: ReadableStream<infer PART>;
12
+ }
13
+ ? PART
14
+ : never;
15
+
16
+ const usage = {
17
+ inputTokens: {
18
+ total: 5,
19
+ noCache: 5,
20
+ cacheRead: undefined,
21
+ cacheWrite: undefined,
22
+ },
23
+ outputTokens: { total: 10, text: 10, reasoning: undefined },
24
+ };
25
+
26
+ class SerializableMockLanguageModel extends MockLanguageModelV4 {
27
+ static [WORKFLOW_SERIALIZE](model: SerializableMockLanguageModel) {
28
+ return { responses: model.responses };
29
+ }
30
+
31
+ static [WORKFLOW_DESERIALIZE](options: {
32
+ responses: MockResponseDescriptor[];
33
+ }) {
34
+ return new SerializableMockLanguageModel(options.responses);
35
+ }
36
+
37
+ constructor(private readonly responses: MockResponseDescriptor[]) {
38
+ super({
39
+ provider: 'workflow-test',
40
+ modelId: 'workflow-test-model',
41
+ doStream: async (options: MockStreamOptions) => {
42
+ const responseIndex = Math.min(
43
+ options.prompt.filter(message => message.role === 'assistant').length,
44
+ responses.length - 1,
45
+ );
46
+ const response = responses[responseIndex];
47
+ const prefix: MockStreamPart[] = [
48
+ { type: 'stream-start', warnings: [] },
49
+ {
50
+ type: 'response-metadata',
51
+ id: 'r',
52
+ modelId: 'mock',
53
+ timestamp: new Date(),
54
+ },
55
+ ];
56
+ const streamParts: MockStreamPart[] =
57
+ response.type === 'text'
58
+ ? [
59
+ ...prefix,
60
+ { type: 'text-start', id: '1' },
61
+ { type: 'text-delta', id: '1', delta: response.text },
62
+ { type: 'text-end', id: '1' },
63
+ {
64
+ type: 'finish',
65
+ finishReason: { unified: 'stop', raw: 'stop' },
66
+ usage,
67
+ },
68
+ ]
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
+ ];
93
+
94
+ return { stream: convertArrayToReadableStream(streamParts) };
95
+ },
96
+ });
97
+ }
98
+ }
2
99
 
3
100
  // Workaround for SWC plugin bug (https://github.com/vercel/workflow/issues/1365):
4
101
  // `new ClassName(...)` in a step closure doesn't get closure vars hoisted
5
102
  // correctly. Wrapping the constructor call in a plain function (imported
6
103
  // from a separate file) fixes it.
7
104
  export function mockProvider(
8
- ...args: ConstructorParameters<typeof MockLanguageModelV4>
9
- ) {
10
- return new MockLanguageModelV4(...args);
105
+ responses: MockResponseDescriptor[],
106
+ ): MockLanguageModelV4 {
107
+ return new SerializableMockLanguageModel(responses);
11
108
  }
@@ -2,42 +2,14 @@ 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.
9
10
  */
10
11
  export function mockTextModel(text: string) {
11
- return mockProvider({
12
- doStream: async () => ({
13
- stream: new ReadableStream({
14
- start(c) {
15
- for (const v of [
16
- { type: 'stream-start', warnings: [] },
17
- {
18
- type: 'response-metadata',
19
- id: 'r',
20
- modelId: 'mock',
21
- timestamp: new Date(),
22
- },
23
- { type: 'text-start', id: '1' },
24
- { type: 'text-delta', id: '1', delta: text },
25
- { type: 'text-end', id: '1' },
26
- {
27
- type: 'finish',
28
- finishReason: { unified: 'stop', raw: 'stop' },
29
- usage: {
30
- inputTokens: { total: 5, noCache: 5 },
31
- outputTokens: { total: 10, text: 10 },
32
- },
33
- },
34
- ] as any[])
35
- c.enqueue(v);
36
- c.close();
37
- },
38
- }),
39
- }),
40
- });
12
+ return mockProvider([{ type: 'text', text }]);
41
13
  }
42
14
 
43
15
  /**
@@ -45,66 +17,5 @@ export function mockTextModel(text: string) {
45
17
  * Determines which response to return by counting assistant messages in the prompt.
46
18
  */
47
19
  export function mockSequenceModel(responses: MockResponseDescriptor[]) {
48
- return mockProvider({
49
- doStream: async (options: any) => {
50
- const responseIndex = Math.min(
51
- options.prompt.filter((m: any) => m.role === 'assistant').length,
52
- responses.length - 1,
53
- );
54
- const selectedResponse = responses[responseIndex];
55
- const parts =
56
- selectedResponse.type === 'text'
57
- ? [
58
- { type: 'stream-start', warnings: [] },
59
- {
60
- type: 'response-metadata',
61
- id: 'r',
62
- modelId: 'mock',
63
- timestamp: new Date(),
64
- },
65
- { type: 'text-start', id: '1' },
66
- { type: 'text-delta', id: '1', delta: selectedResponse.text },
67
- { type: 'text-end', id: '1' },
68
- {
69
- type: 'finish',
70
- finishReason: { unified: 'stop', raw: 'stop' },
71
- usage: {
72
- inputTokens: { total: 5, noCache: 5 },
73
- outputTokens: { total: 10, text: 10 },
74
- },
75
- },
76
- ]
77
- : [
78
- { type: 'stream-start', warnings: [] },
79
- {
80
- type: 'response-metadata',
81
- id: 'r',
82
- modelId: 'mock',
83
- timestamp: new Date(),
84
- },
85
- {
86
- type: 'tool-call',
87
- toolCallId: `call-${responseIndex + 1}`,
88
- toolName: selectedResponse.toolName,
89
- input: selectedResponse.input,
90
- },
91
- {
92
- type: 'finish',
93
- finishReason: { unified: 'tool-calls', raw: undefined },
94
- usage: {
95
- inputTokens: { total: 5, noCache: 5 },
96
- outputTokens: { total: 10, text: 10 },
97
- },
98
- },
99
- ];
100
- return {
101
- stream: new ReadableStream({
102
- start(c) {
103
- for (const streamPart of parts as any[]) c.enqueue(streamPart);
104
- c.close();
105
- },
106
- }),
107
- };
108
- },
109
- });
20
+ return mockProvider(responses);
110
21
  }
@@ -92,6 +92,16 @@ export interface StreamTextIteratorYieldValue {
92
92
  experimental_sandbox?: SandboxSession;
93
93
  }
94
94
 
95
+ export interface StreamTextIteratorAbortedValue {
96
+ aborted: true;
97
+ messages: LanguageModelV4Prompt;
98
+ }
99
+
100
+ export interface StreamTextIteratorErrorValue {
101
+ error: unknown;
102
+ messages: LanguageModelV4Prompt;
103
+ }
104
+
95
105
  // This runs in the workflow context
96
106
  export async function* streamTextIterator({
97
107
  prompt,
@@ -112,6 +122,7 @@ export async function* streamTextIterator({
112
122
  toolsContext,
113
123
  telemetry,
114
124
  includeRawChunks = false,
125
+ timeoutAt,
115
126
  repairToolCall,
116
127
  responseFormat,
117
128
  experimental_sandbox: sandbox,
@@ -135,12 +146,15 @@ export async function* streamTextIterator({
135
146
  toolsContext?: Record<string, Context | undefined>;
136
147
  telemetry?: TelemetryOptions<Context, ToolSet>;
137
148
  includeRawChunks?: boolean;
149
+ timeoutAt?: number;
138
150
  repairToolCall?: ToolCallRepairFunction<ToolSet>;
139
151
  responseFormat?: LanguageModelV4CallOptions['responseFormat'];
140
152
  experimental_sandbox?: SandboxSession;
141
153
  }): AsyncGenerator<
142
154
  StreamTextIteratorYieldValue,
143
- LanguageModelV4Prompt,
155
+ | LanguageModelV4Prompt
156
+ | StreamTextIteratorAbortedValue
157
+ | StreamTextIteratorErrorValue,
144
158
  LanguageModelV4ToolResultPart[]
145
159
  > {
146
160
  let conversationPrompt = [...prompt]; // Create a mutable copy
@@ -158,6 +172,9 @@ export async function* streamTextIterator({
158
172
  let stepNumber = 0;
159
173
  let lastStep: StepResult<any, any> | undefined;
160
174
  let lastStepWasToolCalls = false;
175
+ let wasAborted = false;
176
+ let terminalError: unknown;
177
+ let hasTerminalError = false;
161
178
 
162
179
  // TODO(#12164): replace this AI-core telemetry bridge with a
163
180
  // WorkflowAgent-specific typed dispatcher. `streamTextIterator` widens
@@ -319,20 +336,33 @@ export async function* streamTextIterator({
319
336
  headers: currentGenerationSettings.headers,
320
337
  } as never);
321
338
 
339
+ const streamStepResult = await doStreamStep(
340
+ conversationPrompt,
341
+ currentModel,
342
+ writable,
343
+ serializedTools,
344
+ {
345
+ ...currentGenerationSettings,
346
+ toolChoice: currentToolChoice,
347
+ includeRawChunks,
348
+ timeoutAt,
349
+ repairToolCall,
350
+ responseFormat,
351
+ },
352
+ );
353
+
354
+ if (streamStepResult.aborted) {
355
+ wasAborted = true;
356
+ break;
357
+ }
358
+
359
+ if ('terminalError' in streamStepResult) {
360
+ terminalError = streamStepResult.terminalError;
361
+ hasTerminalError = true;
362
+ }
363
+
322
364
  const { toolCalls, finish, raw, providerExecutedToolResults } =
323
- await doStreamStep(
324
- conversationPrompt,
325
- currentModel,
326
- writable,
327
- serializedTools,
328
- {
329
- ...currentGenerationSettings,
330
- toolChoice: currentToolChoice,
331
- includeRawChunks,
332
- repairToolCall,
333
- responseFormat,
334
- },
335
- );
365
+ streamStepResult;
336
366
  // Reconstruct the full StepResult outside the step boundary so the
337
367
  // durable event log doesn't carry StepResult's redundant copies (or the
338
368
  // per-chunk snapshot the step used to return).
@@ -363,7 +393,12 @@ export async function* streamTextIterator({
363
393
 
364
394
  const finishReason = finish?.finishReason;
365
395
 
366
- 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') {
367
402
  lastStepWasToolCalls = true;
368
403
 
369
404
  const textContent = step.content.filter(
@@ -488,6 +523,14 @@ export async function* streamTextIterator({
488
523
  };
489
524
  }
490
525
 
526
+ if (wasAborted) {
527
+ return { aborted: true, messages: conversationPrompt };
528
+ }
529
+
530
+ if (hasTerminalError) {
531
+ return { error: terminalError, messages: conversationPrompt };
532
+ }
533
+
491
534
  return conversationPrompt;
492
535
  }
493
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({