@ai-sdk/workflow 1.0.66 → 1.0.67

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.67",
4
4
  "type": "module",
5
5
  "description": "WorkflowAgent for building AI agents with AI SDK",
6
6
  "license": "Apache-2.0",
@@ -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,
@@ -48,6 +49,7 @@ export interface DoStreamStepOptions {
48
49
  seed?: number;
49
50
  maxRetries?: number;
50
51
  abortSignal?: AbortSignal;
52
+ timeoutAt?: number;
51
53
  headers?: Record<string, string | undefined>;
52
54
  reasoning?: LanguageModelV4CallOptions['reasoning'];
53
55
  providerOptions?: ProviderOptions;
@@ -99,20 +101,42 @@ export interface DoStreamStepRawResult {
99
101
  warnings?: unknown[];
100
102
  }
101
103
 
104
+ export type DoStreamStepResult =
105
+ | { aborted: true }
106
+ | {
107
+ aborted?: false;
108
+ toolCalls: ParsedToolCall[];
109
+ finish: StreamFinish | undefined;
110
+ raw: DoStreamStepRawResult;
111
+ providerExecutedToolResults: Map<string, ProviderExecutedToolResult>;
112
+ };
113
+
102
114
  export async function doStreamStep(
103
115
  conversationPrompt: LanguageModelV4Prompt,
104
116
  modelInit: LanguageModel,
105
117
  writable?: WritableStream<ModelCallStreamPart<ToolSet>>,
106
118
  serializedTools?: Record<string, SerializableToolDef>,
107
119
  options?: DoStreamStepOptions,
108
- ): Promise<{
109
- toolCalls: ParsedToolCall[];
110
- finish: StreamFinish | undefined;
111
- raw: DoStreamStepRawResult;
112
- providerExecutedToolResults: Map<string, ProviderExecutedToolResult>;
113
- }> {
120
+ ): Promise<DoStreamStepResult> {
114
121
  'use step';
115
122
 
123
+ const timeout =
124
+ options?.timeoutAt == null ? undefined : options.timeoutAt - Date.now();
125
+
126
+ // AbortSignal.timeout(0) does not abort synchronously. Check the deadline
127
+ // explicitly so an expired call never reaches the model, including when a
128
+ // durable step is retried after the original timeout has elapsed.
129
+ if (options?.abortSignal?.aborted || (timeout != null && timeout <= 0)) {
130
+ return { aborted: true };
131
+ }
132
+
133
+ const abortSignal =
134
+ timeout == null
135
+ ? options?.abortSignal
136
+ : options?.abortSignal == null
137
+ ? AbortSignal.timeout(timeout)
138
+ : AbortSignal.any([options.abortSignal, AbortSignal.timeout(timeout)]);
139
+
116
140
  // Resolve model inside step (must happen here for serialization boundary)
117
141
  const model: LanguageModel =
118
142
  typeof modelInit === 'string'
@@ -151,7 +175,7 @@ export async function doStreamStep(
151
175
  // streamModelCall handles: prompt standardization, tool preparation,
152
176
  // model.doStream(), retry logic, and stream part transformation
153
177
  // (tool call parsing, finish reason mapping, file wrapping).
154
- const { stream: modelStream } = await streamModelCall({
178
+ const modelStream = await streamModelCall({
155
179
  model,
156
180
  // streamModelCall expects Prompt (ModelMessage[]) but we pass the
157
181
  // pre-converted LanguageModelV4Prompt. standardizePrompt inside
@@ -162,7 +186,7 @@ export async function doStreamStep(
162
186
  toolChoice: options?.toolChoice,
163
187
  includeRawChunks: options?.includeRawChunks,
164
188
  providerOptions: options?.providerOptions,
165
- abortSignal: options?.abortSignal,
189
+ abortSignal,
166
190
  headers: options?.headers,
167
191
  reasoning: options?.reasoning,
168
192
  output,
@@ -175,7 +199,19 @@ export async function doStreamStep(
175
199
  stopSequences: options?.stopSequences,
176
200
  seed: options?.seed,
177
201
  repairToolCall: options?.repairToolCall,
178
- });
202
+ })
203
+ .then(result => result.stream)
204
+ .catch(error => {
205
+ if (abortSignal?.aborted && isAbortError(error)) {
206
+ return undefined;
207
+ }
208
+
209
+ throw error;
210
+ });
211
+
212
+ if (modelStream == null) {
213
+ return { aborted: true };
214
+ }
179
215
 
180
216
  // Consume the stream: capture data and write to writable in real-time
181
217
  const toolCalls: ParsedToolCall[] = [];
@@ -270,10 +306,23 @@ export async function doStreamStep(
270
306
  await writer.write(part);
271
307
  }
272
308
  }
309
+ } catch (error) {
310
+ if (abortSignal?.aborted && isAbortError(error)) {
311
+ return { aborted: true };
312
+ }
313
+
314
+ throw error;
273
315
  } finally {
274
316
  writer?.releaseLock();
275
317
  }
276
318
 
319
+ if (
320
+ abortSignal?.aborted ||
321
+ (options?.timeoutAt != null && options.timeoutAt <= Date.now())
322
+ ) {
323
+ return { aborted: true };
324
+ }
325
+
277
326
  return {
278
327
  toolCalls,
279
328
  finish,
@@ -1,11 +1,98 @@
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
+ : [
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
+ ];
83
+
84
+ return { stream: convertArrayToReadableStream(streamParts) };
85
+ },
86
+ });
87
+ }
88
+ }
2
89
 
