@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/index.ts CHANGED
@@ -117,6 +117,9 @@ export type {
117
117
  ApiKey, ApiKeyCreated,
118
118
  UsageRollupRow, UsageResponse,
119
119
  CancelRunResponse,
120
+ RequestAgentPauseOptions, RequestAgentPauseResponse,
121
+ SendAgentMessageOptions, SendAgentMessageResponse,
122
+ AnswerSteerOptions,
120
123
  } from "./client.js";
121
124
 
122
125
  // SSE parser — exposed so tests and downstream callers can reuse it.
@@ -126,8 +129,7 @@ export { parseSseStream } from "./sse.js";
126
129
  export { AgentComposeError } from "./errors.js";
127
130
  export { formatError } from "./utils/errors.js";
128
131
 
129
- // Source discovery + bundling utilities
130
- export { discoverRuntimeName } from "./utils/discovery.js";
132
+ // Bundling utilities
131
133
  export {
132
134
  bundleWorkflow,
133
135
  BUNDLER_VERSION,
@@ -209,16 +211,30 @@ export {
209
211
  buildStepEnvs,
210
212
  StepExecutionError,
211
213
  STEP_RESULT_PREFIX,
214
+ STEP_PAUSE_PREFIX,
212
215
  STEP_ENV,
213
216
  RUNNER_BUNDLE_PATH,
214
217
  RUNNER_COMMAND,
215
218
  stepInputPath,
216
219
  requestContextPath,
217
220
  } from "./step-invocation/index.js";
221
+
222
+ // Pause errors (ADR-0006 / ADR-0011) — thrown into user step/agent code when
223
+ // a pause cannot return a value, so callers can catch and classify them.
224
+ export {
225
+ PauseError,
226
+ PauseExpiredError,
227
+ PauseSchemaError,
228
+ PauseRequestError,
229
+ } from "./pause/errors.js";
230
+ export type { PauseErrorCode } from "./pause/errors.js";
231
+ export type { PauseRequest } from "./pause/pause-core.js";
232
+ export type { RequestDecisionRequest, WaitForEventRequest } from "./pause/wrappers.js";
218
233
  export type {
219
234
  StepRequest,
220
235
  StepResult,
221
236
  StepInvocationError,
237
+ StepPauseRequest,
222
238
  StepHandler,
223
239
  StepHandlerResult,
224
240
  ServeStepRequest,
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `ctx.checkpoint(name, fn)` — disk-backed memoise across pause-resume.
3
+ *
4
+ * First call: `fn()` runs, the result is atomically written to
5
+ * `/tmp/wf/state/checkpoints/<name>.json`. On any subsequent invocation
6
+ * of the same step body (e.g. after pause-resume re-enters the
7
+ * subprocess), the file exists in the restored sandbox; `fn` is NOT
8
+ * called, the recorded value is returned.
9
+ *
10
+ * Use for: large context loads, expensive deterministic transforms,
11
+ * idempotent-but-slow computations that you want to skip on resume.
12
+ *
13
+ * Do NOT use for: side effects with external systems (DB writes, HTTP
14
+ * POSTs, sending email). Those need `ctx.invokeChild(...)` — Temporal's
15
+ * exactly-once guarantee covers cross-process side effects; disk-backed
16
+ * memoisation is in-sandbox only and would silently retry on a sandbox
17
+ * recreation that lost the checkpoint file.
18
+ *
19
+ * See ADR-0006 §"`ctx.checkpoint(name, fn)` — disk-backed memoisation".
20
+ */
21
+
22
+ import { assertSafeCheckpointName, readCheckpoint, writeCheckpoint } from "./state-dir.js";
23
+
24
+ /** Read-or-run-and-write helper. `name` is validated by the state-dir
25
+ * layer (rejects path-escape attempts); `fn` is awaited if it returns
26
+ * a Promise so sync and async callers compose identically. */
27
+ export async function checkpoint<T>(name: string, fn: () => Promise<T> | T): Promise<T> {
28
+ const existing = await readCheckpoint<T>(name);
29
+ if (existing.found) return existing.value;
30
+ const value = await fn();
31
+ await writeCheckpoint(name, value);
32
+ return value;
33
+ }
34
+
35
+ /** Bind user checkpoint names to a runner-owned namespace. The user's
36
+ * `name` is still validated independently so a scope prefix cannot turn
37
+ * an empty or path-escaping name into a valid filename. */
38
+ export function scopedCheckpoint(scope: string): typeof checkpoint {
39
+ assertSafeCheckpointName(scope);
40
+ return async <T>(name: string, fn: () => Promise<T> | T): Promise<T> => {
41
+ assertSafeCheckpointName(name);
42
+ return checkpoint(`${scope}.${name}`, fn);
43
+ };
44
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * User-facing pause errors (ADR-0006 / ADR-0011).
3
+ *
4
+ * These are thrown into USER step/agent code when a pause cannot return a
5
+ * value — the caller can catch them. They are distinct from `PauseSignal`
6
+ * (the internal control-flow signal in `pause-core.ts` that unwinds the
7
+ * stack to `serveStep`): `PauseSignal` never reaches user code, these do.
8
+ *
9
+ * Each carries a stable `code` so the activity boundary and the dashboard
10
+ * can classify the failure without string-matching the message — the same
11
+ * pattern as `StepExecutionError` (`step-invocation/types.ts`).
12
+ *
13
+ * `target: ESNext` (sdk/tsconfig.json) makes `extends Error` + `instanceof`
14
+ * work without a manual prototype fix; `this.name = new.target.name` gives
15
+ * each subclass its own class name on the instance.
16
+ */
17
+
18
+ /** Stable, machine-readable discriminator for each pause error. */
19
+ export type PauseErrorCode =
20
+ | "pause_expired"
21
+ | "pause_schema"
22
+ | "pause_request";
23
+
24
+ /** Base for every user-facing pause error. Not thrown directly. */
25
+ export abstract class PauseError extends Error {
26
+ abstract readonly code: PauseErrorCode;
27
+ constructor(message: string) {
28
+ super(message);
29
+ this.name = new.target.name;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * The pause's TTL elapsed before it was resolved, and `onExpiry` was
35
+ * `{ mode: "throw" }` (the default). When `onExpiry` is
36
+ * `{ mode: "resolve", value }`, `ctx.pause` returns `value` instead of
37
+ * throwing this.
38
+ */
39
+ export class PauseExpiredError extends PauseError {
40
+ readonly code = "pause_expired";
41
+ constructor(message = "pause expired before it was resolved") {
42
+ super(message);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * The resume payload failed the caller's `schema`. `issues` carries the
48
+ * validation detail for an in-sandbox `catch`; note only `message` survives
49
+ * the serveStep wire boundary, so do not rely on `issues` outside the
50
+ * throwing process.
51
+ */
52
+ export class PauseSchemaError extends PauseError {
53
+ readonly code = "pause_schema";
54
+ readonly issues: unknown;
55
+ constructor(message: string, issues?: unknown) {
56
+ super(message);
57
+ this.issues = issues;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * The pause request itself is invalid before it can be emitted to the engine:
63
+ * empty reason, non-positive ttl, invalid kind, or a non-JSON payload.
64
+ */
65
+ export class PauseRequestError extends PauseError {
66
+ readonly code = "pause_request";
67
+ constructor(message = "pause request is invalid") {
68
+ super(message);
69
+ }
70
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Local pause/resume manager.
3
+ *
4
+ * This module owns the SDK-local state machine that makes a re-entered
5
+ * runner subprocess pick up agent work correctly. It deliberately does not
6
+ * know about Temporal, Postgres, HTTP resume routes, or dashboard feeds.
7
+ * Those layers own durable wait/audit state; this layer owns only the
8
+ * `/tmp/wf/state` files captured by sandbox snapshots.
9
+ */
10
+
11
+ import { z } from "zod";
12
+ import type { AgentStatus } from "../types/protocol.js";
13
+ import type { ModelExecutionContract } from "../types/runtime.js";
14
+ import { AgentStatusSchema } from "../utils/schemas.js";
15
+ import {
16
+ readAgentState,
17
+ readRuntimeCheckpoint,
18
+ writeAgentState,
19
+ writeRuntimeCheckpoint,
20
+ } from "./state-dir.js";
21
+
22
+ // v2 (PR 7) adds `pendingSteerPause`. The change is purely additive — a v1
23
+ // `agent-<id>.json` is a valid v2 object minus that one `.default(null)` field —
24
+ // so this build WRITES the current version but ACCEPTS both on read. A hard
25
+ // `literal(2)` would reject every v1 checkpoint as invalid and reset the agent
26
+ // to iteration 0: `flushState` writes this file at every iteration boundary for
27
+ // crash recovery (shipped in #100), independent of whether a pause ever fires,
28
+ // so durable v1 snapshots exist for any in-flight run across a deploy.
29
+ const AGENT_LOOP_STATE_SCHEMA_VERSION = 2;
30
+ /** Versions this build can READ. Every field added since v1 is optional or
31
+ * defaulted, so older checkpoints stay loadable across a rolling deploy. */
32
+ const SUPPORTED_AGENT_LOOP_STATE_VERSIONS = z.union([z.literal(1), z.literal(2)]);
33
+
34
+ const RunningAgentLoopStateSchema = z.object({
35
+ schemaVersion: SUPPORTED_AGENT_LOOP_STATE_VERSIONS,
36
+ phase: z.literal("running"),
37
+ iteration: z.number().int().nonnegative(),
38
+ completedIterations: z.number().int().nonnegative(),
39
+ lastSessionId: z.string(),
40
+ lastStatus: AgentStatusSchema.nullable(),
41
+ lastResponseText: z.string(),
42
+ lastResponseValidationError: z.string(),
43
+ iterationsWithoutStatus: z.number().int().nonnegative(),
44
+ blockerStreak: z.object({ key: z.string(), count: z.number().int().nonnegative() }).nullable(),
45
+ // PR 7: a human-requested steer-pause intent, persisted so it survives a
46
+ // resume and the loop re-issues the boundary pause on re-entry.
47
+ pendingSteerPause: z.object({ reason: z.string(), correlationKey: z.string().nullable(), at: z.number().int() }).nullable().default(null),
48
+ });
49
+
50
+ const SettledAgentLoopStateSchema = z.object({
51
+ schemaVersion: SUPPORTED_AGENT_LOOP_STATE_VERSIONS,
52
+ phase: z.literal("settled"),
53
+ result: z.object({
54
+ sessionId: z.string(),
55
+ lastStatus: AgentStatusSchema.nullable(),
56
+ iterations: z.number().int().nonnegative(),
57
+ response: z.unknown().optional(),
58
+ }),
59
+ });
60
+
61
+ const AgentLoopStateSchema = z.discriminatedUnion("phase", [
62
+ RunningAgentLoopStateSchema,
63
+ SettledAgentLoopStateSchema,
64
+ ]);
65
+
66
+ type RunningAgentLoopState = z.infer<typeof RunningAgentLoopStateSchema>;
67
+
68
+ export interface AgentLoopProgressState {
69
+ iteration: number;
70
+ completedIterations: number;
71
+ lastSessionId: string;
72
+ lastStatus: AgentStatus | null;
73
+ lastResponseText: string;
74
+ lastResponseValidationError: string;
75
+ iterationsWithoutStatus: number;
76
+ blockerStreak: { key: string; count: number } | null;
77
+ /** PR 7 steer-pause intent — see RunningAgentLoopStateSchema. Optional so the
78
+ * loop can omit it until it wires steer (the schema defaults it to null). */
79
+ pendingSteerPause?: { reason: string; correlationKey: string | null; at: number } | null;
80
+ }
81
+
82
+ export interface SettledAgentLoopResult<TResponse = unknown> {
83
+ sessionId: string;
84
+ lastStatus: AgentStatus | null;
85
+ iterations: number;
86
+ response?: TResponse;
87
+ }
88
+
89
+ export type AgentLoopRestore<TResponse = unknown> =
90
+ | { kind: "fresh" }
91
+ | { kind: "invalid"; error: string }
92
+ | { kind: "running"; state: AgentLoopProgressState }
93
+ | { kind: "settled"; result: SettledAgentLoopResult<TResponse> };
94
+
95
+ function toProgressState(state: RunningAgentLoopState): AgentLoopProgressState {
96
+ return {
97
+ iteration: state.iteration,
98
+ completedIterations: state.completedIterations,
99
+ lastSessionId: state.lastSessionId,
100
+ lastStatus: state.lastStatus,
101
+ lastResponseText: state.lastResponseText,
102
+ lastResponseValidationError: state.lastResponseValidationError,
103
+ iterationsWithoutStatus: state.iterationsWithoutStatus,
104
+ blockerStreak: state.blockerStreak,
105
+ pendingSteerPause: state.pendingSteerPause,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Coordinates the agent-loop files in `/tmp/wf/state`.
111
+ *
112
+ * Raw filesystem safety lives in `state-dir.ts`; runtime blob shape lives in
113
+ * each runtime adapter. `PauseManager` is the seam that binds those two to
114
+ * the agent-loop state machine.
115
+ */
116
+ export class PauseManager {
117
+ constructor(private readonly agentId: string) {}
118
+
119
+ async restoreAgentLoop<TResponse = unknown>(runtime: ModelExecutionContract): Promise<AgentLoopRestore<TResponse>> {
120
+ const priorState = await readAgentState<unknown>(this.agentId);
121
+ if (priorState === null) return { kind: "fresh" };
122
+
123
+ const parsed = AgentLoopStateSchema.safeParse(priorState);
124
+ if (!parsed.success) return { kind: "invalid", error: parsed.error.message };
125
+
126
+ const state = parsed.data;
127
+ if (state.phase === "settled") {
128
+ return {
129
+ kind: "settled",
130
+ result: {
131
+ sessionId: state.result.sessionId,
132
+ lastStatus: state.result.lastStatus,
133
+ iterations: state.result.iterations,
134
+ ...(Object.prototype.hasOwnProperty.call(state.result, "response")
135
+ ? { response: state.result.response as TResponse }
136
+ : {}),
137
+ },
138
+ };
139
+ }
140
+
141
+ const runtimeBlob = await readRuntimeCheckpoint<unknown>(this.agentId);
142
+ if (runtimeBlob !== null && !runtime.restoreCheckpoint) {
143
+ throw new Error(`runtime checkpoint exists for resumed agentId=${this.agentId}, but runtime cannot restore it`);
144
+ }
145
+ if (runtime.restoreCheckpoint && runtimeBlob === null) {
146
+ throw new Error(`missing runtime checkpoint for resumed agentId=${this.agentId}`);
147
+ }
148
+ if (runtimeBlob !== null) runtime.restoreCheckpoint?.(runtimeBlob);
149
+
150
+ return { kind: "running", state: toProgressState(state) };
151
+ }
152
+
153
+ async saveRunningAgentLoop(state: AgentLoopProgressState, runtime: ModelExecutionContract): Promise<void> {
154
+ await writeAgentState(this.agentId, {
155
+ schemaVersion: AGENT_LOOP_STATE_SCHEMA_VERSION,
156
+ phase: "running",
157
+ ...state,
158
+ });
159
+ if (runtime.captureCheckpoint) {
160
+ const blob = runtime.captureCheckpoint();
161
+ if (blob !== undefined) await writeRuntimeCheckpoint(this.agentId, blob);
162
+ }
163
+ }
164
+
165
+ async saveSettledAgentLoop<TResponse = unknown>(result: SettledAgentLoopResult<TResponse>): Promise<void> {
166
+ await writeAgentState(this.agentId, {
167
+ schemaVersion: AGENT_LOOP_STATE_SCHEMA_VERSION,
168
+ phase: "settled",
169
+ result: {
170
+ sessionId: result.sessionId,
171
+ lastStatus: result.lastStatus,
172
+ iterations: result.iterations,
173
+ ...(result.response !== undefined ? { response: result.response } : {}),
174
+ },
175
+ });
176
+ }
177
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * `pause-core` — the engine behind `ctx.pause` (ADR-0006 / ADR-0011 PR 6).
3
+ *
4
+ * How a pause works (and why this shape):
5
+ *
6
+ * First reach (fresh): read `pauses/<pauseId>.json` → absent/unresolved →
7
+ * write the placeholder record and `throw new PauseSignal(...)`. The
8
+ * exception unwinds the step body to `serveStep`, which emits the
9
+ * `__AC_STEP_PAUSE__` sentinel and exits 0. The engine snapshots, inserts
10
+ * a `run_pauses` row keyed by `pauseId`, and waits durably.
11
+ *
12
+ * Re-entry (resolved): the engine has written the resolution onto the same
13
+ * file. The runner re-runs the step body from the top in a fresh
14
+ * subprocess; the SAME `ctx.pause` call recomputes the SAME `pauseId`,
15
+ * reads the file, and returns the resume payload (or applies `onExpiry`).
16
+ *
17
+ * The whole thing hinges on `pauseId` being recomputed identically across the
18
+ * exit/re-enter boundary, with NO surviving in-process state. So `pauseId` is
19
+ * a **deterministic UUIDv5** of a stable coordinate — never random. UUIDv5
20
+ * (not a free-form string) because `run_pauses.id` is a `uuid` PRIMARY KEY.
21
+ *
22
+ * `PauseSignal` is internal control flow, NOT a user-facing error — `serveStep`
23
+ * and the agent loop must let it propagate (the user-facing `Pause*Error` classes in
24
+ * `errors.ts` are the ones user code catches). It deliberately does not extend
25
+ * `Error`, so a stray `catch (e) { if (e instanceof Error) … }` cannot quietly
26
+ * absorb it; callers add explicit `instanceof PauseSignal` re-throw guards.
27
+ *
28
+ * Runs in the runner subprocess (may use `node:crypto`), never in the Temporal
29
+ * workflow VM.
30
+ */
31
+
32
+ import { createHash } from "node:crypto";
33
+ import type { z } from "zod";
34
+ import { StepPauseRequestSchema, type StepPauseRequest } from "../step-invocation/types.js";
35
+ import type { StepObservability } from "../workflow-steps/observability.js";
36
+ import { nextPauseOrdinalInActiveStep } from "../active-step.js";
37
+ import { readPauseRecord, writePauseRecord } from "./state-dir.js";
38
+ import { PauseExpiredError, PauseRequestError, PauseSchemaError } from "./errors.js";
39
+
40
+ /** Public `ctx.pause` argument shape (ADR-0006 §"SDK surface"). `schema` and
41
+ * `onExpiry` are SDK-local — consumed here, never sent on the wire. The wire
42
+ * `kind` is NOT a public field: a raw `ctx.pause` is always `kind: "custom"`;
43
+ * `decision`/`sleep`/`event` come only from the wrappers. */
44
+ export interface PauseRequest<T = unknown> {
45
+ reason: string;
46
+ payload?: Record<string, unknown>;
47
+ schema?: z.ZodType<T>;
48
+ ttlMs?: number;
49
+ onExpiry?: { mode: "throw" } | { mode: "resolve"; value: T };
50
+ correlationKey?: string;
51
+ snapshot?: boolean;
52
+ }
53
+
54
+ /** The coordinate that makes `pauseId` deterministic + re-entry-stable. */
55
+ export interface PauseCoordinate {
56
+ runId: string;
57
+ stepIndex: number;
58
+ /** Present when the pause fires inside an agent loop (PR 7 steer-pause).
59
+ * Pins the id to the loop boundary (agentId + iteration) so it stays stable
60
+ * across the loop's iteration-skipping on resume — a bare per-step ordinal
61
+ * desyncs because completed iterations don't re-run, landing the ordinal at
62
+ * a different value. Step-body pauses leave it absent (the `:bare` form). */
63
+ agentScope?: { agentId: string; iteration: number };
64
+ }
65
+
66
+ /** Realm-global brand so `isPauseSignal` recognises a PauseSignal thrown by a
67
+ * DIFFERENT SDK instance. A bundled workflow inlines its own SDK copy (its
68
+ * `agent()` steer-pause throws the bundle's `PauseSignal`), but the runner
69
+ * catches with its own class — a plain `instanceof` is false across that
70
+ * boundary, so the pause was mis-classified as a step failure and no
71
+ * `run_pauses` row was written. `ctx.pause` avoided this only because its
72
+ * signal is runner-thrown. `Symbol.for` is shared across the realm, so the
73
+ * brand survives the instance split. */
74
+ const PAUSE_SIGNAL_BRAND = Symbol.for("agent-compose.PauseSignal");
75
+
76
+ /**
77
+ * Internal control-flow signal thrown by `corePause` on a fresh pause.
78
+ * Caught by `serveStep` (emits the sentinel + exits) and re-thrown past every
79
+ * intermediate catch. NOT an `Error` and NOT user-facing.
80
+ */
81
+ export class PauseSignal {
82
+ readonly [PAUSE_SIGNAL_BRAND] = true as const;
83
+ constructor(
84
+ readonly pauseId: string,
85
+ readonly pauseRequest: StepPauseRequest,
86
+ readonly observability?: StepObservability,
87
+ ) {}
88
+ }
89
+
90
+ /** Cross-instance-safe `PauseSignal` check. Use this instead of
91
+ * `err instanceof PauseSignal` anywhere a signal may have been thrown by a
92
+ * different SDK instance (the runner catching a bundled workflow's signal). */
93
+ export function isPauseSignal(err: unknown): err is PauseSignal {
94
+ return typeof err === "object" && err !== null && (err as Record<symbol, unknown>)[PAUSE_SIGNAL_BRAND] === true;
95
+ }
96
+
97
+ // ── Deterministic pauseId ───────────────────────────────────────────────────
98
+
99
+ /** Fixed namespace for agent-compose pause ids (UUIDv5). Constant — must
100
+ * never change, or in-flight paused workflows would recompute different ids
101
+ * on resume and fail to find their resolved records. */
102
+ const PAUSE_NAMESPACE = "5f3e2b8a-0c41-4e9d-9a7b-6d2c1f8e3a40";
103
+
104
+ function uuidToBytes(uuid: string): Buffer {
105
+ return Buffer.from(uuid.replace(/-/g, ""), "hex");
106
+ }
107
+
108
+ function bytesToUuid(b: Buffer): string {
109
+ const h = b.toString("hex");
110
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
111
+ }
112
+
113
+ /** RFC 4122 v5 (SHA-1, name-based). Deterministic: same name → same uuid. */
114
+ function uuidv5(name: string, namespace: string): string {
115
+ const hash = createHash("sha1")
116
+ .update(uuidToBytes(namespace))
117
+ .update(Buffer.from(name, "utf8"))
118
+ .digest();
119
+ const bytes = Buffer.from(hash.subarray(0, 16));
120
+ bytes[6] = (bytes[6] & 0x0f) | 0x50; // version 5
121
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
122
+ return bytesToUuid(bytes);
123
+ }
124
+
125
+ /** The call-site family this pause belongs to. The ordinal is counted per
126
+ * scope; `nextPauseId` appends `:${ordinal}`. An in-agent-loop pause keys on
127
+ * `agentId:iteration` (stable across iteration-skipping on resume); a
128
+ * step-body pause keeps the `:bare` form — byte-identical to shipped ids. */
129
+ function scopeOf(coord: PauseCoordinate): string {
130
+ return coord.agentScope
131
+ ? `${coord.runId}:${coord.stepIndex}:${coord.agentScope.agentId}:${coord.agentScope.iteration}`
132
+ : `${coord.runId}:${coord.stepIndex}:bare`;
133
+ }
134
+
135
+ // Per-scope ordinal. Module-level so it resets on a fresh subprocess (the
136
+ // resume boundary) — which is exactly what makes the k-th pause in a scope
137
+ // recompute the same id across re-entry. Mirrors `nextAgentCallIndexByStep`.
138
+ const pauseOrdinalByScope = new Map<string, number>();
139
+
140
+ function nextPauseOrdinal(scope: string): number {
141
+ const activeOrdinal = nextPauseOrdinalInActiveStep(scope);
142
+ if (activeOrdinal !== null) return activeOrdinal;
143
+ const next = pauseOrdinalByScope.get(scope) ?? 0;
144
+ pauseOrdinalByScope.set(scope, next + 1);
145
+ return next;
146
+ }
147
+
148
+ /** Wipe the ordinal counters. Called by the runner at first-boot alongside
149
+ * `resetStateDir()`; a fresh module load already starts empty, so this is
150
+ * belt-and-braces for any in-process reuse. */
151
+ export function resetPauseOrdinals(): void {
152
+ pauseOrdinalByScope.clear();
153
+ }
154
+
155
+ /** Compute the deterministic pauseId for the next pause in `coord`'s scope. */
156
+ function nextPauseId(coord: PauseCoordinate): string {
157
+ const scope = scopeOf(coord);
158
+ const ordinal = nextPauseOrdinal(scope);
159
+ return uuidv5(`${scope}:${ordinal}`, PAUSE_NAMESPACE);
160
+ }
161
+
162
+ // ── Wire request ─────────────────────────────────────────────────────────────
163
+
164
+ function nonJsonPath(value: unknown, path = "$", seen = new Set<object>()): string | null {
165
+ if (value === null) return null;
166
+ const kind = typeof value;
167
+ if (kind === "string" || kind === "boolean") return null;
168
+ if (kind === "number") return Number.isFinite(value) ? null : path;
169
+ if (kind === "undefined" || kind === "bigint" || kind === "function" || kind === "symbol") return path;
170
+ if (kind !== "object") return path;
171
+
172
+ const obj = value as object;
173
+ if (seen.has(obj)) return path;
174
+ seen.add(obj);
175
+
176
+ if (Array.isArray(value)) {
177
+ for (let i = 0; i < value.length; i++) {
178
+ const child = nonJsonPath(value[i], `${path}[${i}]`, seen);
179
+ if (child) return child;
180
+ }
181
+ seen.delete(obj);
182
+ return null;
183
+ }
184
+
185
+ const proto = Object.getPrototypeOf(obj);
186
+ if (proto !== Object.prototype && proto !== null) return path;
187
+ for (const key of Reflect.ownKeys(obj)) {
188
+ if (typeof key !== "string") return `${path}.${String(key)}`;
189
+ const desc = Object.getOwnPropertyDescriptor(obj, key);
190
+ if (!desc?.enumerable) return `${path}.${key}`;
191
+ const child = nonJsonPath((value as Record<string, unknown>)[key], `${path}.${key}`, seen);
192
+ if (child) return child;
193
+ }
194
+ seen.delete(obj);
195
+ return null;
196
+ }
197
+
198
+ function toWireRequest(req: PauseRequest<unknown>, kind: StepPauseRequest["kind"]): StepPauseRequest {
199
+ // Allowlist-pick the wire fields explicitly — never spread `req`, or the
200
+ // SDK-local `schema` (a non-JSON-serialisable ZodType) and `onExpiry` would
201
+ // ride onto the sentinel / `run_pauses.request_payload`. `kind` is supplied
202
+ // by the caller (the wrappers; "custom" for a raw pause), not read from req.
203
+ const wire = {
204
+ reason: req.reason,
205
+ kind,
206
+ ...(req.payload !== undefined ? { payload: req.payload } : {}),
207
+ ...(req.ttlMs !== undefined ? { ttlMs: req.ttlMs } : {}),
208
+ ...(req.correlationKey !== undefined ? { correlationKey: req.correlationKey } : {}),
209
+ ...(req.snapshot !== undefined ? { snapshot: req.snapshot } : {}),
210
+ };
211
+ const parsed = StepPauseRequestSchema.safeParse(wire);
212
+ if (!parsed.success) {
213
+ throw new PauseRequestError(`pause request failed validation: ${parsed.error.message}`);
214
+ }
215
+ const badPath = nonJsonPath(parsed.data);
216
+ if (badPath) {
217
+ throw new PauseRequestError(`pause request must be JSON-serializable (invalid value at ${badPath})`);
218
+ }
219
+ return parsed.data;
220
+ }
221
+
222
+ // ── The engine ───────────────────────────────────────────────────────────────
223
+
224
+ /**
225
+ * The body of `ctx.pause`. On a fresh pause it throws `PauseSignal`; on
226
+ * re-entry it returns the resume payload (or applies `onExpiry` / throws
227
+ * `PauseSchemaError`). See the module header.
228
+ */
229
+ export async function corePause<T = unknown>(
230
+ req: PauseRequest<T>,
231
+ coord: PauseCoordinate,
232
+ kind: StepPauseRequest["kind"] = "custom",
233
+ ): Promise<T> {
234
+ const wire = toWireRequest(req, kind);
235
+ const pauseId = nextPauseId(coord);
236
+ const record = await readPauseRecord(pauseId);
237
+
238
+ // Fresh pause (no file yet) OR our own un-resolved placeholder: write the
239
+ // placeholder and signal. `resolved !== true` is the guard — a placeholder
240
+ // we just wrote in a prior pass has no `resolved` key.
241
+ if (record === null || record.resolved !== true) {
242
+ await writePauseRecord({ pauseId, request: wire });
243
+ throw new PauseSignal(pauseId, wire);
244
+ }
245
+
246
+ // Resolved by a TTL expiry.
247
+ if (record.expired === true) {
248
+ const onExpiry = req.onExpiry ?? { mode: "throw" };
249
+ if (onExpiry.mode === "resolve") return onExpiry.value;
250
+ throw new PauseExpiredError(`pause "${req.reason}" expired before it was resolved`);
251
+ }
252
+
253
+ // Resolved by a real resume. Validate against the caller's schema if given.
254
+ const payload = record.resumePayload ?? null;
255
+ if (req.schema) {
256
+ const parsed = req.schema.safeParse(payload);
257
+ if (!parsed.success) {
258
+ // v1: schema failure always throws; lax reopen-on-failure is deferred (ADR-0011).
259
+ throw new PauseSchemaError(
260
+ `resume payload for pause "${req.reason}" failed schema validation: ${parsed.error.message}`,
261
+ parsed.error.issues,
262
+ );
263
+ }
264
+ return parsed.data;
265
+ }
266
+ return payload as T;
267
+ }