@ai-sdk/workflow 1.0.0-canary.93 → 1.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/workflow",
3
- "version": "1.0.0-canary.93",
3
+ "version": "1.0.0",
4
4
  "description": "WorkflowAgent for building AI agents with AI SDK",
5
5
  "license": "Apache-2.0",
6
6
  "main": "./dist/index.js",
@@ -27,9 +27,9 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "ajv": "^8.20.0",
30
- "@ai-sdk/provider": "4.0.0-canary.18",
31
- "@ai-sdk/provider-utils": "5.0.0-canary.48",
32
- "ai": "7.0.0-canary.176"
30
+ "@ai-sdk/provider": "4.0.0",
31
+ "@ai-sdk/provider-utils": "5.0.0",
32
+ "ai": "7.0.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "22.19.19",
@@ -7,6 +7,7 @@ import type { Context } from '@ai-sdk/provider-utils';
7
7
  import {
8
8
  experimental_filterActiveTools as filterActiveTools,
9
9
  type Experimental_LanguageModelStreamPart as ModelCallStreamPart,
10
+ type Experimental_SandboxSession as SandboxSession,
10
11
  type LanguageModel,
11
12
  type ModelMessage,
12
13
  type StepResult,
@@ -52,6 +53,8 @@ export interface StreamTextIteratorYieldValue {
52
53
  toolsContext?: Record<string, Context | undefined>;
53
54
  /** Provider-executed tool results (keyed by tool call ID) */
54
55
  providerExecutedToolResults?: Map<string, ProviderExecutedToolResult>;
56
+ /** The sandbox selected for the current step. */
57
+ experimental_sandbox?: SandboxSession;
55
58
  }
56
59
 
57
60
  // This runs in the workflow context
@@ -74,6 +77,7 @@ export async function* streamTextIterator({
74
77
  includeRawChunks = false,
75
78
  repairToolCall,
76
79
  responseFormat,
80
+ experimental_sandbox: sandbox,
77
81
  }: {
78
82
  prompt: LanguageModelV4Prompt;
79
83
  tools: ToolSet;
@@ -94,6 +98,7 @@ export async function* streamTextIterator({
94
98
  includeRawChunks?: boolean;
95
99
  repairToolCall?: ToolCallRepairFunction<ToolSet>;
96
100
  responseFormat?: LanguageModelV4CallOptions['responseFormat'];
101
+ experimental_sandbox?: SandboxSession;
97
102
  }): AsyncGenerator<
98
103
  StreamTextIteratorYieldValue,
99
104
  LanguageModelV4Prompt,
@@ -135,6 +140,8 @@ export async function* streamTextIterator({
135
140
  break;
136
141
  }
137
142
 
143
+ let stepSandbox = sandbox;
144
+
138
145
  // Call prepareStep callback before each step if provided
139
146
  if (prepareStep) {
140
147
  const prepareResult = await prepareStep({
@@ -144,8 +151,11 @@ export async function* streamTextIterator({
144
151
  messages: conversationPrompt,
145
152
  runtimeContext: currentRuntimeContext,
146
153
  toolsContext: currentToolsContext as never,
154
+ experimental_sandbox: sandbox,
147
155
  });
148
156
 
157
+ stepSandbox = prepareResult?.experimental_sandbox ?? sandbox;
158
+
149
159
  // Apply any overrides from prepareStep
150
160
  if (prepareResult?.model !== undefined) {
151
161
  currentModel = prepareResult.model;
@@ -398,6 +408,7 @@ export async function* streamTextIterator({
398
408
  step,
399
409
  runtimeContext: currentRuntimeContext,
400
410
  toolsContext: currentToolsContext,
411
+ experimental_sandbox: stepSandbox,
401
412
  providerExecutedToolResults,
402
413
  };
403
414
 
@@ -473,6 +484,7 @@ export async function* streamTextIterator({
473
484
  step: lastStep,
474
485
  runtimeContext: currentRuntimeContext,
475
486
  toolsContext: currentToolsContext,
487
+ experimental_sandbox: sandbox,
476
488
  };
477
489
  }
478
490
 
@@ -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 { createTestSandbox } from './test-sandbox.js';
7
8
  import { FatalError, getWritable } from 'workflow';
8
9
  import { z } from 'zod';
9
10
 
@@ -585,3 +586,88 @@ export async function agentRuntimeAndToolsContextE2e() {
585
586
  onFinishToolsContext,
586
587
  };
587
588
  }
589
+
590
+ export async function agentSandboxE2e() {
591
+ 'use workflow';
592
+
593
+ let constructorSandboxRanCommand = 'not-run';
594
+ let stepSandboxRanCommand = 'not-run';
595
+ let firstPrepareStepSawConstructorSandbox = false;
596
+ let secondPrepareStepSawConstructorSandbox = false;
597
+ let prepareStepSawStepSandbox = false;
598
+
599
+ // A live sandbox session passed through `experimental_sandbox`. The tool
600
+ // `execute` is inline (not a `'use step'`) so the handle never crosses a
601
+ // step boundary — matching the single-process use of `experimental_sandbox`.
602
+ const constructorSandbox = createTestSandbox({
603
+ run: async ({ command }) => {
604
+ constructorSandboxRanCommand = command;
605
+ return {
606
+ exitCode: 0,
607
+ stdout: `constructor ran: ${command}`,
608
+ stderr: '',
609
+ };
610
+ },
611
+ });
612
+ const stepSandbox = createTestSandbox({
613
+ run: async ({ command }) => {
614
+ stepSandboxRanCommand = command;
615
+ return { exitCode: 0, stdout: `ran: ${command}`, stderr: '' };
616
+ },
617
+ });
618
+
619
+ const agent = new WorkflowAgent({
620
+ model: mockSequenceModel([
621
+ {
622
+ type: 'tool-call',
623
+ toolName: 'runShell',
624
+ input: JSON.stringify({ command: 'echo hello' }),
625
+ },
626
+ { type: 'text', text: 'Command executed.' },
627
+ ]),
628
+ tools: {
629
+ runShell: tool({
630
+ description: 'Run a shell command in the sandbox.',
631
+ inputSchema: z.object({ command: z.string() }),
632
+ execute: async ({ command }, { experimental_sandbox }) => {
633
+ if (experimental_sandbox == null) {
634
+ throw new Error('Sandbox is not available');
635
+ }
636
+ return experimental_sandbox.run({ command });
637
+ },
638
+ }),
639
+ },
640
+ instructions: 'You run shell commands in a sandbox.',
641
+ experimental_sandbox: constructorSandbox,
642
+ prepareStep: ({ stepNumber, experimental_sandbox }) => {
643
+ if (stepNumber === 0) {
644
+ firstPrepareStepSawConstructorSandbox =
645
+ experimental_sandbox === constructorSandbox;
646
+ return { experimental_sandbox: stepSandbox };
647
+ }
648
+
649
+ if (experimental_sandbox === stepSandbox) {
650
+ prepareStepSawStepSandbox = true;
651
+ }
652
+ secondPrepareStepSawConstructorSandbox =
653
+ experimental_sandbox === constructorSandbox;
654
+ return {};
655
+ },
656
+ });
657
+
658
+ const result = await agent.stream({
659
+ messages: [{ role: 'user', content: 'Run echo hello' }],
660
+ writable: getWritable(),
661
+ });
662
+
663
+ return {
664
+ stepCount: result.steps.length,
665
+ lastStepText: result.steps[result.steps.length - 1]?.text,
666
+ toolResults: result.toolResults,
667
+ constructorSandboxRanCommand,
668
+ stepSandboxRanCommand,
669
+ firstPrepareStepSawConstructorSandbox,
670
+ secondPrepareStepSawConstructorSandbox,
671
+ prepareStepSawStepSandbox,
672
+ };
673
+ }
@@ -0,0 +1,26 @@
1
+ import type { Experimental_SandboxSession as SandboxSession } from 'ai';
2
+
3
+ /**
4
+ * Build a minimal {@link SandboxSession} for tests. Only `run` returns a usable
5
+ * result by default; every other method throws so tests stay explicit about
6
+ * what they exercise. Pass `overrides` to customize individual methods.
7
+ */
8
+ export function createTestSandbox(
9
+ overrides: Partial<SandboxSession> = {},
10
+ ): SandboxSession {
11
+ const notImplemented = () => {
12
+ throw new Error('not implemented in test sandbox');
13
+ };
14
+ return {
15
+ description: 'test sandbox',
16
+ readFile: notImplemented,
17
+ readBinaryFile: notImplemented,
18
+ readTextFile: notImplemented,
19
+ writeFile: notImplemented,
20
+ writeBinaryFile: notImplemented,
21
+ writeTextFile: notImplemented,
22
+ spawn: notImplemented,
23
+ run: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
24
+ ...overrides,
25
+ };
26
+ }
@@ -8,6 +8,7 @@ import type {
8
8
  import {
9
9
  getErrorMessage,
10
10
  validateTypes,
11
+ withUserAgentSuffix,
11
12
  type Context,
12
13
  type HasRequiredKey,
13
14
  type InferToolSetContext,
@@ -32,6 +33,7 @@ import {
32
33
  type Prompt,
33
34
  type TelemetryOptions as CoreTelemetryOptions,
34
35
  type Instructions,
36
+ type Experimental_SandboxSession as SandboxSession,
35
37
  } from 'ai';
36
38
  import {
37
39
  createRestrictedTelemetryDispatcher,
@@ -278,6 +280,11 @@ export interface PrepareStepInfo<
278
280
  * `prepareStep` to update it for the current and subsequent steps.
279
281
  */
280
282
  toolsContext: InferToolSetContext<TTools>;
283
+
284
+ /**
285
+ * The sandbox environment that the step is operating in.
286
+ */
287
+ experimental_sandbox?: SandboxSession;
281
288
  }
282
289
 
283
290
  /**
@@ -326,6 +333,11 @@ export interface PrepareStepResult<
326
333
  * Returning a value replaces the agent's tools context.
327
334
  */
328
335
  toolsContext?: InferToolSetContext<TTools>;
336
+
337
+ /**
338
+ * Override the sandbox environment for this step.
339
+ */
340
+ experimental_sandbox?: SandboxSession;
329
341
  }
330
342
 
331
343
  /**
@@ -494,6 +506,14 @@ export type WorkflowAgentOptions<
494
506
  */
495
507
  experimental_download?: DownloadFunction;
496
508
 
509
+ /**
510
+ * Default sandbox environment passed through to tool execution as
511
+ * `experimental_sandbox`.
512
+ *
513
+ * Per-stream `experimental_sandbox` values passed to `stream()` override this default.
514
+ */
515
+ experimental_sandbox?: SandboxSession;
516
+
497
517
  /**
498
518
  * Default callback function called before each step in the agent loop.
499
519
  * Use this to modify settings, manage context, or inject messages dynamically
@@ -559,6 +579,16 @@ export type WorkflowAgentOptions<
559
579
  * model, tools, instructions, or other settings based on runtime context.
560
580
  */
561
581
  prepareCall?: PrepareCallCallback<TTools, TRuntimeContext>;
582
+
583
+ /**
584
+ * Whether to allow system messages inside the `prompt` or `messages` fields.
585
+ * When `false` (the default), system messages in `prompt` or `messages` are
586
+ * rejected to prevent prompt-injection attacks. Set to `true` only when you
587
+ * intentionally interleave system messages with user messages.
588
+ *
589
+ * @default false
590
+ */
591
+ allowSystemInMessages?: boolean;
562
592
  };
563
593
 
564
594
  /**
@@ -923,6 +953,12 @@ export type WorkflowAgentStreamOptions<
923
953
  */
924
954
  experimental_download?: DownloadFunction;
925
955
 
956
+ /**
957
+ * Sandbox environment passed through to tool execution as
958
+ * `experimental_sandbox`. Overrides the constructor-level value if provided.
959
+ */
960
+ experimental_sandbox?: SandboxSession;
961
+
926
962
  /**
927
963
  * Callback function to be called after each step completes.
928
964
  */
@@ -1167,7 +1203,9 @@ export class WorkflowAgent<
1167
1203
  private output?: OutputSpecification<any, any>;
1168
1204
  private experimentalRepairToolCall?: ToolCallRepairFunction<TBaseTools>;
1169
1205
  private experimentalDownload?: DownloadFunction;
1206
+ private experimentalSandbox?: SandboxSession;
1170
1207
  private prepareStep?: PrepareStepCallback<TBaseTools, TRuntimeContext>;
1208
+ private allowSystemInMessages: boolean;
1171
1209
  private constructorOnStepEnd?: WorkflowAgentOnStepEndCallback<
1172
1210
  TBaseTools,
1173
1211
  TRuntimeContext
@@ -1203,6 +1241,7 @@ export class WorkflowAgent<
1203
1241
  this.output = options.output;
1204
1242
  this.experimentalRepairToolCall = options.experimental_repairToolCall;
1205
1243
  this.experimentalDownload = options.experimental_download;
1244
+ this.experimentalSandbox = options.experimental_sandbox;
1206
1245
  this.prepareStep = options.prepareStep;
1207
1246
  this.constructorOnStepEnd = options.onStepEnd ?? options.onStepFinish;
1208
1247
  const { onFinish, onEnd = onFinish } = options;
@@ -1212,6 +1251,7 @@ export class WorkflowAgent<
1212
1251
  this.constructorOnToolExecutionStart = options.onToolExecutionStart;
1213
1252
  this.constructorOnToolExecutionEnd = options.onToolExecutionEnd;
1214
1253
  this.prepareCall = options.prepareCall;
1254
+ this.allowSystemInMessages = options.allowSystemInMessages ?? false;
1215
1255
 
1216
1256
  // Extract generation settings
1217
1257
  this.generationSettings = {
@@ -1342,12 +1382,13 @@ export class WorkflowAgent<
1342
1382
 
1343
1383
  const prompt = await standardizePrompt({
1344
1384
  system: effectiveInstructions,
1345
- allowSystemInMessages: true, // TODO: consider exposing this as a parameter
1385
+ allowSystemInMessages: this.allowSystemInMessages,
1346
1386
  ...(effectivePrompt != null
1347
1387
  ? { prompt: effectivePrompt }
1348
1388
  : { messages: effectiveMessages! }),
1349
1389
  } as Prompt);
1350
1390
  const download = options.experimental_download ?? this.experimentalDownload;
1391
+ const sandbox = options.experimental_sandbox ?? this.experimentalSandbox;
1351
1392
 
1352
1393
  // Process tool approval responses before starting the agent loop.
1353
1394
  // This mirrors how stream-text.ts handles tool-approval-response parts:
@@ -1366,6 +1407,7 @@ export class WorkflowAgent<
1366
1407
  toolName: collected.toolCall.toolName,
1367
1408
  input: collected.toolCall.input,
1368
1409
  reason: collected.approvalResponse.reason,
1410
+ providerExecuted: collected.toolCall.providerExecuted === true,
1369
1411
  collected,
1370
1412
  }),
1371
1413
  );
@@ -1375,9 +1417,26 @@ export class WorkflowAgent<
1375
1417
  toolName: collected.toolCall.toolName,
1376
1418
  input: collected.toolCall.input,
1377
1419
  reason: collected.approvalResponse.reason,
1420
+ providerExecuted: collected.toolCall.providerExecuted === true,
1378
1421
  }),
1379
1422
  );
1380
1423
 
1424
+ // Approval ids of provider-executed tool calls. Provider-executed tools
1425
+ // (e.g. MCP via the Responses API) cannot be resolved locally — the
1426
+ // provider owns execution. We therefore skip them from local execution
1427
+ // and preserve their approval responses in the messages so the provider
1428
+ // receives the approval on the next call. The discriminator is sourced
1429
+ // from the original `tool-call` part (matching how core's stream-text.ts
1430
+ // decides), not from the response part which may be missing the flag.
1431
+ const providerExecutedApprovalIds = new Set<string>(
1432
+ [
1433
+ ...collectedApprovals.approvedToolApprovals,
1434
+ ...collectedApprovals.deniedToolApprovals,
1435
+ ]
1436
+ .filter(collected => collected.toolCall.providerExecuted === true)
1437
+ .map(collected => collected.approvalResponse.approvalId),
1438
+ );
1439
+
1381
1440
  if (approvedToolApprovals.length > 0 || deniedToolApprovals.length > 0) {
1382
1441
  const _toolResultMessages: ModelMessage[] = [];
1383
1442
  const toolResultContent: LanguageModelV4ToolResultPart[] = [];
@@ -1390,6 +1449,11 @@ export class WorkflowAgent<
1390
1449
 
1391
1450
  // Execute approved tools
1392
1451
  for (const approval of approvedToolApprovals) {
1452
+ // Provider-executed approvals are forwarded to the provider via the
1453
+ // preserved approval response below, not executed locally.
1454
+ if (approval.providerExecuted) {
1455
+ continue;
1456
+ }
1393
1457
  const tool = (this.tools as ToolSet)[approval.toolName];
1394
1458
  if (tool && typeof tool.execute === 'function') {
1395
1459
  if (!tool.needsApproval) {
@@ -1482,6 +1546,7 @@ export class WorkflowAgent<
1482
1546
  toolCallId: approval.toolCallId,
1483
1547
  messages: [],
1484
1548
  context: resolvedContext,
1549
+ experimental_sandbox: sandbox,
1485
1550
  });
1486
1551
  const toolResult =
1487
1552
  telemetryDispatcher.executeTool != null
@@ -1564,6 +1629,11 @@ export class WorkflowAgent<
1564
1629
 
1565
1630
  // Create denial results for denied tools
1566
1631
  for (const denial of deniedToolApprovals) {
1632
+ // Provider-executed denials are forwarded to the provider via the
1633
+ // preserved approval response below, not turned into a local result.
1634
+ if (denial.providerExecuted) {
1635
+ continue;
1636
+ }
1567
1637
  toolResultContent.push({
1568
1638
  type: 'tool-result' as const,
1569
1639
  toolCallId: denial.toolCallId,
@@ -1575,20 +1645,33 @@ export class WorkflowAgent<
1575
1645
  });
1576
1646
  }
1577
1647
 
1578
- // Strip approval parts from messages and inject tool results
1648
+ // Strip approval parts that we resolved locally and inject tool results.
1649
+ // Provider-executed approval parts are preserved so the next call to
1650
+ // `convertToLanguageModelPrompt` forwards the approval response to the
1651
+ // provider (it only forwards responses flagged `providerExecuted`).
1579
1652
  const cleanedMessages: ModelMessage[] = [];
1580
1653
  for (const msg of prompt.messages) {
1581
1654
  if (msg.role === 'assistant' && Array.isArray(msg.content)) {
1582
1655
  const filtered = (msg.content as any[]).filter(
1583
- (p: any) => p.type !== 'tool-approval-request',
1656
+ (p: any) =>
1657
+ p.type !== 'tool-approval-request' ||
1658
+ providerExecutedApprovalIds.has(p.approvalId),
1584
1659
  );
1585
1660
  if (filtered.length > 0) {
1586
1661
  cleanedMessages.push({ ...msg, content: filtered });
1587
1662
  }
1588
1663
  } else if (msg.role === 'tool') {
1589
- const filtered = (msg.content as any[]).filter(
1590
- (p: any) => p.type !== 'tool-approval-response',
1591
- );
1664
+ const filtered = (msg.content as any[]).flatMap((p: any) => {
1665
+ if (p.type !== 'tool-approval-response') {
1666
+ return [p];
1667
+ }
1668
+ if (!providerExecutedApprovalIds.has(p.approvalId)) {
1669
+ return [];
1670
+ }
1671
+ // Re-stamp `providerExecuted` so the conversion layer forwards the
1672
+ // response even if the client omitted the flag on the response part.
1673
+ return [{ ...p, providerExecuted: true }];
1674
+ });
1592
1675
  if (filtered.length > 0) {
1593
1676
  cleanedMessages.push({ ...msg, content: filtered });
1594
1677
  }
@@ -1666,6 +1749,14 @@ export class WorkflowAgent<
1666
1749
  }),
1667
1750
  };
1668
1751
 
1752
+ // tag the outgoing request so usage can be attributed to WorkflowAgent.
1753
+ // chains with the `ai/<version>` and `ai-sdk/<provider>/<version>` suffixes
1754
+ // added downstream by the model run and the provider.
1755
+ mergedGenerationSettings.headers = withUserAgentSuffix(
1756
+ mergedGenerationSettings.headers ?? {},
1757
+ 'ai-sdk-agent/workflow',
1758
+ );
1759
+
1669
1760
  // Merge constructor + stream callbacks (constructor first, then stream)
1670
1761
  const mergedOnStepEnd = mergeCallbacks(
1671
1762
  this.constructorOnStepEnd as
@@ -1768,6 +1859,7 @@ export class WorkflowAgent<
1768
1859
  messages: LanguageModelV4Prompt,
1769
1860
  perToolContexts: Record<string, Context | undefined>,
1770
1861
  currentStepNumber: number = 0,
1862
+ stepSandbox?: SandboxSession,
1771
1863
  ): Promise<WorkflowToolExecutionResult> => {
1772
1864
  const toolCallEvent: ToolCall = {
1773
1865
  type: 'tool-call',
@@ -1809,7 +1901,14 @@ export class WorkflowAgent<
1809
1901
  let result: WorkflowToolExecutionResult;
1810
1902
  try {
1811
1903
  const execute = () =>
1812
- executeTool(toolCall, tools, messages, resolvedContext, download);
1904
+ executeTool(
1905
+ toolCall,
1906
+ tools,
1907
+ messages,
1908
+ resolvedContext,
1909
+ download,
1910
+ stepSandbox,
1911
+ );
1813
1912
  result =
1814
1913
  telemetryDispatcher.executeTool != null
1815
1914
  ? await telemetryDispatcher.executeTool({
@@ -1981,6 +2080,7 @@ export class WorkflowAgent<
1981
2080
  | ToolCallRepairFunction<ToolSet>
1982
2081
  | undefined,
1983
2082
  responseFormat: await (options.output ?? this.output)?.responseFormat,
2083
+ experimental_sandbox: sandbox,
1984
2084
  });
1985
2085
 
1986
2086
  // Track the final conversation messages from the iterator
@@ -2006,8 +2106,10 @@ export class WorkflowAgent<
2006
2106
  step,
2007
2107
  runtimeContext: yieldedRuntimeContext,
2008
2108
  toolsContext: yieldedToolsContext,
2109
+ experimental_sandbox: stepSandbox,
2009
2110
  providerExecutedToolResults,
2010
2111
  } = result.value;
2112
+ const toolExecutionSandbox = stepSandbox ?? sandbox;
2011
2113
  // Capture current step number before pushing (0-based)
2012
2114
  const currentStepNumber = steps.length;
2013
2115
  if (step) {
@@ -2088,6 +2190,7 @@ export class WorkflowAgent<
2088
2190
  iterMessages,
2089
2191
  toolsContext,
2090
2192
  currentStepNumber,
2193
+ toolExecutionSandbox,
2091
2194
  ),
2092
2195
  ),
2093
2196
  );
@@ -2227,6 +2330,7 @@ export class WorkflowAgent<
2227
2330
  iterMessages,
2228
2331
  toolsContext,
2229
2332
  currentStepNumber,
2333
+ toolExecutionSandbox,
2230
2334
  ),
2231
2335
  ),
2232
2336
  );
@@ -2704,6 +2808,7 @@ async function executeTool(
2704
2808
  messages: LanguageModelV4Prompt,
2705
2809
  context?: unknown,
2706
2810
  download?: DownloadFunction,
2811
+ sandbox?: SandboxSession,
2707
2812
  ): Promise<WorkflowToolExecutionResult> {
2708
2813
  const tool = tools[toolCall.toolName];
2709
2814
  if (!tool) throw new Error(`Tool "${toolCall.toolName}" not found`);
@@ -2730,6 +2835,7 @@ async function executeTool(
2730
2835
  messages,
2731
2836
  // Pass per-tool context to the tool (resolved from `toolsContext`)
2732
2837
  context,
2838
+ experimental_sandbox: sandbox,
2733
2839
  });
2734
2840
  } catch (error) {
2735
2841
  // Convert tool errors to error-text results sent back to the model,