3
90
  // Workaround for SWC plugin bug (https://github.com/vercel/workflow/issues/1365):
4
91
  // `new ClassName(...)` in a step closure doesn't get closure vars hoisted
5
92
  // correctly. Wrapping the constructor call in a plain function (imported
6
93
  // from a separate file) fixes it.
7
94
  export function mockProvider(
8
- ...args: ConstructorParameters<typeof MockLanguageModelV4>
9
- ) {
10
- return new MockLanguageModelV4(...args);
95
+ responses: MockResponseDescriptor[],
96
+ ): MockLanguageModelV4 {
97
+ return new SerializableMockLanguageModel(responses);
11
98
  }
@@ -8,36 +8,7 @@ export type MockResponseDescriptor =
8
8
  * Mock model that returns a fixed text response.
9
9
  */
10
10
  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
- });
11
+ return mockProvider([{ type: 'text', text }]);
41
12
  }
42
13
 
43
14
  /**
@@ -45,66 +16,5 @@ export function mockTextModel(text: string) {
45
16
  * Determines which response to return by counting assistant messages in the prompt.
46
17
  */
47
18
  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
- });
19
+ return mockProvider(responses);
110
20
  }
@@ -92,6 +92,11 @@ export interface StreamTextIteratorYieldValue {
92
92
  experimental_sandbox?: SandboxSession;
93
93
  }
94
94
 
95
+ export interface StreamTextIteratorAbortedValue {
96
+ aborted: true;
97
+ messages: LanguageModelV4Prompt;
98
+ }
99
+
95
100
  // This runs in the workflow context
