@agent-compose/sdk 0.5.0 → 0.5.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 (90) hide show
  1. package/dist/active-step.d.ts +60 -0
  2. package/dist/agent/agent-loop-steer.test.d.ts +1 -0
  3. package/dist/agent/agent-loop.d.ts +46 -0
  4. package/dist/agent/async-queue.d.ts +29 -0
  5. package/dist/agent/protocol.d.ts +9 -1
  6. package/dist/agent/resolve-agent-id.test.d.ts +1 -0
  7. package/dist/agent/run-agent.d.ts +16 -3
  8. package/dist/agent/steer-control.d.ts +57 -0
  9. package/dist/agent/steer-control.test.d.ts +1 -0
  10. package/dist/client.d.ts +161 -0
  11. package/dist/index.d.ts +7 -4
  12. package/dist/index.js +1341 -157
  13. package/dist/pause/__tests__/agent-loop-checkpoint.test.d.ts +1 -0
  14. package/dist/pause/__tests__/checkpoint.test.d.ts +1 -0
  15. package/dist/pause/__tests__/errors.test.d.ts +1 -0
  16. package/dist/pause/__tests__/manager.test.d.ts +1 -0
  17. package/dist/pause/__tests__/pause-core.test.d.ts +1 -0
  18. package/dist/pause/__tests__/state-dir.test.d.ts +1 -0
  19. package/dist/pause/__tests__/wrappers.test.d.ts +1 -0
  20. package/dist/pause/checkpoint.d.ts +28 -0
  21. package/dist/pause/errors.d.ts +52 -0
  22. package/dist/pause/manager.d.ts +63 -0
  23. package/dist/pause/pause-core.d.ts +101 -0
  24. package/dist/pause/state-dir.d.ts +80 -0
  25. package/dist/pause/wrappers.d.ts +41 -0
  26. package/dist/request-context/request-context.d.ts +12 -0
  27. package/dist/runtimes/claude.d.ts +6 -0
  28. package/dist/runtimes/openai-desktop.d.ts +2 -0
  29. package/dist/runtimes/openai-desktop.js +1338 -156
  30. package/dist/runtimes/vercel.d.ts +12 -0
  31. package/dist/runtimes/vercel.js +50 -7
  32. package/dist/runtimes/vercel.test.d.ts +1 -0
  33. package/dist/sse.d.ts +2 -3
  34. package/dist/step-invocation/index.d.ts +2 -2
  35. package/dist/step-invocation/invoker.d.ts +3 -0
  36. package/dist/step-invocation/protocol.d.ts +12 -0
  37. package/dist/step-invocation/server.d.ts +1 -0
  38. package/dist/step-invocation/types.d.ts +40 -5
  39. package/dist/types/events.d.ts +9 -0
  40. package/dist/types/execution-context.d.ts +25 -0
  41. package/dist/types/protocol.d.ts +8 -0
  42. package/dist/types/runtime.d.ts +55 -0
  43. package/dist/types/sandbox.d.ts +6 -1
  44. package/dist/utils/schemas.d.ts +2 -0
  45. package/dist/workflow-steps/__tests__/pause-wiring.test.d.ts +1 -0
  46. package/dist/workflow-steps/index.d.ts +2 -0
  47. package/dist/workflow-steps/observability.d.ts +43 -11
  48. package/dist/workflow-steps/run-callback.d.ts +39 -0
  49. package/dist/workflow-steps/runner.d.ts +8 -0
  50. package/package.json +1 -1
  51. package/src/active-step.ts +124 -0
  52. package/src/agent/agent-loop.ts +253 -19
  53. package/src/agent/async-queue.ts +61 -0
  54. package/src/agent/protocol.ts +12 -2
  55. package/src/agent/run-agent.ts +184 -8
  56. package/src/agent/steer-control.ts +125 -0
  57. package/src/client.ts +277 -0
  58. package/src/index.ts +18 -2
  59. package/src/pause/checkpoint.ts +44 -0
  60. package/src/pause/errors.ts +70 -0
  61. package/src/pause/manager.ts +177 -0
  62. package/src/pause/pause-core.ts +267 -0
  63. package/src/pause/state-dir.ts +262 -0
  64. package/src/pause/wrappers.ts +79 -0
  65. package/src/request-context/request-context.ts +17 -2
  66. package/src/runtimes/claude.ts +101 -6
  67. package/src/runtimes/openai-desktop.ts +11 -0
  68. package/src/runtimes/vercel.ts +26 -0
  69. package/src/sandbox.ts +45 -17
  70. package/src/sse.ts +8 -6
  71. package/src/step-invocation/index.ts +2 -1
  72. package/src/step-invocation/invoker.ts +107 -29
  73. package/src/step-invocation/protocol.ts +16 -0
  74. package/src/step-invocation/server.ts +45 -12
  75. package/src/step-invocation/types.ts +43 -7
  76. package/src/tools/coding.ts +16 -5
  77. package/src/types/events.ts +9 -0
  78. package/src/types/execution-context.ts +25 -0
  79. package/src/types/protocol.ts +8 -0
  80. package/src/types/runtime.ts +52 -0
  81. package/src/types/sandbox.ts +10 -1
  82. package/src/types/workflow.ts +6 -1
  83. package/src/utils/bundler.ts +8 -3
  84. package/src/utils/schemas.ts +2 -0
  85. package/src/workflow-steps/index.ts +3 -0
  86. package/src/workflow-steps/observability.ts +84 -13
  87. package/src/workflow-steps/run-callback.ts +72 -0
  88. package/src/workflow-steps/runner.ts +70 -8
  89. package/dist/utils/discovery.d.ts +0 -2
  90. package/src/utils/discovery.ts +0 -4
