@agent-compose/sdk 0.5.7 → 0.5.9

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.
Files changed (76) hide show
  1. package/dist/agent/__tests__/run-agent-liveness.test.d.ts +17 -0
  2. package/dist/agent/agent-context.d.ts +67 -0
  3. package/dist/agent/agent-loop.d.ts +23 -12
  4. package/dist/agent/local-pause-request.d.ts +49 -0
  5. package/dist/agent/local-pause-request.test.d.ts +1 -0
  6. package/dist/agent/steer-control.d.ts +22 -6
  7. package/dist/client.d.ts +76 -2
  8. package/dist/index.d.ts +10 -5
  9. package/dist/index.js +2409 -1457
  10. package/dist/pause/checkpoint.d.ts +27 -10
  11. package/dist/pause/manager.d.ts +1 -0
  12. package/dist/pause/pause-core.d.ts +23 -0
  13. package/dist/pause/state-dir.d.ts +1 -1
  14. package/dist/pause/wrappers.d.ts +7 -11
  15. package/dist/processors/builtins.d.ts +20 -1
  16. package/dist/processors/index.d.ts +1 -1
  17. package/dist/processors/processor.d.ts +13 -0
  18. package/dist/runtimes/_acp-client.d.ts +140 -0
  19. package/dist/runtimes/_cli-agent.d.ts +155 -3
  20. package/dist/runtimes/amp.d.ts +2 -2
  21. package/dist/runtimes/cli-agent-acp-live.test.d.ts +30 -0
  22. package/dist/runtimes/cli-agent.test.d.ts +22 -6
  23. package/dist/runtimes/codex.d.ts +7 -2
  24. package/dist/runtimes/openai-desktop.js +2394 -1457
  25. package/dist/runtimes/vercel.js +389 -2
  26. package/dist/sandbox.d.ts +132 -14
  27. package/dist/step-invocation/types.d.ts +1 -1
  28. package/dist/types/__tests__/environment-build-flag.test.d.ts +1 -0
  29. package/dist/types/__tests__/workflow-metadata-provider.test.d.ts +1 -0
  30. package/dist/types/execution-context.d.ts +1 -11
  31. package/dist/types/protocol.d.ts +32 -1
  32. package/dist/types/runtime.d.ts +7 -0
  33. package/dist/types/sandbox-environment.d.ts +6 -1
  34. package/dist/types/sandbox.d.ts +41 -6
  35. package/dist/types/workflow-metadata.d.ts +47 -6
  36. package/dist/types/workflow.d.ts +27 -4
  37. package/dist/utils/bundler.d.ts +7 -1
  38. package/dist/workflow-steps/observability.d.ts +28 -2
  39. package/dist/workflow-steps/types.d.ts +11 -7
  40. package/dist/workflow-steps/workflow.d.ts +5 -1
  41. package/package.json +3 -2
  42. package/src/agent/agent-context.ts +220 -0
  43. package/src/agent/agent-loop.ts +90 -22
  44. package/src/agent/local-pause-request.ts +90 -0
  45. package/src/agent/run-agent.ts +43 -3
  46. package/src/agent/steer-control.ts +21 -7
  47. package/src/client.ts +123 -2
  48. package/src/index.ts +16 -4
  49. package/src/pause/checkpoint.ts +33 -14
  50. package/src/pause/manager.ts +2 -2
  51. package/src/pause/pause-core.ts +35 -0
  52. package/src/pause/state-dir.ts +2 -2
  53. package/src/pause/wrappers.ts +7 -21
  54. package/src/processors/builtins.ts +44 -1
  55. package/src/processors/index.ts +1 -0
  56. package/src/processors/processor.ts +13 -0
  57. package/src/runtimes/_acp-client.ts +516 -0
  58. package/src/runtimes/_cli-agent.ts +418 -3
  59. package/src/runtimes/claude.ts +27 -3
  60. package/src/runtimes/codex.ts +21 -1
  61. package/src/runtimes/vercel.ts +4 -1
  62. package/src/sandbox.ts +429 -67
  63. package/src/step-invocation/types.ts +1 -1
  64. package/src/types/execution-context.ts +1 -11
  65. package/src/types/protocol.ts +27 -1
  66. package/src/types/runtime.ts +7 -0
  67. package/src/types/sandbox-environment.ts +12 -1
  68. package/src/types/sandbox.ts +40 -6
  69. package/src/types/workflow-metadata.ts +51 -6
  70. package/src/types/workflow.ts +27 -6
  71. package/src/utils/bundler.ts +9 -1
  72. package/src/workflow-steps/observability.ts +51 -5
  73. package/src/workflow-steps/runner.ts +9 -5
  74. package/src/workflow-steps/types.ts +11 -7
  75. package/src/workflow-steps/workflow.ts +5 -1
  76. package/src/workflows/invoke-child.ts +7 -1
@@ -43,7 +43,7 @@ export type StepResult<TOutput = unknown> =
43
43
  * it so the runtime type and the parsed shape can't drift.
44
44
  *
45
45
  * Loose by design (the inner zod shape stops at orchestration fields
46
- * the engine needs): the SDK wrapper layer (`requestDecision` /
46
+ * the engine needs): the SDK wrapper layer (`sleep` /
47
47
  * `sleep` / `waitForEvent`) owns its own payload contract, and the
48
48
  * engine treats `payload` as opaque. */
