@agent-compose/sdk 0.8.0 → 0.8.2

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 (44) hide show
  1. package/dist/agent/agent-context.d.ts +1 -1
  2. package/dist/agent/agent-loop.d.ts +8 -0
  3. package/dist/agent/run-agent.d.ts +4 -0
  4. package/dist/client.d.ts +77 -15
  5. package/dist/display.d.ts +16 -0
  6. package/dist/index.d.ts +6 -6
  7. package/dist/index.js +522 -123
  8. package/dist/runtimes/_cli-agent.d.ts +34 -7
  9. package/dist/runtimes/claude-code.d.ts +10 -8
  10. package/dist/runtimes/codex.buildcommand.test.d.ts +9 -0
  11. package/dist/runtimes/codex.d.ts +4 -1
  12. package/dist/runtimes/openai-desktop.js +507 -122
  13. package/dist/sandbox/sizes.d.ts +120 -30
  14. package/dist/sandbox.d.ts +1 -1
  15. package/dist/types/api-conversations.d.ts +198 -0
  16. package/dist/types/api-factory.d.ts +84 -7
  17. package/dist/types/api-runs.d.ts +48 -2
  18. package/dist/types/protocol.d.ts +8 -0
  19. package/dist/types/workflow-metadata.d.ts +14 -5
  20. package/dist/utils/bundler.d.ts +56 -0
  21. package/dist/workflow-steps/workflow.d.ts +7 -0
  22. package/dist/workflows/invoke-child.d.ts +18 -0
  23. package/dist/workflows/invoke-child.test.d.ts +9 -0
  24. package/package.json +2 -2
  25. package/src/agent/agent-context.ts +28 -17
  26. package/src/agent/agent-loop.ts +9 -0
  27. package/src/agent/run-agent.ts +5 -0
  28. package/src/client.ts +201 -30
  29. package/src/display.ts +61 -15
  30. package/src/index.ts +22 -9
  31. package/src/runtimes/_cli-agent.ts +302 -63
  32. package/src/runtimes/claude-code.ts +25 -15
  33. package/src/runtimes/codex.ts +19 -5
  34. package/src/sandbox/providers/e2b.ts +8 -4
  35. package/src/sandbox/sizes.ts +127 -44
  36. package/src/sandbox.ts +8 -0
  37. package/src/types/api-conversations.ts +180 -0
  38. package/src/types/api-factory.ts +89 -7
  39. package/src/types/api-runs.ts +50 -2
  40. package/src/types/protocol.ts +8 -0
  41. package/src/types/workflow-metadata.ts +15 -5
  42. package/src/utils/bundler.ts +213 -3
  43. package/src/workflow-steps/workflow.ts +7 -0
  44. package/src/workflows/invoke-child.ts +47 -11
@@ -10,9 +10,19 @@
10
10
  import type { SandboxNetworkPolicy, SandboxSize } from "../sandbox.js";
11
11
  import type { SnapshotConfig } from "./workflow-metadata.js";
12
12
  import type { RunContext } from "./api-scopes.js";
13
+ import type { BundledWorkflow } from "../utils/bundler.js";
13
14
 
14
15
  export type RunState = "running" | "success" | "failed" | "abandoned" | "canceled";
15
16
 