package/src/sandbox.ts CHANGED
@@ -118,9 +118,28 @@ interface SandboxProviderDef {
118
118
  // ── E2B helpers ───────────────────────────────────────────────────────────────
119
119
 
120
120
  export function makeSandboxProvider(sb: Sandbox | Desktop): SandboxProvider {
121
+ const commands = sb.commands as { run: (cmd: string, opts?: unknown) => Promise<{ exitCode: number; stdout: string; stderr?: string }> };
121
122
  return {
122
123
  sandboxId: sb.sandboxId,
123
- commands: sb.commands,
124
+ commands: {
125
+ async run(cmd, opts) {
126
+ let stderr = "";
127
+ const result = await commands.run(cmd, {
128
+ ...opts,
129
+ onStderr: (chunk: string) => {
130
+ stderr += chunk;
131
+ opts?.onStderr?.(chunk);
132
+ },
133
+ });
134
+ return {
135
+ exitCode: result.exitCode,
136
+ stdout: result.stdout,
137
+ stderr: typeof (result as { stderr?: unknown }).stderr === "string"
138
+ ? (result as { stderr: string }).stderr
139
+ : stderr,
140
+ };
141
+ },
142
+ },
124
143
  files: {
125
144
  async write(path, content) { await sb.files.write(path, content); },
126
145
  },
@@ -161,7 +180,7 @@ export async function parseSseExecStream(
161
180
  body: ReadableStream<Uint8Array>,
162
181
  opts?: ParseSseExecStreamOptions,
163
182
  ): Promise<SandboxCommandResult> {
164
- let stdout = "", exitCode = 0, exited = false;
183
+ let stdout = "", stderr = "", exitCode = 0, exited = false;
165
184
  const reader = body.getReader(), decoder = new TextDecoder();
166
185
  let buf = "";
167
186
  while (true) {
@@ -173,9 +192,10 @@ export async function parseSseExecStream(
173
192
  for (const line of lines) {
174
193
  if (!line.startsWith("data: ")) continue;
175
194
  let event: SseEvent;
176
- try { event = JSON.parse(line.slice(6)) as SseEvent; } catch { continue; }
195
+ try { event = JSON.parse(line.slice(6)) as SseEvent; }
196
+ catch (err) { throw new Error(`Malformed sandbox exec event: ${err instanceof Error ? err.message : String(err)}`); }
177
197
  if (event.type === "stdout") { stdout += event.data ?? ""; opts?.onStdout?.(event.data ?? ""); }
178
- else if (event.type === "stderr") { opts?.onStderr?.(event.data ?? ""); }
198
+ else if (event.type === "stderr") { stderr += event.data ?? ""; opts?.onStderr?.(event.data ?? ""); }
179
199
  else if (event.type === "exit") { exitCode = event.exitCode ?? 0; exited = true; }
180
200
  else if (event.type === "error") { throw new Error(`Sandbox exec error: ${event.data}`); }
181
201
  }
@@ -183,7 +203,8 @@ export async function parseSseExecStream(
183
203
  // QUIC tunnels can delay the stream-end signal even after all data has arrived.
184
204
  if (exited) break;
185
205
  }
186
- return { exitCode, stdout };
206
+ if (!exited) throw new Error("Sandbox exec stream ended without an exit event");
207
+ return { exitCode, stdout, stderr };
187
208
  }
188
209
 
189
210
  // ── Vercel helpers ────────────────────────────────────────────────────────────
@@ -200,27 +221,28 @@ function makeVercelSandboxProvider(sb: any, globalEnvs?: Record<string, string>)
200
221
  sandboxId: sb.sandboxId,
201
222
  commands: {
202
223
  async run(cmd, opts) {
203
- if (opts?.background) {
204
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
205
- void (sb.runCommand({ cmd: "sh", args: ["-c", cmd], cwd: opts.cwd, env: mergeEnvs(opts.envs), detached: true }) as Promise<any>)
206
- .catch((err: unknown) => console.error(`[sandbox] background command failed: ${err instanceof Error ? err.message : String(err)}`));
207
- return { exitCode: 0, stdout: "" };
208
- }
209
224
  const signal = opts?.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined;
210
225
  let stdout = "";
226
+ let stderr = "";
227
+ // `sudo: true` is Vercel's native root flag — applied to the `sh`
228
+ // invocation so the whole shell (and its children) runs as root.
211
229
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
212
- const handle: any = await sb.runCommand({ cmd: "sh", args: ["-c", cmd], cwd: opts?.cwd, env: mergeEnvs(opts?.envs), detached: true, signal });
230
+ const handle: any = await sb.runCommand({ cmd: "sh", args: ["-c", cmd], cwd: opts?.cwd, env: mergeEnvs(opts?.envs), detached: true, signal, ...(opts?.sudo ? { sudo: true } : {}) });
213
231
  // Reconnect to the already-running command on transient stream failures (e.g. BrotliDecompressionError).
232
+ // `h.logs()` replays from the start on reconnect, so reset accumulators per
233
+ // attempt to avoid double-counting. The streaming callbacks may still fire
234
+ // for duplicate chunks during retries — an acceptable tradeoff for resilience.
214
235
  await pRetry(async (attempt) => {
215
236
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
216
237
  const h: any = attempt === 1 ? handle : await sb.getCommand(handle.cmdId);
238
+ if (attempt > 1) { stdout = ""; stderr = ""; }
217
239
  for await (const log of h.logs()) {
218
240
  if (log.stream === "stdout") { stdout += log.data; opts?.onStdout?.(log.data); }
219
- else { opts?.onStderr?.(log.data); }
241
+ else { stderr += log.data; opts?.onStderr?.(log.data); }
220
242
  }
221
243
  }, { retries: 3, minTimeout: 1_000, factor: 2 });
222
244
  const finished = await handle.wait();
223
- return { exitCode: finished.exitCode, stdout };
245
+ return { exitCode: finished.exitCode, stdout, stderr };
224
246
  },
225
247
  },
226
248
  files: {
@@ -261,19 +283,25 @@ export function makeLocalSandboxProvider(): SandboxProvider {
261
283
  sandboxId: "local",
262
284
  commands: {
263
285
  run(cmd, opts) {
286
+ // Prepend `sudo` so the command runs as root. The local provider
287
+ // targets the runner's own host VM (used by defineSandboxEnvironment
288
+ // recipes); harmless when the host is already root or has passwordless
289
+ // sudo, which is the only context it runs in.
290
+ const finalCmd = opts?.sudo ? `sudo ${cmd}` : cmd;
264
291
  return new Promise((resolve, reject) => {
265
- const proc = spawn("sh", ["-c", cmd], {
292
+ const proc = spawn("sh", ["-c", finalCmd], {
266
293
  ...(opts?.cwd ? { cwd: opts.cwd } : {}),
267
294
  env: { ...process.env, ...(opts?.envs ?? {}) },
268
295
  stdio: ["ignore", "pipe", "pipe"],
269
296
  });
270
297
  let stdout = "";
298
+ let stderr = "";
271
299
  proc.stdout?.setEncoding("utf8");
272
300
  proc.stderr?.setEncoding("utf8");
273
301
  proc.stdout?.on("data", (chunk: string) => { stdout += chunk; opts?.onStdout?.(chunk); });
274
- proc.stderr?.on("data", (chunk: string) => { opts?.onStderr?.(chunk); });
302
+ proc.stderr?.on("data", (chunk: string) => { stderr += chunk; opts?.onStderr?.(chunk); });
275
303
  proc.on("error", reject);
276
- proc.on("close", (code) => resolve({ exitCode: code ?? 0, stdout }));
304
+ proc.on("close", (code) => resolve({ exitCode: code ?? 0, stdout, stderr }));
277
305
  });
278
306
  },
279
307
  },
package/src/sse.ts CHANGED
@@ -7,9 +7,8 @@
7
7
  * - `event`: event name (from `event:` line; `""` if absent)
8
8
  * - `data`: parsed JSON payload (the SDK's stream events are always JSON)
9
9
  *
10
- * Malformed payloads are silently skipped — same behaviour as the previous
11
- * CLI-side parser. Caller drives the loop via `for await (...)` and is
12
- * responsible for breaking on terminal events.
10
+ * Malformed payloads throw. A dropped terminal event is worse than a loud
11
+ * protocol error for log consumers.
13
12
  *
14
13
  * Output type stays loose (`Record<string, unknown>`) on purpose: typed
15
14
  * unions like `RunEvent` aren't structurally narrowable from
@@ -42,10 +41,13 @@ export async function* parseSseStream(
42
41
  if (line.startsWith("event:")) { event = line.slice(6).trim(); continue; }
43
42
  if (line.startsWith("data:")) { dataLines.push(line.slice(5).trim()); continue; }
44
43
  if (line === "" && dataLines.length > 0) {
44
+ let data: Record<string, unknown>;
45
45
  try {
46
- const data = JSON.parse(dataLines.join("\n")) as Record<string, unknown>;
47
- yield { id: seq, event, data };
48
- } catch { /* malformed — skip */ }
46
+ data = JSON.parse(dataLines.join("\n")) as Record<string, unknown>;
47
+ } catch (err) {
48
+ throw new Error(`Malformed SSE JSON payload for event "${event || "message"}": ${err instanceof Error ? err.message : String(err)}`);
49
+ }
50
+ yield { id: seq, event, data };
49
51
  event = ""; seq = 0; dataLines = [];
50
52
  }
51
53
  }
@@ -20,6 +20,7 @@
20
20
 
21
21
  export {
22
22
  STEP_RESULT_PREFIX,
23
+ STEP_PAUSE_PREFIX,
23
24
  STEP_ENV,
24
25
  RUNNER_BUNDLE_PATH,
25
26
  RUNNER_COMMAND,
@@ -30,4 +31,4 @@ export { invokeStep, parseStepResult, buildStepEnvs } from "./invoker.js";
30
31
  export { serveStep } from "./server.js";
31
32
  export type { StepHandler, ServeStepRequest, StepHandlerResult } from "./server.js";
32
33
  export { StepExecutionError } from "./types.js";
33
- export type { StepRequest, StepResult, StepInvocationError } from "./types.js";
34
+ export type { StepRequest, StepResult, StepInvocationError, StepPauseRequest } from "./types.js";
@@ -22,12 +22,50 @@
22
22
  */
23
23
 
24
24
  import { randomBytes } from "node:crypto";
25
+ import { z } from "zod";
25
26
  import type { SandboxProvider } from "../types/sandbox.js";
26
- import { RUNNER_COMMAND, STEP_ENV, stepResultLinePrefix, requestContextPath, stepInputPath } from "./protocol.js";
27
+ import { RUNNER_COMMAND, STEP_ENV, stepResultLinePrefix, stepPauseLinePrefix, requestContextPath, stepInputPath } from "./protocol.js";
28
+ import { StepPauseRequestSchema } from "./types.js";
27
29
  import type { StepRequest, StepResult } from "./types.js";
30
+ import type { StepObservability } from "../workflow-steps/observability.js";
28
31
 
29
32
  export { RUNNER_COMMAND } from "./protocol.js";
30
33
 
34
+ const StepObservabilitySchema = z.object({
35
+ metadata: z.record(z.string(), z.unknown()).optional(),
36
+ events: z.array(z.unknown()).optional(),
37
+ subSteps: z.array(z.object({
38
+ name: z.string(),
39
+ startedAt: z.number(),
40
+ durationMs: z.number(),
41
+ status: z.enum(["completed", "failed"]),
42
+ error: z.string().optional(),
43
+ })).optional(),
44
+ }).passthrough();
45
+
46
+ const StepResultEnvelopeSchema = z.discriminatedUnion("ok", [
47
+ z.object({
48
+ ok: z.literal(true),
49
+ output: z.unknown().optional(),
50
+ observability: StepObservabilitySchema.optional(),
51
+ }),
52
+ z.object({
53
+ ok: z.literal(false),
54
+ kind: z.enum(["protocol", "user-step"]),
55
+ error: z.string().optional(),
56
+ }),
57
+ ]);
58
+
59
+ /** Wire schema for the pause sentinel — what the runner emits on stdout
60
+ * when user code calls `ctx.pause(...)`. The inner `pauseRequest` schema
61
+ * is the canonical `StepPauseRequestSchema` from types.ts so the runtime
62
+ * type (`StepPauseRequest`) and the parsed shape can't drift. */
63
+ const StepPauseEnvelopeSchema = z.object({
64
+ pauseId: z.string().min(1),
65
+ pauseRequest: StepPauseRequestSchema,
66
+ observability: StepObservabilitySchema.optional(),
67
+ });
68
+
31
69
  /** Build the env map for one step invocation. Pure function; tests use it
32
70
  * to assert the env shape (and to assert that no server-held credentials
33
71
  * leak in) without spawning a sandbox. */
@@ -35,6 +73,7 @@ export function buildStepEnvs(args: {
35
73
  runId: string;
36
74
  stepIndex: number;
37
75
  resultToken: string;
76
+ isResume?: boolean;
38
77
  }): Record<string, string> {
39
78
  return {
40
79
  [STEP_ENV.RUN_ID]: args.runId,
@@ -43,6 +82,7 @@ export function buildStepEnvs(args: {
43
82
  [STEP_ENV.STEP_INPUT_PATH]: stepInputPath(args.stepIndex),
44
83
  [STEP_ENV.REQUEST_CONTEXT_PATH]: requestContextPath(args.stepIndex),
45
84
  [STEP_ENV.STEP_RESULT_TOKEN]: args.resultToken,
85
+ [STEP_ENV.STEP_RESUME]: args.isResume ? "1" : "0",
46
86
  };
47
87
  }
48
88
 
@@ -59,35 +99,61 @@ export function parseStepResult<TOutput = unknown>(
59
99
  stdout: string,
60
100
  resultToken: string,
61
101
  ): StepResult<TOutput> | null {
62
- const prefix = stepResultLinePrefix(resultToken);
63
- const line = stdout.split(/\r?\n/).find((l) => l.startsWith(prefix));
64
- if (!line) return null;
65
- let parsed: {
66
- ok: boolean;
67
- output?: unknown;
68
- kind?: string;
69
- error?: string;
70
- observability?: import("../workflow-steps/observability.js").StepObservability;
71
- };
102
+ // Pause sentinel takes precedence: a step that called `ctx.pause(...)`
103
+ // exits without ever emitting a result, but if the runner did emit a
104
+ // partial / stale result in a prior buffer chunk, the pause sentinel
105
+ // is the load-bearing signal. Scan one pass, dispatch by prefix.
106
+ const resultPrefix = stepResultLinePrefix(resultToken);
107
+ const pausePrefix = stepPauseLinePrefix(resultToken);
108
+ let resultLine: string | undefined;
109
+ let pauseLine: string | undefined;
110
+ for (const line of stdout.split(/\r?\n/)) {
111
+ if (!pauseLine && line.startsWith(pausePrefix)) pauseLine = line;
112
+ if (!resultLine && line.startsWith(resultPrefix)) resultLine = line;
113
+ if (pauseLine && resultLine) break;
114
+ }
115
+
116
+ if (pauseLine) {
117
+ try {
118
+ const raw = JSON.parse(pauseLine.slice(pausePrefix.length));
119
+ const parsed = StepPauseEnvelopeSchema.safeParse(raw);
120
+ if (!parsed.success) {
121
+ return { ok: false, error: { kind: "protocol", message: `step pause schema validation failed: ${parsed.error.message}` } };
122
+ }
123
+ return {
124
+ ok: "paused",
125
+ pauseId: parsed.data.pauseId,
126
+ pauseRequest: parsed.data.pauseRequest,
127
+ ...(parsed.data.observability ? { observability: parsed.data.observability as StepObservability } : {}),
128
+ };
129
+ } catch {
130
+ return { ok: false, error: { kind: "protocol", message: "step pause JSON parse failed" } };
131
+ }
132
+ }
133
+
134
+ if (!resultLine) return null;
135
+ let parsed: z.infer<typeof StepResultEnvelopeSchema>;
72
136
  try {
73
- parsed = JSON.parse(line.slice(prefix.length));
137
+ const raw = JSON.parse(resultLine.slice(resultPrefix.length));
138
+ const result = StepResultEnvelopeSchema.safeParse(raw);
139
+ if (!result.success) {
140
+ const kind = raw && typeof raw === "object" && typeof (raw as { kind?: unknown }).kind === "string"
141
+ ? ` (kind=${(raw as { kind: string }).kind})`
142
+ : "";
143
+ return { ok: false, error: { kind: "protocol", message: `step result schema validation failed${kind}: ${result.error.message}` } };
144
+ }
145
+ parsed = result.data;
74
146
  } catch {
75
147
  return { ok: false, error: { kind: "protocol", message: "step result JSON parse failed" } };
76
148
  }
77
149
  if (parsed.ok) {
78
150
  return parsed.observability
79
- ? { ok: true, output: parsed.output as TOutput, observability: parsed.observability }
151
+ ? { ok: true, output: parsed.output as TOutput, observability: parsed.observability as StepObservability }
80
152
  : { ok: true, output: parsed.output as TOutput };
81
153
  }
82
- const kind: "protocol" | "user-step" = parsed.kind === "user-step" ? "user-step" : "protocol";
154
+ const kind = parsed.kind;
83
155
  const baseMessage = parsed.error ?? "step body failed without a message";
84
- // An unknown kind value means the runner emitted something this invoker
85
- // doesn't recognise — most likely a version-skew deploy. Preserve the
86
- // original kind string in the message so operators see the actual value
87
- // instead of silently re-classifying it as "protocol".
88
- const message = parsed.kind && parsed.kind !== "user-step" && parsed.kind !== "protocol"
89
- ? `(unknown kind "${parsed.kind}") ${baseMessage}`
90
- : baseMessage;
156
+ const message = baseMessage;
91
157
  return { ok: false, error: { kind, message } };
92
158
  }
93
159
 
@@ -101,6 +167,8 @@ export function parseStepResult<TOutput = unknown>(
101
167
  */
102
168
  export interface InvokeStepOptions {
103
169
  envs?: Record<string, string>;
170
+ /** True when re-entering a step after resolving or expiring a pause. */
171
+ isResume?: boolean;
104
172
  /** Live stdout / stderr from the runner subprocess, line-by-line. Called
105
173
  * from inside `sandbox.commands.run` as chunks arrive. The sentinel line
106
174
  * carrying the protocol result token is filtered out before delivery so
@@ -131,17 +199,21 @@ export async function invokeStep<TOutput = unknown>(
131
199
  runId: request.runId,
132
200
  stepIndex: request.stepIndex,
133
201
  resultToken,
202
+ isResume: opts?.isResume,
134
203
  }),
135
204
  };
136
205
 
137
206
  // The sandbox emits stdout as raw chunks, not lines. Buffer between
138
207
  // emissions so a `console.log` split across two chunks (or a partial
139
208
  // trailing line) is delivered to onStdout/onStderr as one logical line.
140
- // The sentinel line (carrying `resultToken`) is filtered out so callers
141
- // never see protocol bytes in user-log capture. The prefix is built
142
- // from `stepResultLinePrefix` — the same helper `parseStepResult` uses,
143
- // so the filter and the parser can't drift.
144
- const sentinelPrefix = stepResultLinePrefix(resultToken);
209
+ // Sentinel lines (carrying `resultToken`) are filtered out so callers
210
+ // never see protocol bytes in user-log capture. Both the result and
211
+ // pause prefixes are built from the same shared helpers `parseStepResult`
212
+ // uses, so the filter and the parser can't drift apart.
213
+ const resultSentinel = stepResultLinePrefix(resultToken);
214
+ const pauseSentinel = stepPauseLinePrefix(resultToken);
215
+ const isSentinel = (line: string) =>
216
+ line.startsWith(resultSentinel) || line.startsWith(pauseSentinel);
145
217
  const makeLineSplitter = (sink: ((line: string) => void) | undefined, filterSentinel: boolean) => {
146
218
  if (!sink) return { onChunk: undefined, flush: () => {} };
147
219
  let buf = "";
@@ -152,7 +224,7 @@ export async function invokeStep<TOutput = unknown>(
152
224
  while ((nl = buf.indexOf("\n")) !== -1) {
153
225
  const line = buf.slice(0, nl);
154
226
  buf = buf.slice(nl + 1);
155
- if (filterSentinel && line.startsWith(sentinelPrefix)) continue;
227
+ if (filterSentinel && isSentinel(line)) continue;
156
228
  sink(line);
157
229
  }
158
230
  },
@@ -164,7 +236,7 @@ export async function invokeStep<TOutput = unknown>(
164
236
  if (buf.length === 0) return;
165
237
  const line = buf;
166
238
  buf = "";
167
- if (filterSentinel && line.startsWith(sentinelPrefix)) return;
239
+ if (filterSentinel && isSentinel(line)) return;
168
240
  sink(line);
169
241
  },
170
242
  };
@@ -188,11 +260,17 @@ export async function invokeStep<TOutput = unknown>(
188
260
  // before emitting" (runner-exit) from "runner exited cleanly but didn't
189
261
  // speak the protocol" (protocol) — the exit code is the evidence.
190
262
  if (result.exitCode !== 0) {
263
+ const stderrTail = result.stderr.trim().slice(-2000);
264
+ const stdoutTail = result.stdout.trim().slice(-2000);
265
+ const details = [
266
+ stderrTail ? `stderr:\n${stderrTail}` : "",
267
+ stdoutTail ? `stdout:\n${stdoutTail}` : "",
268
+ ].filter(Boolean).join("\n");
191
269
  return {
192
270
  ok: false,
193
271
  error: {
194
272
  kind: "runner-exit",
195
- message: `runner subprocess exited ${result.exitCode} before emitting a step result`,
273
+ message: `runner subprocess exited ${result.exitCode} before emitting a step result${details ? `\n${details}` : ""}`,
196
274
  exitCode: result.exitCode,
197
275
  },
198
276
  };
@@ -14,6 +14,13 @@
14
14
  * with the same prefix but a wrong token is rejected. */
15
15
  export const STEP_RESULT_PREFIX = "__AC_STEP_RESULT__";
16
16
 
17
+ /** Parallel sentinel for pause requests. The runner emits this — instead
18
+ * of (not in addition to) a step result — when user code calls
19
+ * `ctx.pause(...)`. Exits cleanly afterwards so the activity can snapshot
20
+ * the now-frozen sandbox and durably wait via Temporal `condition()`.
21
+ * See ADR-0006 §"How it actually pauses — Temporal-native, end-to-end". */
22
+ export const STEP_PAUSE_PREFIX = "__AC_STEP_PAUSE__";
23
+
17
24
  /** Build the line prefix the runner emits and the invoker scans for. The
18
25
  * runner-side serveStep writes `<prefix><token>:<json>\n`; the invoker's
19
26
  * result parser AND the stdout line splitter both match on this exact
@@ -23,6 +30,14 @@ export function stepResultLinePrefix(token: string): string {
23
30
  return `${STEP_RESULT_PREFIX}${token}:`;
24
31
  }
25
32
 
33
+ /** Build the pause sentinel line prefix. Same per-invocation token as the
34
+ * result sentinel so the two channels share one secret — a user
35
+ * `console.log` can't forge either without knowing the token, and the
36
+ * invoker can filter both prefixes from captured stdout with one token. */
37
+ export function stepPauseLinePrefix(token: string): string {
38
+ return `${STEP_PAUSE_PREFIX}${token}:`;
39
+ }
40
+
26
41
  /** Sandbox-side path where dispatch writes the compiled runner bundle.
27
42
  * Both modes (full-mode `dispatch.ts` and step-mode `invokeStep`) spawn
28
43
  * the runner from this path; single source of truth. */
@@ -41,6 +56,7 @@ export const STEP_ENV = {
41
56
  STEP_INPUT_PATH: "AC_STEP_INPUT_PATH",
42
57
  REQUEST_CONTEXT_PATH: "AC_REQUEST_CONTEXT_PATH",
43
58
  STEP_RESULT_TOKEN: "AC_STEP_RESULT_TOKEN",
59
+ STEP_RESUME: "AC_STEP_RESUME",
44
60
  } as const;
45
61
 
46
62
  /** Sandbox-side path where the invoker writes the JSON-encoded step input.
@@ -23,14 +23,17 @@
23
23
 
24
24
  import { readFileSync } from "node:fs";
25
25
  import type { RequestContextWire } from "../request-context/request-context.js";
26
+ import { RequestContextWireSchema } from "../request-context/request-context.js";
26
27
  import type { StepObservability } from "../workflow-steps/observability.js";
27
- import { STEP_ENV, STEP_RESULT_PREFIX } from "./protocol.js";
28
+ import { STEP_ENV, STEP_PAUSE_PREFIX, STEP_RESULT_PREFIX } from "./protocol.js";
29
+ import { PauseSignal, isPauseSignal } from "../pause/pause-core.js";
28
30
 
29
31
  /** What the handler receives. The serveStep already parsed the input
30
32
  * and request context from their JSON files. */
31
33
  export interface ServeStepRequest<TInput = unknown> {
32
34
  runId: string;
33
35
  stepIndex: number;
36
+ isResume: boolean;
34
37
  input: TInput;
35
38
  requestContext: RequestContextWire;
36
39
  }
@@ -64,9 +67,8 @@ type SentinelPayload =
64
67
  * immediately after a non-awaited write can drop the data on Linux
65
68
  * pipes, leaving the invoker with empty stdout and the runner-exit
66
69
  * classification pointing at the wrong thing. */
67
- function emitResult(token: string, payload: SentinelPayload): Promise<void> {
70
+ function writeSentinelLine(line: string): Promise<void> {
68
71
  return new Promise<void>((resolve, reject) => {
69
- const line = `${STEP_RESULT_PREFIX}${token}:${JSON.stringify(payload)}\n`;
70
72
  process.stdout.write(line, (err) => {
71
73
  if (err) reject(err);
72
74
  else resolve();
@@ -74,6 +76,24 @@ function emitResult(token: string, payload: SentinelPayload): Promise<void> {
74
76
  });
75
77
  }
76
78
 
79
+ function emitResult(token: string, payload: SentinelPayload): Promise<void> {
80
+ return writeSentinelLine(`${STEP_RESULT_PREFIX}${token}:${JSON.stringify(payload)}\n`);
81
+ }
82
+
83
+ /** Emit the tokenised pause sentinel — `__AC_STEP_PAUSE__<token>:{pauseId,
84
+ * pauseRequest}` — when the handler threw a `PauseSignal` instead of
85
+ * returning. Same per-invocation token + awaited-flush discipline as
86
+ * `emitResult`; the invoker's `parseStepResult` matches this exact framing
87
+ * (and pause takes precedence over any stray result line). */
88
+ function emitPause(token: string, signal: PauseSignal): Promise<void> {
89
+ const body = JSON.stringify({
90
+ pauseId: signal.pauseId,
91
+ pauseRequest: signal.pauseRequest,
92
+ ...(signal.observability ? { observability: signal.observability } : {}),
93
+ });
94
+ return writeSentinelLine(`${STEP_PAUSE_PREFIX}${token}:${body}\n`);
95
+ }
96
+
77
97
  /** Delete step-invocation transport envs so the user's workflow code
78
98
  * (loaded inside the handler) can't read them. Matters most for
79
99
  * AC_STEP_RESULT_TOKEN: if it stayed in env, a `console.log` from a dep
@@ -89,6 +109,7 @@ function scrubProtocolEnvs(): void {
89
109
  STEP_ENV.STEP_INPUT_PATH,
90
110
  STEP_ENV.REQUEST_CONTEXT_PATH,
91
111
  STEP_ENV.STEP_RESULT_TOKEN,
112
+ STEP_ENV.STEP_RESUME,
92
113
  ]) {
93
114
  delete process.env[key];
94
115
  }
@@ -129,13 +150,19 @@ export async function serveStep<TInput, TOutput>(
129
150
  // and re-emitted as a step failure.
130
151
  let exitCode = 0;
131
152
  let payload!: SentinelPayload;
153
+ // A pause is not a result. When the handler throws `PauseSignal` (user code
154
+ // called `ctx.pause`), emit the pause sentinel — not a result — and exit 0,
155
+ // so the activity snapshots the now-frozen sandbox and waits durably.
156
+ // ADR-0006 §"How it actually pauses".
157
+ let pauseSignal: PauseSignal | undefined;
132
158
 
133
- let setupResult: { runId: string; stepIndex: number; input: TInput; requestContext: RequestContextWire } | undefined;
159
+ let setupResult: { runId: string; stepIndex: number; isResume: boolean; input: TInput; requestContext: RequestContextWire } | undefined;
134
160
  try {
135
161
  const runId = process.env[STEP_ENV.RUN_ID];
136
162
  const stepIndexEnv = process.env[STEP_ENV.STEP_INDEX];
137
163
  const inputPath = process.env[STEP_ENV.STEP_INPUT_PATH];
138
164
  const ctxPath = process.env[STEP_ENV.REQUEST_CONTEXT_PATH];
165
+ const isResume = process.env[STEP_ENV.STEP_RESUME] === "1";
139
166
  const stepIndex = stepIndexEnv === undefined ? Number.NaN : Number(stepIndexEnv);
140
167
 
141
168
  if (!runId || !inputPath || !ctxPath || !Number.isFinite(stepIndex)) {
@@ -150,13 +177,9 @@ export async function serveStep<TInput, TOutput>(
150
177
  }
151
178
 
152
179
  const input = JSON.parse(readFileSync(inputPath, "utf8")) as TInput;
153
- // RequestContextWire parsing is loose here on purpose — the wire is
154
- // server-controlled, malformed shape is a server bug, and we want a
155
- // useful failure reason in `failRun`. Strict validation belongs at
156
- // the workflow-level RequestContext.parse boundary, not here.
157
- const requestContext = JSON.parse(readFileSync(ctxPath, "utf8")) as RequestContextWire;
180
+ const requestContext = RequestContextWireSchema.parse(JSON.parse(readFileSync(ctxPath, "utf8")));
158
181
 
159
- setupResult = { runId, stepIndex, input, requestContext };
182
+ setupResult = { runId, stepIndex, isResume, input, requestContext };
160
183
  } catch (err) {
161
184
  exitCode = 1;
162
185
  payload = { ok: false, kind: "protocol", error: err instanceof Error ? err.message : String(err) };
@@ -174,11 +197,21 @@ export async function serveStep<TInput, TOutput>(
174
197
  ? { ok: true, output, observability }
175
198
  : { ok: true, output };
176
199
  } catch (err) {
177
- exitCode = 1;
178
- payload = { ok: false, kind: "user-step", error: err instanceof Error ? err.message : String(err) };
200
+ // PauseSignal is control flow, not a failure — emit a pause, not a
201
+ // user-step error. Checked first so it can't be misclassified.
202
+ if (isPauseSignal(err)) {
203
+ pauseSignal = err;
204
+ } else {
205
+ exitCode = 1;
206
+ payload = { ok: false, kind: "user-step", error: err instanceof Error ? err.message : String(err) };
207
+ }
179
208
  }
180
209
  }
181
210
 
211
+ if (pauseSignal) {
212
+ await emitPause(token, pauseSignal);
213
+ process.exit(0);
214
+ }
182
215
  await emitResult(token, payload);
183
216
  process.exit(exitCode);
184
217
  }
@@ -3,6 +3,7 @@
3
3
  * the discriminated error union that classifies failure modes.
4
4
  */
5
5
 
6
+ import { z } from "zod";
6
7
  import type { RequestContextWire } from "../request-context/request-context.js";
7
8
  import type { StepObservability } from "../workflow-steps/observability.js";
8
9
 
@@ -19,15 +20,50 @@ export interface StepRequest<TInput = unknown> {
19
20
  }
20
21
 
21
22
  /**
22
- * Outcome of one step invocation. Successful runs carry the step's output;
23
- * failed runs carry a kinded error so the caller can distinguish "user
24
- * code threw" from "runner crashed before emitting" from "wire protocol
25
- * violation". The dashboard surfaces the kind to operators; the activity
26
- * uses the kind to pick a useful failRun reason.
23
+ * Outcome of one step invocation. Three terminal states:
24
+ *
25
+ * - `ok: true` — step body resolved with `output`.
26
+ * - `ok: false` — step body or runner errored; kind classifies why.
27
+ * - `ok: "paused"` — step body called `ctx.pause(...)` and exited cleanly.
28
+ * The activity captures a sandbox snapshot and the workflow waits on
29
+ * Temporal `condition()` until something resumes the pause (HTTP
30
+ * resume route, TTL expiry, cancellation). `pauseRequest` is what the
31
+ * caller passed to `ctx.pause` — the route surfaces it on the
32
+ * pending-pauses dashboard so an operator sees the question being
33
+ * asked. `observability` carries any ctx metadata/sub-step/agent events
34
+ * recorded before the pause unwound. See ADR-0006.
27
35
  */
28
36
  export type StepResult<TOutput = unknown> =
29
- | { ok: true; output: TOutput; observability?: StepObservability }
30
- | { ok: false; error: StepInvocationError };
37
+ | { ok: true; output: TOutput; observability?: StepObservability }
38
+ | { ok: "paused"; pauseId: string; pauseRequest: StepPauseRequest; observability?: StepObservability }
39
+ | { ok: false; error: StepInvocationError };
40
+
41
+ /** Wire schema for one pause request. Canonical here — `parseStepResult`
42
+ * imports it for validation, and `StepPauseRequest` is `z.infer`'d from
43
+ * it so the runtime type and the parsed shape can't drift.
44
+ *
45
+ * Loose by design (the inner zod shape stops at orchestration fields
46
+ * the engine needs): the SDK wrapper layer (`requestDecision` /
47
+ * `sleep` / `waitForEvent`) owns its own payload contract, and the
48
+ * engine treats `payload` as opaque. */
49
+ export const StepPauseRequestSchema = z.object({
50
+ /** Short human-readable label; surfaces on the pending-pauses feed. */
51
+ reason: z.string().min(1),
52
+ /** Wrapper discriminator: which SDK helper produced this pause. */
53
+ kind: z.enum(["decision", "sleep", "event", "custom"]),
54
+ /** Caller-supplied payload — opaque to the engine. */
55
+ payload: z.record(z.string(), z.unknown()).optional(),
56
+ /** Pause TTL in milliseconds. Bounded by Temporal's sleep durability. */
57
+ ttlMs: z.number().int().positive().optional(),
58
+ /** Optional second-key resume route — pending pause is unique per
59
+ * (run, correlationKey) so event-driven senders can resume without
60
+ * knowing the pause id. */
61
+ correlationKey: z.string().min(1).optional(),
62
+ /** Skip the per-pause sandbox snapshot. Default behaviour is to
63
+ * snapshot; only the lightweight wrappers (`ctx.sleep`) opt out. */
64
+ snapshot: z.boolean().optional(),
65
+ });
66
+ export type StepPauseRequest = z.infer<typeof StepPauseRequestSchema>;
31
67
 
32
68
  /**
33
69
  * Discriminated error union.