49
49
  export const StepPauseRequestSchema = z.object({
@@ -3,7 +3,7 @@
3
3
  import type { InvokeAndWaitOptions, RunStatus } from "../client.js";
4
4
  import type { RequestContext } from "../request-context/request-context.js";
5
5
  import type { PauseRequest } from "../pause/pause-core.js";
6
- import type { RequestDecisionRequest, WaitForEventRequest } from "../pause/wrappers.js";
6
+ import type { WaitForEventRequest } from "../pause/wrappers.js";
7
7
  import type { SandboxProvider } from "./sandbox.js";
8
8
 
9
9
  /** The identity of this workflow run. */
@@ -29,14 +29,6 @@ export interface BaseExecutionContext {
29
29
  setMetadata?: (data: Record<string, unknown>) => Promise<void>;
30
30
  /** Invoke another registered workflow and wait for it to settle. */
31
31
  invokeChild: InvokeChild;
32
- /**
33
- * Disk-backed memoise across pause-resume. First call runs `fn` and
34
- * atomically writes the result to the sandbox; on resume the recorded
35
- * value is returned and `fn` is NOT re-executed. Use for expensive
36
- * deterministic transforms; for side effects, use `invokeChild`.
37
- * See ADR-0006 §"`ctx.checkpoint(name, fn)` — disk-backed memoisation".
38
- */
39
- checkpoint<T>(name: string, fn: () => Promise<T> | T): Promise<T>;
40
32
  /**
41
33
  * Pause for feedback. The step exits and the workflow waits durably until
42
34
  * something resolves the pause (a resume call, a TTL expiry); on resume the
@@ -46,8 +38,6 @@ export interface BaseExecutionContext {
46
38
  * PauseExpiredError / PauseSchemaError. See ADR-0006 / ADR-0011.
47
39
  */
48
40
  pause<T = unknown>(req: PauseRequest<T>): Promise<T>;
49
- /** Pause for a typed decision (a `schema` is required). Wrapper over `pause`. */
50
- requestDecision<T>(req: RequestDecisionRequest<T>): Promise<T>;
51
41
  /** Lightweight timed pause — resolves after `durationMs`, no snapshot. */
52
42
  sleep(durationMs: number): Promise<void>;
53
43
  /** Pause until an event resumes by `correlationKey`. Wrapper over `pause`. */
@@ -33,6 +33,16 @@ export interface AgentMessageToolResult extends AgentMessageBase {
33
33
  toolUseId: string;
34
34
  output: string;
35
35
  isError: boolean;
36
+ /** Structured file diffs produced by the tool call, carried alongside the
37
+ * rendered `output` so a richer renderer can use the structure without
38
+ * re-plumbing the normaliser. Sourced from ACP `tool_call`/`tool_call_update`
39
+ * `diff` content blocks (WS-C / ADR-0020 Q3). Optional and additive:
40
+ * existing producers (claude / vercel / legacy JSONL) omit it. `oldText` is
41
+ * null for a newly created file. */
42
+ diffs?: { path: string; oldText: string | null; newText: string }[];
43
+ /** File locations touched by the tool call (ACP `locations` field), enabling
44
+ * "follow-along" UI. Optional and additive (WS-C / ADR-0020 Q3). */
45
+ locations?: { path: string; line?: number }[];
36
46
  }
37
47
 
38
48
  export interface AgentMessageDone extends AgentMessageBase {
@@ -55,6 +65,21 @@ export interface AgentMessageUsage extends AgentMessageBase {
55
65
  numTurns: number;
56
66
  }
57
67
 
68
+ /** Structured execution plan emitted by an agent (ACP `plan` session update,
69
+ * WS-C / ADR-0020 Q2). Each `plan` notification REPLACES the whole plan — the
70
+ * normaliser emits one `AgentMessagePlan` per notification carrying the entire
71
+ * `entries` array, and downstream treats the latest as authoritative. This is
72
+ * NOT an `AgentStatus` and does not feed self-pause; it is observability only.
73
+ * Additive 9th kind: existing producers never emit it. */
74
+ export interface AgentMessagePlan extends AgentMessageBase {
75
+ type: "plan";
76
+ entries: {
77
+ content: string;
78
+ priority: "high" | "medium" | "low";
79
+ status: "pending" | "in_progress" | "completed";
80
+ }[];
81
+ }
82
+
58
83
  export type AgentMessage =
59
84
  | AgentMessageInit
60
85
  | AgentMessageText
@@ -63,7 +88,8 @@ export type AgentMessage =
63
88
  | AgentMessageToolResult
64
89
  | AgentMessageDone
65
90
  | AgentMessageError
66
- | AgentMessageUsage;
91
+ | AgentMessageUsage
92
+ | AgentMessagePlan;
67
93
 
68
94
  /** Status block the agent emits to signal iteration completion or blockers. */
69
95
  export interface AgentStatus {
@@ -5,6 +5,7 @@
5
5
  import type { SandboxProvider } from "./sandbox.js";
6
6
  import type { AgentMessage } from "./protocol.js";
7
7
  import type { Processor, ProcessorContext, ToolCall } from "../processors/processor.js";
8
+ import type { BoundaryPauseFn } from "../pause/pause-core.js";
8
9
  import type { RequestContext } from "../request-context/request-context.js";
9
10
 
10
11
  /** Configuration for a single MCP server. */
@@ -31,6 +32,12 @@ export interface RuntimeOptions {
31
32
  /** Agent id and label for processor context / adapter logs. */
32
33
  agentId?: string;
33
34
  iteration?: number;
35
+ /** The run's pause boundary, threaded from the agent loop so a runtime-driven
36
+ * pre-tool gate (e.g. the ACP `session/request_permission` path through
37
+ * `gateToolCall`) can raise a human-approval `ctx.pause`. The runtime binds it
38
+ * to the current `{ agentId, iteration }` when it builds a ProcessorContext.
39
+ * Absent ⇒ a processor pause throws (no boundary; see `boundProcessorPause`). */
40
+ pause?: BoundaryPauseFn;
34
41
  /** Optional JSON schema for runtimes with native structured-output support. */
35
42
  outputFormat?: { type: "json_schema"; schema: Record<string, unknown> };
36
43
  }
@@ -37,7 +37,7 @@
37
37
  import type { SandboxProvider } from "./sandbox.js";
38
38
  import { defineWorkflow } from "./workflow.js";
39
39
  import type { Workflow } from "../workflow-steps/types.js";
40
- import type { SnapshotConfig } from "./workflow-metadata.js";
40
+ import type { SandboxResources, SnapshotConfig } from "./workflow-metadata.js";
41
41
 
42
42
  export interface SandboxEnvironmentDefinition {
43
43
  name: string;
@@ -48,6 +48,11 @@ export interface SandboxEnvironmentDefinition {
48
48
  * useful — an env with no snapshot can't be referenced as a
49
49
  * `bootFrom` on another workflow). */
50
50
  snapshots?: SnapshotConfig;
51
+ /** Which substrate to build this environment on (and any sizing).
52
+ * An env image is provider-specific — a snapshot captured on E2B
53
+ * can't boot on Vercel and vice-versa — so building the E2B base/
54
+ * agent-env requires `resources: { provider: "e2b" }`. */
55
+ resources?: SandboxResources;
51
56
  }
52
57
 
53
58
  /** Sugar over `defineWorkflow` for setup-only workflows that exist to
@@ -61,7 +66,13 @@ export function defineSandboxEnvironment(
61
66
  }
62
67
  return defineWorkflow<void, Record<string, unknown>>({
63
68
  ...(env.description !== undefined ? { description: env.description } : {}),
69
+ ...(env.resources !== undefined ? { resources: env.resources } : {}),
64
70
  snapshots: env.snapshots ?? { saveLatest: true },
71
+ // Mark this as an environment build so the server skips the /factory mount
72
+ // for its runs — an env build builds a platform image and never touches the
73
+ // shared drive; baking a live Archil mount into its snapshot breaks the
74
+ // re-mount of every workflow that later boots from it (#13).
75
+ environmentBuild: true,
65
76
  run: async (_ctx, sandbox) => env.setup(sandbox),
66
77
  });
67
78
  }
@@ -25,6 +25,35 @@ export interface SandboxCommandResult {
25
25
  stderr: string;
26
26
  }
27
27
 
28
+ /** A spawned long-lived command with a writable stdin and readable stdout,
29
+ * exposed as byte web-streams. Unlike `commands.run` (which buffers to
30
+ * completion and exposes stdout only via an `onStdout` callback), a duplex
31
+ * handle keeps the process alive and lets the caller WRITE to its stdin —
32
+ * the half `commands.run` cannot provide. It is the transport the ACP client
33
+ * (`AcpClientPeer` over `ndJsonStream`) needs: the agent CLI reads JSON-RPC
34
+ * request frames on stdin and answers on stdout.
35
+ *
36
+ * Only `makeLocalSandboxProvider` implements it. The runner runs IN the
37
+ * sandbox VM and spawns CLIs via the local provider (a plain `child_process`
38
+ * pipe), so a duplex stdin works identically on Vercel/E2B/local. The
39
+ * vercel/e2b providers are the SERVER→sandbox view and never spawn the in-VM
40
+ * CLI, so they leave `spawnDuplex` undefined and callers fall back cleanly. */
41
+ export interface SandboxDuplexProcess {
42
+ /** Subprocess stdin. JSON-RPC request frames are written here. */
43
+ stdin: WritableStream<Uint8Array>;
44
+ /** Subprocess stdout. JSON-RPC response/notification frames arrive here. */
45
+ stdout: ReadableStream<Uint8Array>;
46
+ /** Resolves when the subprocess exits, carrying the captured stderr tail. */
47
+ exited: Promise<{ exitCode: number; stderr: string }>;
48
+ /** Force-terminate the subprocess. */
49
+ kill(): void;
50
+ }
51
+
52
+ export interface SandboxSpawnDuplexOptions {
53
+ cwd?: string;
54
+ envs?: Record<string, string>;
55
+ }
56
+
28
57
  /**
29
58
  * A sandbox provider implements the RAW provider operations only. It does NOT
30
59
  * implement transient-failure retry/backoff: reconnecting and snapshotting both
@@ -51,6 +80,11 @@ export interface SandboxProvider {
51
80
  // `run(sb, cmd)` helper is the canonical pattern — copy it into any
52
81
  // setup workflow that needs to fail loudly on command errors.
53
82
  run(cmd: string, opts?: SandboxCommandRunOptions): Promise<SandboxCommandResult>;
83
+ /** Spawn a long-lived command with a real duplex stdin/stdout. OPTIONAL —
84
+ * implemented only by `makeLocalSandboxProvider` (the in-VM `child_process`
85
+ * view). The vercel/e2b providers (server→sandbox) leave it undefined; an
86
+ * ACP caller that finds it absent falls back to the JSONL transport. */
87
+ spawnDuplex?(cmd: string, opts?: SandboxSpawnDuplexOptions): SandboxDuplexProcess;
54
88
  };
55
89
  files: {
56
90
  write(path: string, content: string): Promise<void>;
@@ -65,12 +99,12 @@ export interface SandboxProvider {
65
99
  * be omitted when the provider doesn't expose it; the server stores
66
100
  * `null` for missing values rather than estimating. */
67
101
  snapshot?(): Promise<{ snapshotId: string; sizeBytes?: number }>;
68
- /** Replace the live sandbox's egress policy in place. Vercel implements
69
- * it via `sandbox.update({ networkPolicy })` (2.x) so the server can
70
- * push a freshly resolved policy — with re-minted connector access
71
- * tokens — before each step instead of relying on the policy baked at
72
- * create. Providers whose enforcement lives inside the VM (E2B
73
- * iron-proxy) leave it undefined. */
102
+ /** Replace the live sandbox's egress policy in place — so the server can
103
+ * push a freshly resolved policy (with re-minted connector access tokens)
104
+ * before each step instead of relying on the policy baked at create.
105
+ * Vercel implements it via `sandbox.update({ networkPolicy })` (2.x); E2B
106
+ * via its native `sandbox.updateNetwork(...)`. Providers without a live
107
+ * network-update primitive leave it undefined. */
74
108
  updateNetworkPolicy?(policy: SandboxNetworkPolicy): Promise<void>;
75
109
  }
76
110
 
@@ -9,9 +9,15 @@
9
9
  * `workflow-steps/workflow.ts` — it is the cycle-break point.
10
10
  */
11
11
 
12
- import type { SandboxNetworkPolicy } from "../sandbox.js";
12
+ import type { SandboxNetworkPolicy, SandboxSize, SandboxProviderName } from "../sandbox.js";
13
13
  import type { Processor } from "../processors/processor.js";
14
14
 
15
+ /** The sandbox providers selectable per-workflow. A narrowing of
16
+ * `SandboxProviderName` to the two substrates that execute step workloads —
17
+ * `e2b-desktop` (registry-only, never a workflow's runtime) is deliberately
18
+ * excluded so a workflow can only ask for a substrate that actually runs steps. */
19
+ export type WorkflowSandboxProvider = Extract<SandboxProviderName, "vercel" | "e2b">;
20
+
15
21
  /**
16
22
  * Workflow-level metadata read by the server at registration. Lives on
17
23
  * every `Workflow` as `workflow.metadata`, regardless of which form of
@@ -77,11 +83,14 @@ export interface IOSchema {
77
83
  * output-only release. New code should prefer `IOSchema`. */
78
84
  export type OutputSchema = IOSchema;
79
85
 
80
- /** Per-connector HTTP request matcher (Tier-2 capability narrowing). The
81
- * iron-proxy / firewall consults these when deciding whether to attach the
82
- * brokered Authorization header to an outbound request. A request to the
83
- * connector host whose method is not in `methods`, or whose path matches no
84
- * entry in `pathPrefixes`, is refused (403) and the token is WITHHELD. */
86
+ /** Per-connector HTTP request matcher (Tier-2 capability narrowing). Vercel's
87
+ * firewall consults these when deciding whether to attach the brokered
88
+ * Authorization header to an outbound request: a request to the connector
89
+ * host whose method is not in `methods`, or whose path matches no entry in
90
+ * `pathPrefixes`, goes out WITHOUT the token. NOT enforced on E2B — its
91
+ * native rules carry only a header transform, no method/path matcher, so the
92
+ * token rides every request to an allowed connector host there (the host
93
+ * allowlist still confines which hosts are reachable). See `toE2bNetwork`. */
85
94
  export interface ConnectorRequestRules {
86
95
  /** Allowed HTTP methods (upper-case). Omit = any method (subject to
87
96
  * `access`). */
@@ -141,6 +150,25 @@ export interface InvokePolicy {
141
150
  workflows?: string[];
142
151
  }
143
152
 
153
+ /** Sandbox machine resources for a workflow's runs. A coarse size knob
154
+ * today; kept as its own object so finer controls (disk, gpu, …) can be
155
+ * added later without reshaping `WorkflowMetadata`. */
156
+ export interface SandboxResources {
157
+ /** Machine hardware SKU — one of the `SandboxSize` vCPU strings
158
+ * (`2vcpu-4gb` | `4vcpu-8gb` | `8vcpu-16gb` | `32vcpu-64gb`). Maps to
159
+ * provider specs at create time (Vercel: 2 / 4 / 8 / 32 vCPU, 2048 MB RAM
160
+ * per vCPU). Omit → the smallest SKU. E2B sizing is template-defined and
161
+ * ignores this. */
162
+ size?: SandboxSize;
163
+ /** Sandbox provider this workflow's runs execute on — `"vercel"` or
164
+ * `"e2b"`. Optional and additive: omit and the run resolves to the
165
+ * platform default (`vercel`). Set explicitly to pin a workflow to a
166
+ * substrate regardless of the deployment default. Snapshot formats are
167
+ * provider-specific, so the resolved provider is stamped on the run row
168
+ * at first dispatch and reused across pause/resume/replay. */
169
+ provider?: WorkflowSandboxProvider;
170
+ }
171
+
144
172
  export interface WorkflowMetadata {
145
173
  /** One-line, human-readable description of what the workflow does.
146
174
  * Surfaced on the dashboard template tile + run page header. Authors
@@ -161,6 +189,9 @@ export interface WorkflowMetadata {
161
189
  outputSchema?: IOSchema;
162
190
  /** All snapshot config — boot source + capture mode. */
163
191
  snapshots?: SnapshotConfig;
192
+ /** Sandbox machine resources — size + provider. Optional; omit → smallest
193
+ * SKU on the platform-default provider (`vercel`). */
194
+ resources?: SandboxResources;
164
195
  processors?: readonly Processor[];
165
196
  /** Connector requirements — providers whose APIs this workflow calls.
166
197
  * Dispatch resolves an authorized grant per provider and injects a
@@ -172,6 +203,18 @@ export interface WorkflowMetadata {
172
203
  /** Tier-1 invoke ACL — who may dispatch this connector-brokering workflow.
173
204
  * See `InvokePolicy`. */
174
205
  invokePolicy?: InvokePolicy;
206
+ /** Set by `defineSandboxEnvironment` to mark this workflow as an ENVIRONMENT
207
+ * BUILD — a setup-only workflow whose job is to leave its VM configured and
208
+ * snapshot it (base-env / agent-env). Environment builds build a platform
209
+ * IMAGE and never use the shared factory drive, so the server SKIPS mounting
210
+ * /factory for them: a live Archil mount baked into the captured snapshot
211
+ * fails the NEXT boot's re-mount ("an older Archil process is still running
212
+ * for this mountpoint"), degrading /factory for every workflow booting from
213
+ * that snapshot. Absent on ordinary workflows — which mount /factory exactly
214
+ * as before. Optional + additive: an ABSENT flag contributes nothing to the
215
+ * canonical metadata hash (frozen-metadata rule), so existing workflows are
216
+ * not forced to re-register. */
217
+ environmentBuild?: boolean;
175
218
  }
176
219
 
177
220
  /**
@@ -198,10 +241,12 @@ export function extractMetadata(source: Partial<WorkflowMetadata>): WorkflowMeta
198
241
  if (source.inputSchema !== undefined) out.inputSchema = freezeMetadataValue(source.inputSchema);
199
242
  if (source.outputSchema !== undefined) out.outputSchema = freezeMetadataValue(source.outputSchema);
200
243
  if (source.snapshots !== undefined) out.snapshots = Object.freeze({ ...source.snapshots });
244
+ if (source.resources !== undefined) out.resources = Object.freeze({ ...source.resources });
201
245
  if (source.processors !== undefined) out.processors = Object.freeze([...source.processors]);
202
246
  if (source.connectors !== undefined) out.connectors = freezeMetadataValue(source.connectors);
203
247
  if (source.connectorOperation !== undefined) out.connectorOperation = Object.freeze({ ...source.connectorOperation });
204
248
  if (source.invokePolicy !== undefined) out.invokePolicy = freezeMetadataValue(source.invokePolicy);
249
+ if (source.environmentBuild !== undefined) out.environmentBuild = source.environmentBuild;
205
250
  return Object.freeze(out);
206
251
  }
207
252
 
@@ -37,8 +37,8 @@ export interface AgentEventSink {
37
37
  emit(event: AgentLifecycleEvent): void | Promise<void>;
38
38
  }
39
39
 
40
- import type { SnapshotConfig, BootSnapshot, ReuseSnapshot, IOSchema, OutputSchema } from "./workflow-metadata.js";
41
- export type { SnapshotConfig, BootSnapshot, ReuseSnapshot, IOSchema, OutputSchema };
40
+ import type { SnapshotConfig, BootSnapshot, ReuseSnapshot, IOSchema, OutputSchema, SandboxResources } from "./workflow-metadata.js";
41
+ export type { SnapshotConfig, BootSnapshot, ReuseSnapshot, IOSchema, OutputSchema, SandboxResources };
42
42
 
43
43
  /** Turn/iteration budget for `agent(opts)`. Re-exported here so authors
44
44
  * can type per-invoke budget overrides they pass as workflow input. */
@@ -53,9 +53,16 @@ export interface WorkflowCtx<TInput extends Record<string, unknown> = Record<str
53
53
  /** Persist key-value metadata on the run record (e.g. prUrl, planUrl). */
54
54
  setMetadata: (data: Record<string, unknown>) => Promise<void>;
55
55
  /**
56
- * Wrap a named step for observability. Emits step_started / step_completed /
57
- * step_failed lifecycle events with duration. Use for long phases you want
56
+ * Durable named step (ADR-0012). Runs `fn` once and memoises its result to
57
+ * the sandbox state-dir; on a pause-resume re-entry the recorded value is
58
+ * returned and `fn` is NOT re-run (a duration-0 "restored" sub-step). Also
59
+ * emits substep_started / substep_completed / substep_failed lifecycle
60
+ * events with duration — use for long phases you want both durable and
58
61
  * visible on the run's timeline (setup, external API calls, submit).
62
+ *
63
+ * Names must be unique within a step body (they key the memoise file).
64
+ * A body that pauses is never memoised — the resume re-runs it. For
65
+ * cross-process side effects (DB writes, emails) use `invokeChild`.
59
66
  */
60
67
  step<T>(name: string, fn: () => Promise<T>): Promise<T>;
61
68
  /** Pass to `agent({ events: ctx.agentEvents })` to stream agent lifecycle events. */
@@ -120,6 +127,15 @@ export interface WorkflowDefinition<
120
127
  * `invoke({ snapshots })` overrides this default.
121
128
  */
122
129
  snapshots?: SnapshotConfig;
130
+ /**
131
+ * Sandbox resources — machine SKU (`size`, a `SandboxSize` vCPU string:
132
+ * `2vcpu-4gb` | `4vcpu-8gb` | `8vcpu-16gb` | `32vcpu-64gb`) and `provider`
133
+ * (`vercel` | `e2b`). `size` maps to provider machine specs at create
134
+ * (Vercel: vCPUs, 2048 MB RAM per vCPU); E2B sizing is template-defined and
135
+ * ignores it. Optional; omit → smallest SKU on the default provider.
136
+ * Per-invocation `invoke({ size })` overrides the size.
137
+ */
138
+ resources?: SandboxResources;
123
139
  /**
124
140
  * Outbound network policy for the runner sandbox.
125
141
  * Use "*": [] to allow all traffic while still injecting headers for specific domains.
@@ -191,6 +207,13 @@ export interface WorkflowDefinition<
191
207
  * invokePolicy: { users: "owner", workflows: ["nightly-orchestrator"] }
192
208
  */
193
209
  invokePolicy?: InvokePolicy;
210
+ /**
211
+ * Internal — set by `defineSandboxEnvironment`, not by workflow authors.
212
+ * Marks the workflow as an environment build (base-env / agent-env) so the
213
+ * server skips mounting the shared factory drive for its runs (#13). See
214
+ * `WorkflowMetadata.environmentBuild`.
215
+ */
216
+ environmentBuild?: boolean;
194
217
  }
195
218
 
196
219
  /**
@@ -237,9 +260,7 @@ function compileRunForm<TOutput, TInput extends Record<string, unknown>>(
237
260
  setMetadata: stepCtx.setMetadata,
238
261
  step: stepCtx.step,
239
262
  agentEvents: stepCtx.agentEvents,
240
- checkpoint: stepCtx.checkpoint,
241
263
  pause: stepCtx.pause,
242
- requestDecision: stepCtx.requestDecision,
243
264
  sleep: stepCtx.sleep,
244
265
  waitForEvent: stepCtx.waitForEvent,
245
266
  processors: metadata.processors ?? [],
@@ -25,7 +25,7 @@ import { parse as babelParse } from "@babel/parser";
25
25
  import type { File, ExportDefaultDeclaration, CallExpression, Expression, Statement } from "@babel/types";
26
26
  import { importSourceModule } from "./source-loader.js";
27
27
  import type { SnapshotConfig } from "../types/workflow.js";
28
- import type { IOSchema, OutputSchema, ConnectorRequirements, ConnectorOperationTag, InvokePolicy } from "../types/workflow-metadata.js";
28
+ import type { IOSchema, OutputSchema, ConnectorRequirements, ConnectorOperationTag, InvokePolicy, SandboxResources } from "../types/workflow-metadata.js";
29
29
  import type { SandboxNetworkPolicy } from "../sandbox.js";
30
30
  import { isWorkflow } from "../workflow-steps/workflow.js";
31
31
  import type { Workflow } from "../workflow-steps/types.js";
@@ -98,6 +98,9 @@ export interface BundledWorkflow {
98
98
  /** Snapshot config from the workflow definition — `bootFrom` (where to
99
99
  * restore at run start), `save`, `retain`. */
100
100
  snapshots?: SnapshotConfig;
101
+ /** Sandbox resources declared via `defineWorkflow({ resources })` — machine
102
+ * SKU (`size`) and `provider` (`vercel` | `e2b`). */
103
+ resources?: SandboxResources;
101
104
  workflowPlan: WorkflowPlan;
102
105
  /** Compact JSON-Schema-shaped description of the workflow's input
103
106
  * type. Extracted from the workflow's declared `input` zod schema
@@ -112,6 +115,9 @@ export interface BundledWorkflow {
112
115
  /** Tier-1 invoke ACL — who may dispatch this connector-brokering
113
116
  * workflow (`defineWorkflow({ invokePolicy })`). */
114
117
  invokePolicy?: InvokePolicy;
118
+ /** Set by `defineSandboxEnvironment` — marks an environment build so the
119
+ * server skips the /factory mount for its runs (#13). */
120
+ environmentBuild?: boolean;
115
121
  }
116
122
 
117
123
  async function bundle(path: string, label: string): Promise<string> {
@@ -334,9 +340,11 @@ export async function bundleWorkflow(
334
340
  ...(inputSchema !== undefined ? { inputSchema } : {}),
335
341
  ...(outputSchema !== undefined ? { outputSchema } : {}),
336
342
  ...(metadata.snapshots !== undefined ? { snapshots: metadata.snapshots } : {}),
343
+ ...(metadata.resources !== undefined ? { resources: metadata.resources } : {}),
337
344
  ...(metadata.connectors !== undefined ? { connectors: metadata.connectors } : {}),
338
345
  ...(metadata.connectorOperation !== undefined ? { connectorOperation: metadata.connectorOperation } : {}),
339
346
  ...(metadata.invokePolicy !== undefined ? { invokePolicy: metadata.invokePolicy } : {}),
347
+ ...(metadata.environmentBuild !== undefined ? { environmentBuild: metadata.environmentBuild } : {}),
340
348
  };
341
349
  }
342
350
 
@@ -32,15 +32,21 @@
32
32
  import type { AgentLifecycleEvent } from "../agent/agent-loop.js";
33
33
  import type { AgentEventSink } from "../types/workflow.js";
34
34
  import type { LiveAgentEventEmitter } from "./run-callback.js";
35
- import { PauseSignal, isPauseSignal } from "../pause/pause-core.js";
35
+ import { isPauseSignal } from "../pause/pause-core.js";
36
+ import type { ScopedMemoize } from "../pause/checkpoint.js";
36
37
 
37
38
  /** One named sub-step (from `ctx.step("name", async () => ...)`).
38
- * Becomes a `workflow_substep_*` lifecycle event on the run timeline. */
39
+ * Becomes a `workflow_substep_*` lifecycle event on the run timeline.
40
+ *
41
+ * `restored` is the durable-step resume case (ADR-0012): the body did NOT
42
+ * re-run — its memoised result was read from the state-dir checkpoint a
43
+ * prior subprocess wrote. Always `durationMs: 0` (no work happened this
44
+ * pass) and never carries `error`. */
39
45
  export interface SubStepEvent {
40
46
  name: string;
41
47
  startedAt: number;
42
48
  durationMs: number;
43
- status: "completed" | "failed";
49
+ status: "completed" | "failed" | "restored";
44
50
  /** Present when status="failed" — the user error's message. */
45
51
  error?: string;
46
52
  }
@@ -76,22 +82,60 @@ export class StepObservabilityCollector {
76
82
  private events: AgentLifecycleEvent[] = [];
77
83
  private subSteps: SubStepEvent[] = [];
78
84
  private readonly liveEmitter?: LiveAgentEventEmitter;
85
+ /** State-dir memoise bound to this step's `step<idx>` scope. Present in
86
+ * the sandbox runner (durable `ctx.step`); absent in-process so unit
87
+ * tests record observability without touching disk. */
88
+ private readonly memoize?: ScopedMemoize;
89
+ /** Sub-step names already used in THIS step body. A durable `ctx.step`
90
+ * memoises by name, so a reused name would alias two distinct bodies
91
+ * onto one checkpoint file — guarded by throwing on reuse (ADR-0012). */
92
+ private readonly usedStepNames: Set<string> = new Set();
79
93
  /** Seqs of events the server confirmed via the live route. */
80
94
  private readonly ackedSeqs: Set<number> = new Set();
81
95
  /** Promises for in-flight live emits — awaited at snapshot time. */
82
96
  private readonly inFlight: Set<Promise<void>> = new Set();
83
97
 
84
- constructor(opts: { liveEmitter?: LiveAgentEventEmitter } = {}) {
98
+ constructor(opts: { liveEmitter?: LiveAgentEventEmitter; memoize?: ScopedMemoize } = {}) {
85
99
  this.liveEmitter = opts.liveEmitter;
100
+ this.memoize = opts.memoize;
86
101
  }
87
102
 
88
103
  readonly setMetadata = async (data: Record<string, unknown>): Promise<void> => {
89
104
  Object.assign(this.metadata, data);
90
105
  };
91
106
 
107
+ /**
108
+ * Durable named sub-step (ADR-0012). The body's result is memoised to the
109
+ * state-dir checkpoint keyed `step<idx>.<name>`; on a pause-resume re-entry
110
+ * the value is read from disk and `fn` is NOT re-run (recorded as a
111
+ * duration-0 `"restored"` sub-step). A body that PAUSES (PauseSignal)
112
+ * propagates BEFORE any memoise write — partial work is never stored, so
113
+ * the resume re-runs it.
114
+ *
115
+ * When no memoise store is wired (in-process tests), the body always runs
116
+ * and is recorded as a normal timed `"completed"` / `"failed"` sub-step.
117
+ */
92
118
  readonly step = async <T>(name: string, fn: () => Promise<T>): Promise<T> => {
119
+ if (this.usedStepNames.has(name)) {
120
+ throw new Error(
121
+ `ctx.step("${name}", …) reuses a step name already used in this step body. ` +
122
+ `Durable step names must be unique — they key the resume-time memoise file.`,
123
+ );
124
+ }
125
+ this.usedStepNames.add(name);
126
+
93
127
  const startedAt = Date.now();
94
128
  try {
129
+ if (this.memoize) {
130
+ const { value, restored } = await this.memoize(name, fn);
131
+ this.subSteps.push({
132
+ name,
133
+ startedAt,
134
+ durationMs: restored ? 0 : Date.now() - startedAt,
135
+ status: restored ? "restored" : "completed",
136
+ });
137
+ return value;
138
+ }
95
139
  const result = await fn();
96
140
  this.subSteps.push({
97
141
  name,
@@ -102,7 +146,9 @@ export class StepObservabilityCollector {
102
146
  return result;
103
147
  } catch (err) {
104
148
  // A pause inside ctx.step is control flow, not a failed sub-step — let
105
- // it propagate untouched so serveStep emits the pause sentinel.
149
+ // it propagate untouched so serveStep emits the pause sentinel. The
150
+ // memoise write is skipped (it only runs after `fn` resolves), so no
151
+ // partial result is stored — the resume re-enters and re-runs the body.
106
152
  if (isPauseSignal(err)) throw err;
107
153
  this.subSteps.push({
108
154
  name,
@@ -29,7 +29,7 @@ import type { SandboxProvider } from "../types/sandbox.js";
29
29
  import type { WorkflowRun, WorkflowCtx } from "../types/workflow.js";
30
30
  import { StepObservabilityCollector, type StepObservability } from "./observability.js";
31
31
  import type { LiveAgentEventEmitter } from "./run-callback.js";
32
- import { scopedCheckpoint } from "../pause/checkpoint.js";
32
+ import { scopedMemoize } from "../pause/checkpoint.js";
33
33
  import { corePause, PauseSignal, isPauseSignal, type PauseRequest } from "../pause/pause-core.js";
34
34
  import type { StepPauseRequest } from "../step-invocation/types.js";
35
35
  import { buildPauseWrappers, type PauseFn, type KindedPauseFn } from "../pause/wrappers.js";
@@ -129,7 +129,10 @@ export async function runWorkflowSingleStep(opts: RunWorkflowSingleStepOpts): Pr
129
129
  if (!step) throw new Error(`Step index ${opts.stepIndex} not found in workflow "${opts.workflow.id}"`);
130
130
  const parsedInput = step.input.safeParse(opts.input);
131
131
  if (!parsedInput.success) throw new StepValidationError(step.name, "input", parsedInput.error);
132
- const collector = new StepObservabilityCollector({ liveEmitter: opts.liveAgentEventEmitter });
132
+ const collector = new StepObservabilityCollector({
133
+ liveEmitter: opts.liveAgentEventEmitter,
134
+ memoize: scopedMemoize(`step${opts.stepIndex}`),
135
+ });
133
136
  const coord = { runId: opts.run.id, stepIndex: opts.stepIndex };
134
137
  const pauseWithKind: KindedPauseFn = function <T>(req: PauseRequest<T>, kind: StepPauseRequest["kind"]): Promise<T> {
135
138
  return corePause(req, coord, kind);
@@ -148,7 +151,6 @@ export async function runWorkflowSingleStep(opts: RunWorkflowSingleStepOpts): Pr
148
151
  setMetadata: collector.setMetadata,
149
152
  step: collector.step,
150
153
  agentEvents: collector.agentEvents,
151
- checkpoint: scopedCheckpoint(`step${opts.stepIndex}`),
152
154
  pause,
153
155
  ...buildPauseWrappers(pauseWithKind),
154
156
  };
@@ -223,7 +225,10 @@ export async function runWorkflowSteps<TInput, TOutput>(
223
225
  throw err;
224
226
  }
225
227
 
226
- const collector = new StepObservabilityCollector({ liveEmitter: opts.liveAgentEventEmitter });
228
+ const collector = new StepObservabilityCollector({
229
+ liveEmitter: opts.liveAgentEventEmitter,
230
+ memoize: scopedMemoize(`step${i}`),
231
+ });
227
232
  const coord = { runId: run.id, stepIndex: i };
228
233
  const pauseWithKind: KindedPauseFn = function <T>(req: PauseRequest<T>, kind: StepPauseRequest["kind"]): Promise<T> {
229
234
  return corePause(req, coord, kind);
@@ -242,7 +247,6 @@ export async function runWorkflowSteps<TInput, TOutput>(
242
247
  setMetadata: collector.setMetadata,
243
248
  step: collector.step,
244
249
  agentEvents: collector.agentEvents,
245
- checkpoint: scopedCheckpoint(`step${i}`),
246
250
  pause,
247
251
  ...buildPauseWrappers(pauseWithKind),
248
252
  };
@@ -38,14 +38,18 @@ export interface StepContext<TInput = unknown> extends BaseExecutionContext {
38
38
  */
39
39
  setMetadata(data: Record<string, unknown>): Promise<void>;
40
40
  /**
41
- * Wrap a named sub-step for observability. Emits
42
- * `workflow_substep_started` / `workflow_substep_completed` /
43
- * `workflow_substep_failed` lifecycle events on the run timeline with
44
- * the measured `durationMs`. Use for long sub-phases inside one step
45
- * (setup, external API call, submit).
41
+ * Durable named sub-step (ADR-0012). Runs `fn` once and memoises its
42
+ * result to the state-dir checkpoint keyed `step<idx>.<name>`; on a
43
+ * pause-resume re-entry the value is read from disk and `fn` is NOT
44
+ * re-run. Emits `workflow_substep_started` /
45
+ * `workflow_substep_completed` / `workflow_substep_failed` lifecycle
46
+ * events on the run timeline with the measured `durationMs` — a restored
47
+ * sub-step reports `durationMs: 0` and status `"restored"`.
46
48
  *
47
- * The block runs even if observability flushing fails — instrumentation
48
- * never breaks the workflow.
49
+ * Names must be unique within one step body — they key the memoise file,
50
+ * so a reused name throws. A body that pauses (PauseSignal) is never
51
+ * memoised: the resume re-enters and re-runs it. For cross-process side
52
+ * effects (DB writes, emails) use `invokeChild`, not `step`.
49
53
  */
50
54
  step<T>(name: string, fn: () => Promise<T>): Promise<T>;
51
55
  /**
@@ -23,7 +23,7 @@ import type { z } from "zod";
23
23
  import type { Step, Workflow } from "./types.js";
24
24
  import { WORKFLOW_BRAND } from "./types.js";
25
25
  import { extractMetadata } from "../types/workflow-metadata.js";
26
- import type { SnapshotConfig } from "../types/workflow-metadata.js";
26
+ import type { SnapshotConfig, SandboxResources } from "../types/workflow-metadata.js";
27
27
  import type { SandboxNetworkPolicy } from "../sandbox.js";
28
28
  import type { Processor } from "../processors/processor.js";
29
29
 
@@ -51,6 +51,10 @@ export interface StepWorkflowDefinition<TInput, TOutput> {
51
51
  networkPolicy?: SandboxNetworkPolicy;
52
52
  placeholders?: Record<string, string>;
53
53
  snapshots?: SnapshotConfig;
54
+ /** Sandbox resources — machine SKU (`size`, a `SandboxSize` vCPU string)
55
+ * and `provider` (`vercel` | `e2b`). Vercel maps `size` to vCPUs; E2B
56
+ * sizing is template-defined. Omit → smallest SKU on the default provider. */
57
+ resources?: SandboxResources;
54
58
  processors?: readonly Processor[];
55
59
  }
56
60
 
@@ -24,6 +24,12 @@ export function buildInvokeChild(
24
24
  return (name, input, childOpts) => getChildClient().invokeAndWait(name, input, {
25
25
  ...childOpts,
26
26
  parentRunId: runId,
27
- factorySlug: childOpts?.factorySlug ?? opts.defaultFactorySlug ?? process.env.AGENT_COMPOSE_FACTORY_SLUG ?? "default",
27
+ // The runner sets `AGENT_COMPOSE_FACTORY` (the run's factory slug — see
28
+ // activities.ts loadRunnerEnvs). Default the child to it so it lands in the
29
+ // SAME factory as the parent — the per-run API key is factory-scoped, so a
30
+ // child dispatched into another factory 403s. (Was reading the never-set
31
+ // `AGENT_COMPOSE_FACTORY_SLUG`, silently falling back to "default".)
32
+ factorySlug: childOpts?.factorySlug ?? opts.defaultFactorySlug
33
+ ?? process.env.AGENT_COMPOSE_FACTORY ?? process.env.AGENT_COMPOSE_FACTORY_SLUG ?? "default",
28
34
  });
29
35
  }