17
+ /** INLINE invoke payload (`POST /factories/:slug/invoke`): everything
18
+ * `bundleWorkflow` produced — source, the REQUIRED manifest binding the
19
+ * bytes, the workflow plan, and the build fields — plus the name the run
20
+ * reports as its workflow. The server runs it through the same
21
+ * validate/build core as registration but writes NO registry row: the run
22
+ * snapshots the validated source (auditable, replayable) and the name
23
+ * stays free for real registrations. */
24
+ export type InlineWorkflowPayload = BundledWorkflow & { name: string };
25
+
16
26
  export interface InvokeWorkflowOptions {
17
27
  /** Per-invocation snapshot config override. `snapshots.bootFrom`
18
28
  * replaces the template's boot source; `snapshots.saveLatest` and
@@ -41,13 +51,50 @@ export interface InvokeWorkflowOptions {
41
51
  * with the same key inside the server's dedup window returns the original
42
52
  * run instead of starting a new one (matches `resumePause`'s pattern). */
43
53
  idempotencyKey?: string;
44
- }
54
+ /** Who pays for this run's model calls:
55
+ *
56
+ * - `"default"` (Auto) — the initiating human's connected subscription
57
+ * when one is enabled for workflows, else platform credits;
58
+ * - `"platform"` — always platform credits (metered);
59
+ * - `"subscription"` — REQUIRE the initiating human's plan. If it
60
+ * cannot be honored the invoke FAILS (412) rather than quietly
61
+ * spending credits;
62
+ * - `"byok"` — a factory secret named by `fundingSecret`, injected as
63
+ * the runtime's raw provider key. Never metered.
64
+ *
65
+ * Omitted → the workflow's own default (its `settings.funding`), else
66
+ * Auto. */
67
+ funding?: FundingChoice;
68
+ /** `funding: "byok"` only — the FACTORY SECRET NAME whose value funds the
69
+ * run. Must be a provider key variable (ANTHROPIC_API_KEY,
70
+ * OPENAI_API_KEY, CODEX_API_KEY, OPENROUTER_API_KEY) — that is where the
71
+ * sandbox's runtimes read it. The name only; the value never leaves the
72
+ * server's secret store. */
73
+ fundingSecret?: string;
74
+ }
75
+
76
+ /** Inference-funding choice for a run — a lane the caller PINS, or
77
+ * `"default"` (Auto, the automatic ladder). Mirrors the same vocabulary
78
+ * cloud sessions use. */
79
+ export type FundingChoice = "default" | "platform" | "subscription" | "byok";
45
80
 
46
81
  export interface InvokeAndWaitOptions extends InvokeWorkflowOptions {
47
82
  timeoutMs?: number;
48
83
  pollIntervalMs?: number;
49
84
  }
50
85
 
86
+ /** Options for `invokeInline` — the named-invoke options plus the run-title
87
+ * override (an inline run has no registered template to inherit one from). */
88
+ export interface InvokeInlineOptions extends InvokeWorkflowOptions {
89
+ /** Run title override — defaults to the workflow name. */
90
+ title?: string;
91
+ }
92
+
93
+ export interface InvokeInlineAndWaitOptions extends InvokeInlineOptions {
94
+ timeoutMs?: number;
95
+ pollIntervalMs?: number;
96
+ }
97
+
51
98
  export interface InvokeResult {
52
99
  id: string;
53
100
  }