96
101
  export async function* streamTextIterator({
97
102
  prompt,
@@ -112,6 +117,7 @@ export async function* streamTextIterator({
112
117
  toolsContext,
113
118
  telemetry,
114
119
  includeRawChunks = false,
120
+ timeoutAt,
115
121
  repairToolCall,
116
122
  responseFormat,
117
123
  experimental_sandbox: sandbox,
@@ -135,12 +141,13 @@ export async function* streamTextIterator({
135
141
  toolsContext?: Record<string, Context | undefined>;
136
142
  telemetry?: TelemetryOptions<Context, ToolSet>;
137
143
  includeRawChunks?: boolean;
144
+ timeoutAt?: number;
138
145
  repairToolCall?: ToolCallRepairFunction<ToolSet>;
139
146
  responseFormat?: LanguageModelV4CallOptions['responseFormat'];
140
147
  experimental_sandbox?: SandboxSession;
141
148
  }): AsyncGenerator<
142
149
  StreamTextIteratorYieldValue,
143
- LanguageModelV4Prompt,
150
+ LanguageModelV4Prompt | StreamTextIteratorAbortedValue,
144
151
  LanguageModelV4ToolResultPart[]
145
152
  > {
146
153
  let conversationPrompt = [...prompt]; // Create a mutable copy
@@ -158,6 +165,7 @@ export async function* streamTextIterator({
158
165
  let stepNumber = 0;
159
166
  let lastStep: StepResult<any, any> | undefined;
160
167
  let lastStepWasToolCalls = false;
168
+ let wasAborted = false;
161
169
 
162
170
  // TODO(#12164): replace this AI-core telemetry bridge with a
163
171
  // WorkflowAgent-specific typed dispatcher. `streamTextIterator` widens
@@ -319,20 +327,28 @@ export async function* streamTextIterator({
319
327
  headers: currentGenerationSettings.headers,
320
328
  } as never);
321
329
 
330
+ const streamStepResult = await doStreamStep(
331
+ conversationPrompt,
332
+ currentModel,
333
+ writable,
334
+ serializedTools,
335
+ {
336
+ ...currentGenerationSettings,
337
+ toolChoice: currentToolChoice,
338
+ includeRawChunks,
339
+ timeoutAt,
340
+ repairToolCall,
341
+ responseFormat,
342
+ },
343
+ );
344
+
345
+ if (streamStepResult.aborted) {
346
+ wasAborted = true;
347
+ break;
348
+ }
349
+
322
350
  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
- );
351
+ streamStepResult;
336
352
  // Reconstruct the full StepResult outside the step boundary so the
337
353
  // durable event log doesn't carry StepResult's redundant copies (or the
338
354
  // per-chunk snapshot the step used to return).
@@ -488,6 +504,10 @@ export async function* streamTextIterator({
488
504
  };
489
505
  }
490
506
 
507
+ if (wasAborted) {
508
+ return { aborted: true, messages: conversationPrompt };
509
+ }
510
+
491
511
  return conversationPrompt;
492
512
  }
493
513
 
@@ -7,6 +7,7 @@ import type {
7
7
  } from '@ai-sdk/provider';
8
8
  import {
9
9
  getErrorMessage,
10
+ isAbortError,
10
11
  validateTypes,
11
12
  withUserAgentSuffix,
12
13
  type Context,
@@ -39,7 +40,6 @@ import {
39
40
  createRestrictedTelemetryDispatcher,
40
41
  collectToolApprovals,
41
42
  convertToLanguageModelPrompt,
42
- mergeAbortSignals,
43
43
  mergeCallbacks,
44
44
  standardizePrompt,
45
45
  validateApprovedToolApprovals,
@@ -1788,10 +1788,10 @@ export class WorkflowAgent<
1788
1788
  download,
1789
1789
  });
1790
1790
 
1791
- const effectiveAbortSignal = mergeAbortSignals(
1792
- options.abortSignal ?? effectiveGenerationSettings.abortSignal,
1793
- options.timeout,
1794
- );
1791
+ const effectiveAbortSignal =
1792
+ options.abortSignal ?? effectiveGenerationSettings.abortSignal;
1793
+ const timeoutAt =
1794
+ options.timeout == null ? undefined : Date.now() + options.timeout;
1795
1795
 
1796
1796
  // Merge generation settings: constructor defaults < prepareCall < stream options
1797
1797
  const mergedGenerationSettings: GenerationSettings = {
@@ -2157,6 +2157,7 @@ export class WorkflowAgent<
2157
2157
  toolsContext,
2158
2158
  telemetry: effectiveTelemetry,
2159
2159
  includeRawChunks: options.includeRawChunks ?? false,
2160
+ timeoutAt,
2160
2161
  repairToolCall: (options.repairToolCall ??
2161
2162
  options.experimental_repairToolCall ??
2162
2163
  this.repairToolCall) as ToolCallRepairFunction<ToolSet> | undefined,
@@ -2519,14 +2520,24 @@ export class WorkflowAgent<
2519
2520
  }
2520
2521
  }
2521
2522
 
2522
- // When the iterator completes normally, result.value contains the final conversation prompt
2523
+ // When the iterator completes normally, result.value contains the final
2524
+ // conversation prompt. Aborts inside the retryable model step are
2525
+ // returned as data so the workflow runtime does not retry them.
2523
2526
  if (result.done) {
2524
- finalMessages = result.value;
2527
+ if (Array.isArray(result.value)) {
2528
+ finalMessages = result.value;
2529
+ } else {
2530
+ finalMessages = result.value.messages;
2531
+ wasAborted = true;
2532
+ if (options.onAbort) {
2533
+ await options.onAbort({ steps });
2534
+ }
2535
+ }
2525
2536
  }
2526
2537
  } catch (error) {
2527
2538
  encounteredError = error;
2528
2539
  // Check if this is an abort error
2529
- if (error instanceof Error && error.name === 'AbortError') {
2540
+ if (isAbortError(error)) {
2530
2541
  wasAborted = true;
2531
2542
  if (options.onAbort) {
2532
2543
  await options.onAbort({ steps });