@@ -256,7 +303,8 @@ export interface ListRunsOptions {
256
303
  factorySlug?: string;
257
304
  /** Substring match on the registered workflow name (`metadata._workflow`). */
258
305
  workflow?: string;
259
- /** Substring match across title / task title / branch / run id. */
306
+ /** Substring match across title / task title / branch — or an exact run
307
+ * id (full UUID). */
260
308
  search?: string;
261
309
  outcome?: string;
262
310
  sort?: "newest" | "oldest" | "fastest" | "slowest";
@@ -37,6 +37,12 @@ export interface AgentMessageToolUse extends AgentMessageBase {
37
37
  toolName: string;
38
38
  toolInput: Record<string, unknown>;
39
39
  toolUseId: string;
40
+ /** The spawning subagent call's tool_use id when this call ran INSIDE a
41
+ * subagent (claude stream-json stamps `parent_tool_use_id` on every
42
+ * sidechain event) — lets renderers nest child activity under the
43
+ * Agent/Task call instead of flattening it into the parent transcript.
44
+ * Optional and additive: producers without sidechains omit it. */
45
+ parentToolUseId?: string;
40
46
  }
41
47
 
42
48
  export interface AgentMessageToolResult extends AgentMessageBase {
@@ -54,6 +60,8 @@ export interface AgentMessageToolResult extends AgentMessageBase {
54
60
  /** File locations touched by the tool call (ACP `locations` field), enabling
55
61
  * "follow-along" UI. Optional and additive (WS-C / ADR-0020 Q3). */
56
62
  locations?: { path: string; line?: number }[];
63
+ /** Sidechain attribution, mirroring AgentMessageToolUse.parentToolUseId. */
64
+ parentToolUseId?: string;
57
65
  }
58
66
 
59
67
  export interface AgentMessageDone extends AgentMessageBase {
@@ -154,11 +154,12 @@ export interface InvokePolicy {
154
154
  * today; kept as its own object so finer controls (disk, gpu, …) can be
155
155
  * added later without reshaping `WorkflowMetadata`. */
156
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. */
157
+ /** Machine hardware SKU. The vocabulary is `SANDBOX_SIZES` in
158
+ * `sandbox/sizes.ts` — the single source; do not restate it here or
159
+ * anywhere else. Maps to provider specs at create time: Vercel takes the
160
+ * vCPU count and allocates RAM at 2048 MB/vCPU; E2B has no create-time
161
+ * cpu/mem knob at all, so the size selects a PRE-BUILT per-size template
162
+ * (`E2B_TEMPLATE_SIZES`). Omit → `DEFAULT_SANDBOX_SIZE`. */
162
163
  size?: SandboxSize;
163
164
  /** Sandbox provider this workflow's runs execute on — `"vercel"` or
164
165
  * `"e2b"`. Optional and additive: omit and the run resolves to the
@@ -241,6 +242,14 @@ export interface WorkflowMetadata {
241
242
  * canonical metadata hash (frozen-metadata rule), so existing workflows are
242
243
  * not forced to re-register. */
243
244
  environmentBuild?: boolean;
245
+ /** Whether this workflow's runs need the factory drive. ABSENT ⇒
246
+ * `"required"`: on a drive-backed factory the server treats the /factory
247
+ * mount as load-bearing — a mount failure FAILS the run instead of
248
+ * silently proceeding drive-less. Declare `"none"` for a workflow that
249
+ * genuinely never touches /factory: the server skips the mount entirely
250
+ * for its runs (the explicit no-drive mode; there is no silent degrade).
251
+ * Optional + additive (frozen-metadata rule). */
252
+ factoryDrive?: "required" | "none";
244
253
  }
245
254
 
246
255
  /**
@@ -274,6 +283,7 @@ export function extractMetadata(source: Partial<WorkflowMetadata>): WorkflowMeta
274
283
  if (source.connectorOperation !== undefined) out.connectorOperation = Object.freeze({ ...source.connectorOperation });
275
284
  if (source.invokePolicy !== undefined) out.invokePolicy = freezeMetadataValue(source.invokePolicy);
276
285
  if (source.environmentBuild !== undefined) out.environmentBuild = source.environmentBuild;
286
+ if (source.factoryDrive !== undefined) out.factoryDrive = source.factoryDrive;
277
287
  return Object.freeze(out);
278
288
  }
279
289
 
@@ -18,9 +18,23 @@
18
18
  * parses or imports user source — it only validates the structured manifest
19
19
  * this function returns alongside the bundled bytes, and cross-checks the
20
20
  * manifest's `sourceHash` against the source it received.
21
+ *
22
+ * Module resolution carries two more layers, because most workflow source is
23
+ * now written by an AGENT and the bundler's error is the only feedback it
24
+ * gets (the Workflow Studio's agent authored `import … from "agentc/sdk"`
25
+ * and prod answered with bun's `Maybe you need to "bun install"?` — advice
26
+ * nobody could act on inside a sandbox with no package.json):
27
+ *
28
+ * - TOLERATE (`SDK_SPECIFIER_ALIASES` + `sdkAliasPlugin`) — near-miss
29
+ * spellings of `@agent-compose/sdk` resolve to the real package, so a
30
+ * draft already written with the wrong one builds unedited.
31
+ * - SELF-CORRECT (`explainBundleFailure`) — anything that still fails to
32
+ * resolve produces an error NAMING the real package, which an agent
33
+ * reading its own tool error can fix on the next turn.
21
34
  */
22
35
 
23
36
  import { createHash } from "node:crypto";
37
+ import { dirname } from "node:path";
24
38
  import { parse as babelParse } from "@babel/parser";
25
39
  import type { File, ExportDefaultDeclaration, CallExpression, Expression, Statement } from "@babel/types";
26
40
  import { importSourceModule } from "./source-loader.js";
@@ -121,11 +135,191 @@ export interface BundledWorkflow {
121
135
  /** Set by `defineSandboxEnvironment` — marks an environment build so the
122
136
  * server skips the /factory mount for its runs (#13). */
123
137
  environmentBuild?: boolean;
138
+ /** Drive requirement declared via `defineWorkflow({ factoryDrive })`.
139
+ * Absent ⇒ `"required"` (a failed /factory mount fails the run);
140
+ * `"none"` = explicit no-drive opt-out. */
141
+ factoryDrive?: "required" | "none";
142
+ }
143
+
144
+ /** The one true import specifier for the platform SDK. */
145
+ export const SDK_PACKAGE = "@agent-compose/sdk";
146
+
147
+ /**
148
+ * Import specifiers that can only have MEANT `@agent-compose/sdk`, rewritten
149
+ * to it at bundle time so a draft written with the wrong spelling builds
150
+ * unedited.
151
+ *
152
+ * Every entry carries an `sdk` segment or suffix, so none of them can be a
153
+ * real third-party package a workflow might legitimately depend on: `a/b`
154
+ * forms are subpaths of packages that don't exist, and the `*-sdk` forms
155
+ * name this platform explicitly. Bare `agentc` / `agent-compose` are
156
+ * deliberately NOT aliased — those are plausible npm package names, and
157
+ * silently redirecting a real dependency is worse than a clear error.
158
+ *
159
+ * Exported for the unit test that pins the table.
160
+ */
161
+ export const SDK_SPECIFIER_ALIASES: readonly string[] = [
162
+ "agentc/sdk",
163
+ "@agentc/sdk",
164
+ "agentc-sdk",
165
+ "agent-compose/sdk",
166
+ "agentcompose/sdk",
167
+ "@agentcompose/sdk",
168
+ "agent-compose-sdk",
169
+ ];
170
+
171
+ const SDK_ALIAS_SET = new Set(SDK_SPECIFIER_ALIASES);
172
+
173
+ /** `@agent-compose/sdk` when `specifier` is a known near-miss for it, else
174
+ * null. The single authority: the plugin's regex filter is only a fast
175
+ * pre-filter, and this decides. */
176
+ export function resolveSdkAlias(specifier: string): string | null {
177
+ return SDK_ALIAS_SET.has(specifier) ? SDK_PACKAGE : null;
178
+ }
179
+
180
+ /** Minimal shape of the Bun surface this module drives. Accessed via
181
+ * globalThis so the SDK keeps no compile-time dependency on @types/bun. */
182
+ interface BunBuildLog { message: string; name?: string; specifier?: string }
183
+ interface BunSurface {
184
+ build(opts: {
185
+ entrypoints: string[]; format: string; target: string;
186
+ plugins?: { name: string; setup(build: BunPluginBuild): void }[];
187
+ }): Promise<{ success: boolean; outputs: { text(): Promise<string> }[]; logs: BunBuildLog[] }>;
188
+ resolveSync(specifier: string, parent: string): string;
189
+ }
190
+ interface BunPluginBuild {
191
+ onResolve(
192
+ constraints: { filter: RegExp; namespace?: string },
193
+ callback: (args: { path: string; importer?: string; resolveDir?: string }) => { path: string } | undefined,
194
+ ): void;
195
+ }
196
+
197
+ /** Anchored regex over the alias table — the plugin filter. Specifiers are
198
+ * literal package names, but escape anyway so a future entry with a `.`
199
+ * or `+` can't widen the filter. */
200
+ function aliasFilter(): RegExp {
201
+ const alternation = SDK_SPECIFIER_ALIASES
202
+ .map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
203
+ .join("|");
204
+ return new RegExp(`^(?:${alternation})$`);
205
+ }
206
+
207
+ /**
208
+ * The TOLERATE layer: a Bun resolve plugin that maps every known near-miss
209
+ * specifier onto the real SDK. Resolution goes through Bun's own resolver
210
+ * from the importing file's directory, so the alias lands on exactly the
211
+ * `@agent-compose/sdk` the build would have used had the source spelled it
212
+ * correctly. When the real SDK can't be resolved either, the plugin declines
213
+ * and Bun's failure flows into `explainBundleFailure` below.
214
+ */
215
+ function sdkAliasPlugin(bun: BunSurface): { name: string; setup(build: BunPluginBuild): void } {
216
+ return {
217
+ name: "agent-compose-sdk-alias",
218
+ setup(build) {
219
+ build.onResolve({ filter: aliasFilter() }, (args) => {
220
+ const target = resolveSdkAlias(args.path);
221
+ if (!target) return undefined;
222
+ // `importer` is the importing FILE, `resolveDir` already a directory.
223
+ const from = args.importer ? dirname(args.importer) : args.resolveDir;
224
+ try {
225
+ return { path: bun.resolveSync(target, from && from.length > 0 ? from : process.cwd()) };
226
+ } catch {
227
+ return undefined;
228
+ }
229
+ });
230
+ },
231
+ };
232
+ }
233
+
234
+ /** The unresolved specifier a Bun diagnostic is about, or null when it isn't
235
+ * a resolve failure. `ResolveMessage` carries it as a field; parsing the
236
+ * message text is the fallback. */
237
+ function readUnresolvedSpecifier(diag: unknown): string | null {
238
+ if (!diag || typeof diag !== "object") return null;
239
+ const d = diag as { specifier?: unknown; message?: unknown };
240
+ if (typeof d.specifier === "string" && d.specifier.length > 0) return d.specifier;
241
+ const message = typeof d.message === "string" ? d.message : "";
242
+ const m = /Could not resolve:?\s*"([^"]+)"/.exec(message);
243
+ return m ? m[1] : null;
244
+ }
245
+
246
+ /** Flatten a bundler failure into the diagnostics it actually carries.
247
+ * Bun reports through two channels and both land here: a THROWN
248
+ * `AggregateError("Bundle failed")` whose real messages hide in `.errors`,
249
+ * and a returned `logs` array on `success: false`. */
250
+ function flattenDiagnostics(failure: unknown): unknown[] {
251
+ const out: unknown[] = [];
252
+ const seen = new Set<unknown>();
253
+ const walk = (node: unknown, depth: number): void => {
254
+ if (!node || depth > 4 || seen.has(node)) return;
255
+ seen.add(node);
256
+ if (Array.isArray(node)) {
257
+ for (const sub of node) walk(sub, depth + 1);
258
+ return;
259
+ }
260
+ out.push(node);
261
+ if (typeof node === "object") {
262
+ const n = node as { errors?: unknown; cause?: unknown };
263
+ if (Array.isArray(n.errors)) for (const sub of n.errors) walk(sub, depth + 1);
264
+ if (n.cause) walk(n.cause, depth + 1);
265
+ }
266
+ };
267
+ walk(failure, 0);
268
+ return out;
269
+ }
270
+
271
+ function diagnosticText(diag: unknown): string {
272
+ if (diag instanceof Error) return diag.message || String(diag);
273
+ if (diag && typeof diag === "object" && typeof (diag as { message?: unknown }).message === "string") {
274
+ return (diag as { message: string }).message;
275
+ }
276
+ return String(diag);
277
+ }
278
+
279
+ /**
280
+ * The SELF-CORRECTING layer. Turns a Bun bundle failure into a message that
281
+ * names the real fix instead of leaking Bun's internals.
282
+ *
283
+ * An unresolved import is almost always one thing: workflow source naming
284
+ * the platform SDK by a specifier that isn't its package name. Bun answers
285
+ * that with `Could not resolve: "agentc/sdk". Maybe you need to "bun
286
+ * install"?` — advice the author cannot act on (there is no package.json to
287
+ * install into; the drive holds one file). The rewritten message says the
288
+ * package name, so an agent reading its own tool error can fix the import on
289
+ * the next turn.
290
+ *
291
+ * Non-resolve failures (syntax errors, transform failures) keep their
292
+ * verbatim diagnostics — those are already actionable.
293
+ *
294
+ * Exported for the unit test; `bundleWorkflow` is the supported entrypoint.
295
+ */
296
+ export function explainBundleFailure(err: unknown, label: string): string {
297
+ const diagnostics = flattenDiagnostics(err);
298
+ const unresolved: string[] = [];
299
+ for (const diag of diagnostics) {
300
+ const specifier = readUnresolvedSpecifier(diag);
301
+ if (specifier && !unresolved.includes(specifier)) unresolved.push(specifier);
302
+ }
303
+
304
+ if (unresolved.length > 0) {
305
+ const quoted = unresolved.map((s) => `"${s}"`).join(", ");
306
+ return (
307
+ `Could not resolve ${quoted} — workflow source imports the platform SDK as "${SDK_PACKAGE}". ` +
308
+ `Fix the import specifier in ${label}; anything that is genuinely a third-party ` +
309
+ `package must be installed where the source is bundled.`
310
+ );
311
+ }
312
+
313
+ const detail = diagnostics
314
+ .map(diagnosticText)
315
+ .filter((t) => t.length > 0 && t !== "Bundle failed")
316
+ .join("\n");
317
+ return `Failed to bundle ${label}:\n${detail || "the bundler reported no diagnostics"}`;
124
318
  }
125
319
 
126
320
  async function bundle(path: string, label: string): Promise<string> {
127
321
  // Use globalThis to access Bun without a compile-time dependency on @types/bun.
128
- const bun = globalThis as unknown as { Bun?: { build(opts: { entrypoints: string[]; format: string; target: string }): Promise<{ success: boolean; outputs: { text(): Promise<string> }[]; logs: { message: string }[] }> } };
322
+ const bun = globalThis as unknown as { Bun?: BunSurface };
129
323
  if (!bun.Bun?.build) throw new Error("bundleWorkflow requires the Bun runtime (Bun.build)");
130
324
  // target: "node" — the bundle runs inside the Vercel sandbox under Node 22+.
131
325
  //
@@ -141,9 +335,24 @@ async function bundle(path: string, label: string): Promise<string> {
141
335
  // Pure-ESM workflows (e.g. only importing `@agent-compose/sdk`) bundled
142
336
  // identically under either target — that's why this bug stayed hidden
143
337
  // until the first workflow that pulled CJS deps got dispatched.
144
- const result = await bun.Bun.build({ entrypoints: [path], format: "esm", target: "node" });
338
+ //
339
+ // The alias plugin rewrites known near-miss SDK specifiers before Bun's
340
+ // resolver sees them; whatever still fails to resolve comes back through
341
+ // `explainBundleFailure` naming the real package instead of Bun's
342
+ // "Maybe you need to `bun install`?".
343
+ let result: Awaited<ReturnType<BunSurface["build"]>>;
344
+ try {
345
+ result = await bun.Bun.build({
346
+ entrypoints: [path], format: "esm", target: "node",
347
+ plugins: [sdkAliasPlugin(bun.Bun)],
348
+ });
349
+ } catch (err) {
350
+ // Modern Bun.build THROWS AggregateError("Bundle failed") on a resolve
351
+ // or transform error rather than returning `success: false`.
352
+ throw new WorkflowSourceValidationError(explainBundleFailure(err, label));
353
+ }
145
354
  if (!result.success) {
146
- throw new Error(`Failed to bundle ${label}:\n${result.logs.map((l) => l.message).join("\n")}`);
355
+ throw new WorkflowSourceValidationError(explainBundleFailure(result.logs, label));
147
356
  }
148
357
  return result.outputs[0].text();
149
358
  }
@@ -371,6 +580,7 @@ export async function bundleWorkflow(
371
580
  ...(metadata.connectorOperation !== undefined ? { connectorOperation: metadata.connectorOperation } : {}),
372
581
  ...(metadata.invokePolicy !== undefined ? { invokePolicy: metadata.invokePolicy } : {}),
373
582
  ...(metadata.environmentBuild !== undefined ? { environmentBuild: metadata.environmentBuild } : {}),
583
+ ...(metadata.factoryDrive !== undefined ? { factoryDrive: metadata.factoryDrive } : {}),
374
584
  };
375
585
  }
376
586
 
@@ -77,6 +77,13 @@ export interface StepWorkflowDefinition<TInput, TOutput> {
77
77
  * server skips mounting the shared factory drive for its runs (#13). See
78
78
  * `WorkflowMetadata.environmentBuild`. */
79
79
  environmentBuild?: boolean;
80
+ /** Whether this workflow's runs need the factory drive. Omit (⇒
81
+ * `"required"`) for any workflow that reads or writes /factory: a failed
82
+ * mount then FAILS the run instead of silently proceeding drive-less.
83
+ * Declare `"none"` for a drive-agnostic workflow — the server skips the
84
+ * /factory mount entirely for its runs. See
85
+ * `WorkflowMetadata.factoryDrive`. */
86
+ factoryDrive?: "required" | "none";
80
87
  }
81
88
 
82
89
  export function createStepWorkflow<TInput, TOutput>(
@@ -1,6 +1,32 @@
1
1
  import { AgentComposeClient } from "../client.js";
2
+ import { getActiveStep, nextPauseOrdinalInActiveStep } from "../active-step.js";
2
3
  import type { InvokeChild } from "../types/execution-context.js";
3
4
 
5
+ /**
6
+ * Derive the deterministic per-call `Idempotency-Key` for a `ctx.invokeChild`
7
+ * dispatch: `invoke-child:<parentRunId>:step<stepIndex>:<childName>:<ordinal>`.
8
+ *
9
+ * Replay safety: a replayed step re-runs its body from the top, so the k-th
10
+ * `invokeChild(name)` call in a step re-derives the SAME key (the per-scope
11
+ * ordinal counter lives on the active-step state, which resets identically on
12
+ * every (re-)entry — the `nextPauseOrdinalInActiveStep` pattern). The server's
13
+ * idempotency window then returns the original child run instead of
14
+ * double-dispatching. Outside step execution (local dev, tests) there is no
15
+ * stable coordinate to key on — returns null and the dispatch is unkeyed,
16
+ * exactly the old behavior.
17
+ *
18
+ * Key syntax matches the server's `Idempotency-Key` grammar
19
+ * (`[A-Za-z0-9_\-:.]{1,255}`): runId is a UUID, step index a number, and
20
+ * workflow names are kebab-case.
21
+ */
22
+ export function deriveInvokeChildIdempotencyKey(parentRunId: string, childName: string): string | null {
23
+ const step = getActiveStep();
24
+ if (!step) return null;
25
+ const ordinal = nextPauseOrdinalInActiveStep(`invoke-child:${childName}`);
26
+ if (ordinal === null) return null;
27
+ return `invoke-child:${parentRunId}:step${step.stepIndex}:${childName}:${ordinal}`;
28
+ }
29
+
4
30
  /**
5
31
  * Build the public-API child workflow invoker used by legacy and sandboxed
6
32
  * workflow execution. Provider-backed engines may inject a different
@@ -21,15 +47,25 @@ export function buildInvokeChild(
21
47
  childClient = new AgentComposeClient({ apiKey, baseUrl });
22
48
  return childClient;
23
49
  };
24
- return (name, input, childOpts) => getChildClient().invokeAndWait(name, input, {
25
- ...childOpts,
26
- parentRunId: runId,
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",
34
- });
50
+ return (name, input, childOpts) => {
51
+ // Replay-safe dispatch: derive a deterministic per-step key so a step
52
+ // re-entered after pause/resume (or a crash-replayed attempt) dedupes to
53
+ // the original child run instead of double-dispatching. An explicit
54
+ // caller-supplied key wins.
55
+ const idempotencyKey = childOpts?.idempotencyKey
56
+ ?? deriveInvokeChildIdempotencyKey(runId, name)
57
+ ?? undefined;
58
+ return getChildClient().invokeAndWait(name, input, {
59
+ ...childOpts,
60
+ ...(idempotencyKey !== undefined ? { idempotencyKey } : {}),
61
+ parentRunId: runId,
62
+ // The runner sets `AGENT_COMPOSE_FACTORY` (the run's factory slug — see
63
+ // activities.ts loadRunnerEnvs). Default the child to it so it lands in the
64
+ // SAME factory as the parent — the per-run API key is factory-scoped, so a
65
+ // child dispatched into another factory 403s. (Was reading the never-set
66
+ // `AGENT_COMPOSE_FACTORY_SLUG`, silently falling back to "default".)
67
+ factorySlug: childOpts?.factorySlug ?? opts.defaultFactorySlug
68
+ ?? process.env.AGENT_COMPOSE_FACTORY ?? process.env.AGENT_COMPOSE_FACTORY_SLUG ?? "default",
69
+ });
70
+ };
35
71
  }