@agent-compose/sdk 0.5.1 → 0.5.5

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 (96) 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 +16 -6
  12. package/dist/index.js +1586 -158
  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/_cli-agent.d.ts +72 -0
  28. package/dist/runtimes/amp.d.ts +22 -0
  29. package/dist/runtimes/claude.d.ts +6 -0
  30. package/dist/runtimes/codex.d.ts +20 -0
  31. package/dist/runtimes/openai-desktop.d.ts +2 -0
  32. package/dist/runtimes/openai-desktop.js +1578 -157
  33. package/dist/runtimes/vercel.d.ts +53 -1
  34. package/dist/runtimes/vercel.js +60 -8
  35. package/dist/runtimes/vercel.test.d.ts +1 -0
  36. package/dist/sse.d.ts +2 -3
  37. package/dist/step-invocation/index.d.ts +2 -2
  38. package/dist/step-invocation/invoker.d.ts +3 -0
  39. package/dist/step-invocation/protocol.d.ts +12 -0
  40. package/dist/step-invocation/server.d.ts +1 -0
  41. package/dist/step-invocation/types.d.ts +40 -5
  42. package/dist/types/events.d.ts +9 -0
  43. package/dist/types/execution-context.d.ts +25 -0
  44. package/dist/types/protocol.d.ts +8 -0
  45. package/dist/types/runtime.d.ts +55 -0
  46. package/dist/types/sandbox.d.ts +4 -4
  47. package/dist/utils/schemas.d.ts +2 -0
  48. package/dist/workflow-steps/__tests__/pause-wiring.test.d.ts +1 -0
  49. package/dist/workflow-steps/index.d.ts +2 -0
  50. package/dist/workflow-steps/observability.d.ts +43 -11
  51. package/dist/workflow-steps/run-callback.d.ts +39 -0
  52. package/dist/workflow-steps/runner.d.ts +8 -0
  53. package/package.json +1 -1
  54. package/src/active-step.ts +124 -0
  55. package/src/agent/agent-loop.ts +253 -19
  56. package/src/agent/async-queue.ts +61 -0
  57. package/src/agent/protocol.ts +12 -2
  58. package/src/agent/run-agent.ts +184 -8
  59. package/src/agent/steer-control.ts +125 -0
  60. package/src/client.ts +277 -0
  61. package/src/index.ts +38 -4
  62. package/src/pause/checkpoint.ts +44 -0
  63. package/src/pause/errors.ts +70 -0
  64. package/src/pause/manager.ts +177 -0
  65. package/src/pause/pause-core.ts +267 -0
  66. package/src/pause/state-dir.ts +262 -0
  67. package/src/pause/wrappers.ts +79 -0
  68. package/src/request-context/request-context.ts +17 -2
  69. package/src/runtimes/_cli-agent.ts +161 -0
  70. package/src/runtimes/amp.ts +94 -0
  71. package/src/runtimes/claude.ts +101 -6
  72. package/src/runtimes/codex.ts +109 -0
  73. package/src/runtimes/openai-desktop.ts +11 -0
  74. package/src/runtimes/vercel.ts +78 -2
  75. package/src/sandbox.ts +39 -20
  76. package/src/sse.ts +8 -6
  77. package/src/step-invocation/index.ts +2 -1
  78. package/src/step-invocation/invoker.ts +107 -29
  79. package/src/step-invocation/protocol.ts +16 -0
  80. package/src/step-invocation/server.ts +45 -12
  81. package/src/step-invocation/types.ts +43 -7
  82. package/src/tools/coding.ts +16 -5
  83. package/src/types/events.ts +9 -0
  84. package/src/types/execution-context.ts +25 -0
  85. package/src/types/protocol.ts +8 -0
  86. package/src/types/runtime.ts +52 -0
  87. package/src/types/sandbox.ts +8 -4
  88. package/src/types/workflow.ts +6 -1
  89. package/src/utils/bundler.ts +8 -3
  90. package/src/utils/schemas.ts +2 -0
  91. package/src/workflow-steps/index.ts +3 -0
  92. package/src/workflow-steps/observability.ts +84 -13
  93. package/src/workflow-steps/run-callback.ts +72 -0
  94. package/src/workflow-steps/runner.ts +70 -8
  95. package/dist/utils/discovery.d.ts +0 -2
  96. package/src/utils/discovery.ts +0 -4
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Pause state directory — durable per-agent / per-checkpoint / per-pause
3
+ * blobs the sandbox snapshot carries across pause-resume.
4
+ *
5
+ * Layout under `/tmp/wf/state/`:
6
+ *
7
+ * agent-<agentInstanceId>.json ← agent loop state (iteration, messages, processor cursor)
8
+ * runtime-<agentInstanceId>.json ← runtime-private blob (opaque to the loop)
9
+ * checkpoints/<name>.json ← user `ctx.checkpoint(name, fn)` memoised values
10
+ * pauses/<pauseId>.json ← pause records (request + resume payload once available)
11
+ *
12
+ * Atomic writes (write-tmp → fsync → rename) so a mid-write `sandbox.snapshot()`
13
+ * cannot capture partial state. Most read helpers return `null` on missing-file
14
+ * rather than throwing — callers branch on presence to distinguish "first
15
+ * run" from "post-resume restore". User checkpoints use an explicit
16
+ * found/value result because `null` is a valid memoised value.
17
+ *
18
+ * The state-dir layout is treated as wire protocol between the SDK
19
+ * runner (writer) and the SDK on the next subprocess invocation (reader).
20
+ * Changing a filename or shape is a breaking change to pause resumption
21
+ * for any in-flight paused workflow. See ADR-0006.
22
+ */
23
+
24
+ import { promises as fs } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { randomBytes } from "node:crypto";
27
+
28
+ /** Root of the pause state-dir. Defaults to `/tmp/wf/state` — the
29
+ * canonical location inside the sandbox. Tests and non-`/tmp`-writable
30
+ * environments can override via `AGENT_COMPOSE_STATE_DIR`.
31
+ *
32
+ * Read at CALL TIME, not module load. An earlier draft cached it on
33
+ * first import, but that turned out to be test-ordering-fragile: once
34
+ * any test imported state-dir (directly or transitively via
35
+ * agent-loop) the path got locked to whatever env was set at that
36
+ * instant, and a later test setting `AGENT_COMPOSE_STATE_DIR` would
37
+ * silently write to the cached path instead. Reading the env each
38
+ * call costs nothing measurable next to the fsync. */
39
+ export function getStateDir(): string {
40
+ return process.env.AGENT_COMPOSE_STATE_DIR ?? "/tmp/wf/state";
41
+ }
42
+
43
+ const checkpointsDir = () => join(getStateDir(), "checkpoints");
44
+ const pausesDir = () => join(getStateDir(), "pauses");
45
+
46
+ const agentStatePath = (agentInstanceId: string) => join(getStateDir(), `agent-${agentInstanceId}.json`);
47
+ const runtimeStatePath = (agentInstanceId: string) => join(getStateDir(), `runtime-${agentInstanceId}.json`);
48
+ const checkpointPath = (name: string) => join(checkpointsDir(), `${name}.json`);
49
+ const pausePath = (pauseId: string) => join(pausesDir(), `${pauseId}.json`);
50
+
51
+ /** `name` is interpolated into a filename; reject anything outside a
52
+ * conservative slug. Prevents a workflow author from writing
53
+ * `ctx.checkpoint("../../etc/passwd", fn)` and escaping the dir.
54
+ *
55
+ * First character must be alphanumeric or `_` so dotfile names (`.foo`)
56
+ * and parent-directory tokens (`.`, `..`) are rejected regardless of
57
+ * any future change to the `.json` suffix invariant. Empty strings are
58
+ * rejected because the first character is required.
59
+ *
60
+ * Allowed: `agent-1`, `expensive_load`, `p.42`, `decision-v2`.
61
+ * Rejected: ``, `.`, `..`, `.hidden`, `-leading`, `a/b`, `a\b`. */
62
+ const NAME_RE = /^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/;
63
+
64
+ function assertSafeName(label: string, name: string): void {
65
+ if (!NAME_RE.test(name)) {
66
+ throw new Error(`${label} name must match ${NAME_RE} (got ${JSON.stringify(name)})`);
67
+ }
68
+ }
69
+
70
+ export function assertSafeCheckpointName(name: string): void {
71
+ assertSafeName("checkpoint", name);
72
+ }
73
+
74
+ /** Ensure the state-dir and its subdirectories exist. Idempotent —
75
+ * re-running is a no-op. Called lazily by every write entry-point so
76
+ * callers don't need to remember to call it. */
77
+ async function ensureDirs(): Promise<void> {
78
+ await fs.mkdir(checkpointsDir(), { recursive: true });
79
+ await fs.mkdir(pausesDir(), { recursive: true });
80
+ }
81
+
82
+ /** Atomic write: temp file in the same directory, fsync to flush
83
+ * contents to disk, then rename onto the target. POSIX rename is
84
+ * atomic when source and target are on the same filesystem (always
85
+ * true here — same dir), so a snapshot in progress sees either the
86
+ * pre-write file or the fully-written file, never a partial. */
87
+ async function atomicWriteJSON(path: string, value: unknown): Promise<void> {
88
+ await ensureDirs();
89
+ const tmp = `${path}.tmp.${randomBytes(6).toString("hex")}`;
90
+ const payload = JSON.stringify(value);
91
+ if (payload === undefined) {
92
+ throw new Error("pause state values must be JSON-serialisable");
93
+ }
94
+ const handle = await fs.open(tmp, "w");
95
+ try {
96
+ await handle.writeFile(payload, { encoding: "utf8" });
97
+ await handle.sync();
98
+ } finally {
99
+ await handle.close();
100
+ }
101
+ await fs.rename(tmp, path);
102
+ }
103
+
104
+ /** Read + JSON-parse. Returns `null` when the file doesn't exist —
105
+ * the absence IS the signal "no state yet".
106
+ * Any other error (permission, JSON syntax, partial write) bubbles
107
+ * so the runner fails loudly rather than silently re-running. */
108
+ async function readJSON<T = unknown>(path: string): Promise<T | null> {
109
+ try {
110
+ const raw = await fs.readFile(path, "utf8");
111
+ return JSON.parse(raw) as T;
112
+ } catch (err) {
113
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
114
+ throw err;
115
+ }
116
+ }
117
+
118
+ export type CheckpointRead<T> =
119
+ | { found: true; value: T }
120
+ | { found: false };
121
+
122
+ async function readJSONWithPresence<T = unknown>(path: string): Promise<CheckpointRead<T>> {
123
+ try {
124
+ const raw = await fs.readFile(path, "utf8");
125
+ return { found: true, value: JSON.parse(raw) as T };
126
+ } catch (err) {
127
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return { found: false };
128
+ throw err;
129
+ }
130
+ }
131
+
132
+ // ── Agent loop state ────────────────────────────────────────────────────────
133
+ //
134
+ // `agentInstanceId` flows from workflow-author code (`agent({ agentId })`)
135
+ // and from the step-mode derivation in `run-agent.ts`. Both are
136
+ // validated here so a malformed id can't escape the state-dir even
137
+ // through the agent / runtime entry points.
138
+
139
+ export async function readAgentState<T = unknown>(agentInstanceId: string): Promise<T | null> {
140
+ assertSafeName("agent", agentInstanceId);
141
+ return readJSON<T>(agentStatePath(agentInstanceId));
142
+ }
143
+
144
+ export async function writeAgentState(agentInstanceId: string, state: unknown): Promise<void> {
145
+ assertSafeName("agent", agentInstanceId);
146
+ await atomicWriteJSON(agentStatePath(agentInstanceId), state);
147
+ }
148
+
149
+ // ── Runtime-private blob ────────────────────────────────────────────────────
150
+
151
+ export async function readRuntimeCheckpoint<T = unknown>(agentInstanceId: string): Promise<T | null> {
152
+ assertSafeName("runtime", agentInstanceId);
153
+ return readJSON<T>(runtimeStatePath(agentInstanceId));
154
+ }
155
+
156
+ export async function writeRuntimeCheckpoint(agentInstanceId: string, blob: unknown): Promise<void> {
157
+ assertSafeName("runtime", agentInstanceId);
158
+ await atomicWriteJSON(runtimeStatePath(agentInstanceId), blob);
159
+ }
160
+
161
+ // ── User checkpoints ────────────────────────────────────────────────────────
162
+
163
+ export async function readCheckpoint<T = unknown>(name: string): Promise<CheckpointRead<T>> {
164
+ assertSafeCheckpointName(name);
165
+ return readJSONWithPresence<T>(checkpointPath(name));
166
+ }
167
+
168
+ export async function writeCheckpoint(name: string, value: unknown): Promise<void> {
169
+ assertSafeCheckpointName(name);
170
+ await atomicWriteJSON(checkpointPath(name), value);
171
+ }
172
+
173
+ // ── Pause records ───────────────────────────────────────────────────────────
174
+
175
+ /** The `pauses/<pauseId>.json` file — the only durable handoff between the
176
+ * paused subprocess and the resumed one.
177
+ *
178
+ * Written twice in two execution contexts:
179
+ * - At pause time the runner writes the placeholder `{ pauseId, request }`
180
+ * (no `resolved`). The snapshot captures it.
181
+ * - At resume time the engine activity writes the resolution — usually
182
+ * merged on top of the placeholder, but snapshotless pauses may only have
183
+ * `{ resolved: true, resumePayload, expired }` in a fresh sandbox. Those
184
+ * three resolution fields are always present together so the byte shape
185
+ * distinguishes a real resume (`expired:false`) from a TTL expiry
186
+ * (`expired:true`) from an unresolved placeholder (`resolved` absent).
187
+ *
188
+ * `corePause` reads it on re-entry: `resolved !== true` ⇒ still my own
189
+ * placeholder, pause again; `expired` ⇒ apply `onExpiry`; else return
190
+ * `resumePayload`. */
191
+ export interface PauseRecord {
192
+ pauseId: string;
193
+ request: unknown;
194
+ /** Set true once the engine has written the resolution (resume or expiry). */
195
+ resolved?: boolean;
196
+ /** Value to return from `ctx.pause` on a real resume; null for expiry. */
197
+ resumePayload?: unknown;
198
+ /** True when the resolution is a TTL expiry, not a caller-driven resume. */
199
+ expired?: boolean;
200
+ }
201
+
202
+ function hasOwn(obj: object, key: string): boolean {
203
+ return Object.prototype.hasOwnProperty.call(obj, key);
204
+ }
205
+
206
+ function validatePauseRecord(value: unknown, expectedPauseId: string): PauseRecord {
207
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
208
+ throw new Error(`pause record ${expectedPauseId} is malformed: expected object`);
209
+ }
210
+ const record = value as Record<string, unknown>;
211
+ if (record.pauseId !== undefined && record.pauseId !== expectedPauseId) {
212
+ throw new Error(`pause record ${expectedPauseId} is malformed: pauseId mismatch`);
213
+ }
214
+
215
+ if (record.resolved === true) {
216
+ if (typeof record.expired !== "boolean") {
217
+ throw new Error(`pause record ${expectedPauseId} is malformed: resolved records require boolean expired`);
218
+ }
219
+ if (!hasOwn(record, "resumePayload") || record.resumePayload === undefined) {
220
+ throw new Error(`pause record ${expectedPauseId} is malformed: resolved records require resumePayload`);
221
+ }
222
+ return {
223
+ pauseId: expectedPauseId,
224
+ request: hasOwn(record, "request") ? record.request : null,
225
+ ...record,
226
+ } as unknown as PauseRecord;
227
+ }
228
+
229
+ if (record.pauseId !== expectedPauseId) {
230
+ throw new Error(`pause record ${expectedPauseId} is malformed: unresolved records require matching pauseId`);
231
+ }
232
+ if (!hasOwn(record, "request") || record.request === undefined) {
233
+ throw new Error(`pause record ${expectedPauseId} is malformed: unresolved records require request`);
234
+ }
235
+ if (record.resolved !== undefined && record.resolved !== false) {
236
+ throw new Error(`pause record ${expectedPauseId} is malformed: resolved must be true, false, or absent`);
237
+ }
238
+ if (hasOwn(record, "expired") || hasOwn(record, "resumePayload")) {
239
+ throw new Error(`pause record ${expectedPauseId} is malformed: unresolved records must not include resolution fields`);
240
+ }
241
+ return record as unknown as PauseRecord;
242
+ }
243
+
244
+ export async function readPauseRecord(pauseId: string): Promise<PauseRecord | null> {
245
+ assertSafeName("pause", pauseId);
246
+ const record = await readJSON<unknown>(pausePath(pauseId));
247
+ return record === null ? null : validatePauseRecord(record, pauseId);
248
+ }
249
+
250
+ export async function writePauseRecord(record: PauseRecord): Promise<void> {
251
+ assertSafeName("pause", record.pauseId);
252
+ validatePauseRecord(record, record.pauseId);
253
+ await atomicWriteJSON(pausePath(record.pauseId), record);
254
+ }
255
+
256
+ /** Wipe the entire state directory. Used by the runner at first-boot
257
+ * (no pauseId in env) so a freshly-dispatched run never inherits state
258
+ * from a prior dispatch that shared the sandbox snapshot template.
259
+ * No-op when the directory doesn't exist. */
260
+ export async function resetStateDir(): Promise<void> {
261
+ await fs.rm(getStateDir(), { recursive: true, force: true });
262
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The three opinionated pause wrappers (ADR-0006 §"SDK surface"), each a thin
3
+ * closure over `ctx.pause`:
4
+ *
5
+ * - `requestDecision` — pause for a typed human/agent decision (schema required).
6
+ * - `sleep` — a lightweight timed pause; resolves on its own TTL,
7
+ * skips the snapshot, returns void.
8
+ * - `waitForEvent` — pause until an event resumes by correlation key.
9
+ *
10
+ * Built from a `PauseFn` so the wrapper logic lives in one place and the step
11
+ * runner just spreads them onto the context next to `pause`.
12
+ */
13
+
14
+ import type { z } from "zod";
15
+ import type { StepPauseRequest } from "../step-invocation/types.js";
16
+ import type { PauseRequest } from "./pause-core.js";
17
+
18
+ /** The public `ctx.pause` callable (always `kind: "custom"`). */
19
+ export type PauseFn = <T = unknown>(req: PauseRequest<T>) => Promise<T>;
20
+
21
+ /** Internal: pause with an explicit wire `kind`. The wrappers stamp
22
+ * `decision`/`sleep`/`event` through this; the public `ctx.pause` is always
23
+ * `custom` and never exposes it. */
24
+ export type KindedPauseFn = <T = unknown>(req: PauseRequest<T>, kind: StepPauseRequest["kind"]) => Promise<T>;
25
+
26
+ export interface RequestDecisionRequest<T> {
27
+ reason: string;
28
+ payload?: Record<string, unknown>;
29
+ /** Required — a decision is always validated against a shape. */
30
+ schema: z.ZodType<T>;
31
+ ttlMs?: number;
32
+ }
33
+
34
+ export interface WaitForEventRequest<T> {
35
+ reason: string;
36
+ /** Required — the by-key resume route targets this. */
37
+ correlationKey: string;
38
+ schema?: z.ZodType<T>;
39
+ ttlMs?: number;
40
+ }
41
+
42
+ export interface PauseWrappers {
43
+ requestDecision<T>(req: RequestDecisionRequest<T>): Promise<T>;
44
+ sleep(durationMs: number): Promise<void>;
45
+ waitForEvent<T = unknown>(req: WaitForEventRequest<T>): Promise<T>;
46
+ }
47
+
48
+ export function buildPauseWrappers(pause: KindedPauseFn): PauseWrappers {
49
+ return {
50
+ requestDecision<T>(req: RequestDecisionRequest<T>): Promise<T> {
51
+ return pause<T>({
52
+ reason: req.reason,
53
+ schema: req.schema,
54
+ ...(req.payload !== undefined ? { payload: req.payload } : {}),
55
+ ...(req.ttlMs !== undefined ? { ttlMs: req.ttlMs } : {}),
56
+ }, "decision");
57
+ },
58
+
59
+ // Resolves on its OWN ttl — there is no external resumer for a sleep, so
60
+ // onExpiry resolves (not throws) with void. snapshot:false keeps it cheap.
61
+ sleep(durationMs: number): Promise<void> {
62
+ return pause<void>({
63
+ reason: `sleep ${durationMs}ms`,
64
+ ttlMs: durationMs,
65
+ snapshot: false,
66
+ onExpiry: { mode: "resolve", value: undefined },
67
+ }, "sleep");
68
+ },
69
+
70
+ waitForEvent<T = unknown>(req: WaitForEventRequest<T>): Promise<T> {
71
+ return pause<T>({
72
+ reason: req.reason,
73
+ correlationKey: req.correlationKey,
74
+ ...(req.schema !== undefined ? { schema: req.schema } : {}),
75
+ ...(req.ttlMs !== undefined ? { ttlMs: req.ttlMs } : {}),
76
+ }, "event");
77
+ },
78
+ };
79
+ }
@@ -39,6 +39,8 @@
39
39
  * is per-run by design (cross-run state must be explicit via `input`).
40
40
  */
41
41
 
42
+ import { z } from "zod";
43
+
42
44
  /** Reserved key namespace prefix — anything starting with this is platform-owned. */
43
45
  export const AC_RESERVED_PREFIX = "ac__";
44
46
 
@@ -99,6 +101,18 @@ export interface RequestContextWire {
99
101
  user: Record<string, unknown>;
100
102
  }
101
103
 
104
+ export const RequestContextWireSchema = z.object({
105
+ reserved: z.object({
106
+ teamId: z.string(),
107
+ runId: z.string(),
108
+ workflowId: z.string(),
109
+ factoryId: z.string().nullable(),
110
+ apiKeyScopes: z.array(z.string()).readonly(),
111
+ parentRunId: z.string().nullable(),
112
+ }),
113
+ user: z.record(z.string(), z.unknown()),
114
+ }) satisfies z.ZodType<RequestContextWire>;
115
+
102
116
  /** Thrown when a caller tries to `set` or `delete` a reserved key downstream. */
103
117
  export class ReservedKeyError extends Error {
104
118
  readonly key: string;
@@ -185,9 +199,10 @@ export class RequestContext<U extends Record<string, unknown> = Record<string, u
185
199
  * its own `AbortSignal` separately via `withAbortSignal()`.
186
200
  */
187
201
  static deserialise(wire: RequestContextWire): RequestContext {
202
+ const parsed = RequestContextWireSchema.parse(wire);
188
203
  const ctx = new RequestContext(
189
- { ...wire.reserved, abortSignal: undefined },
190
- new Map(Object.entries(wire.user)),
204
+ { ...parsed.reserved, abortSignal: undefined },
205
+ new Map(Object.entries(parsed.user)),
191
206
  );
192
207
  return ctx;
193
208
  }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * CLI-agent runtime base — drive an external agentic coding CLI inside the
3
+ * sandbox and map its JSON-Lines (JSONL) stream onto the `AgentMessage`
4
+ * contract.
5
+ *
6
+ * This is a different *mechanism* from the other runtimes: `claudeRuntime`
7
+ * drives the Anthropic Agent SDK and `vercelRuntime` drives the Vercel AI SDK,
8
+ * but a CLI-agent runtime spawns the provider's own CLI (`codex exec --json`,
9
+ * `amp -x --stream-json`) — the CLI brings its own agent loop + tools, and we
10
+ * only stream-parse the events it prints. Codex and Amp are the first two; this
11
+ * base is the shared machinery (spawn → line-buffer stdout → JSONL parse →
12
+ * AsyncQueue bridge → init/usage/done/error lifecycle), parameterised per-CLI
13
+ * by a `CliAgentSpec`.
14
+ *
15
+ * Requirements (per spec): the provider CLI is installed in the sandbox image,
16
+ * and the provider's API key is present in the sandbox environment (see each
17
+ * spec's `authEnv`). `commands.run` inherits the sandbox env, so secrets set via
18
+ * `agentc secrets set` are visible to the CLI.
19
+ *
20
+ * NOTE: the per-CLI command construction + resume flags + exact event shapes in
21
+ * the shipped specs are mapped from each tool's docs and have NOT been verified
22
+ * against a live CLI run — verify before relying on them in production.
23
+ */
24
+
25
+ import type { AgentMessage, ModelExecutionContract, RuntimeOptions, SandboxProvider } from "../index.js";
26
+ import { defineRuntime } from "../types/runtime.js";
27
+ import { AsyncQueue } from "../agent/async-queue.js";
28
+ import { formatError } from "../utils/errors.js";
29
+
30
+ function now(): string { return new Date().toISOString(); }
31
+
32
+ /** Single-quote a value for safe interpolation into a `sh -c` command line. */
33
+ export function shellQuote(value: string): string {
34
+ return `'${value.replace(/'/g, `'\\''`)}'`;
35
+ }
36
+
37
+ /** Per-CLI behaviour. The base owns the lifecycle (init/done/error) and the
38
+ * transport (spawn + JSONL parse); a spec owns the CLI-specific bits. */
39
+ export interface CliAgentSpec {
40
+ /** Runtime self-id surfaced on `agent.spawned` (dashboard runtime icon). */
41
+ kind: string;
42
+ /** Env var the CLI reads for auth — documentation only (must be set in the
43
+ * sandbox env via a workflow secret). */
44
+ authEnv: string;
45
+ /** Default model id when none is configured; omit to let the CLI choose. */
46
+ defaultModel?: string;
47
+ /** Serialise the user prompt into the bytes written to the prompt file —
48
+ * plain text for a CLI that reads the prompt from stdin (codex `-`), or a
49
+ * JSONL user message for a `--stream-json-input` CLI (amp). */
50
+ promptPayload(prompt: string): string;
51
+ /** Build the one-shot shell command for a turn. `promptPath` is a file in the
52
+ * sandbox holding `promptPayload(prompt)`; `sessionId` continues a thread. */
53
+ buildCommand(args: { promptPath: string; sessionId?: string; model?: string; cwd?: string }): string;
54
+ /** Map one parsed JSONL stdout event to `AgentMessage`s. The base emits
55
+ * `init`/`done`/`error` lifecycle itself, so a spec maps only content +
56
+ * usage (text / thinking / tool_use / tool_result / usage). */
57
+ mapEvent(parsed: Record<string, unknown>): AgentMessage[];
58
+ /** Pull a session/thread id out of a parsed event so the next turn can
59
+ * resume it (codex `thread.started.thread_id`, amp `session_id`). */
60
+ extractSessionId(parsed: Record<string, unknown>): string | undefined;
61
+ }
62
+
63
+ export class CliAgentRunner implements ModelExecutionContract {
64
+ readonly kind: string;
65
+
66
+ constructor(
67
+ private readonly sandbox: SandboxProvider,
68
+ private readonly options: RuntimeOptions,
69
+ private readonly spec: CliAgentSpec,
70
+ private readonly configModel?: string,
71
+ ) {
72
+ this.kind = spec.kind;
73
+ }
74
+
75
+ get model(): string | undefined {
76
+ return this.configModel ?? this.options.model ?? this.spec.defaultModel;
77
+ }
78
+
79
+ // No captureCheckpoint/restoreCheckpoint: the CLI persists its thread/rollout
80
+ // on the sandbox filesystem (which round-trips through the pause snapshot),
81
+ // and the loop already carries the session id we emit on init/done and pass
82
+ // back as `sessionId` for resume — same model as claudeRuntime.
83
+
84
+ async *sendMessage(opts: {
85
+ prompt: string;
86
+ sessionId?: string;
87
+ iteration?: number;
88
+ signal?: AbortSignal;
89
+ }): AsyncGenerator<AgentMessage> {
90
+ const promptPath = `/tmp/ac-${this.spec.kind}-${this.options.agentId ?? "agent"}-${opts.iteration ?? 0}.in`;
91
+ await this.sandbox.files.write(promptPath, this.spec.promptPayload(opts.prompt));
92
+ const cmd = this.spec.buildCommand({
93
+ promptPath,
94
+ sessionId: opts.sessionId,
95
+ model: this.model,
96
+ cwd: this.options.cwd,
97
+ });
98
+
99
+ // Bridge the streaming stdout callback into an async-iterable of complete
100
+ // JSONL lines. `onStdout` chunks aren't line-aligned, so buffer + split.
101
+ const lines = new AsyncQueue<string>();
102
+ let buf = "";
103
+ const onStdout = (data: string) => {
104
+ buf += data;
105
+ let nl: number;
106
+ while ((nl = buf.indexOf("\n")) >= 0) {
107
+ const line = buf.slice(0, nl).trim();
108
+ buf = buf.slice(nl + 1);
109
+ if (line) lines.push(line);
110
+ }
111
+ };
112
+
113
+ // `commands.run` resolves when the process exits. Kick it off (don't await
114
+ // yet); flush the trailing buffer + close the queue on completion so the
115
+ // for-await below drains and we can read the exit code.
116
+ const runPromise = this.sandbox.commands.run(cmd, {
117
+ ...(this.options.cwd ? { cwd: this.options.cwd } : {}),
118
+ onStdout,
119
+ }).then(
120
+ (res) => { const tail = buf.trim(); if (tail) lines.push(tail); lines.close(); return res; },
121
+ (err) => { lines.close(); throw err; },
122
+ );
123
+
124
+ yield { type: "init", sessionId: opts.sessionId ?? "", timestamp: now() };
125
+
126
+ let sessionId = opts.sessionId;
127
+ let sawError = false;
128
+ try {
129
+ for await (const line of lines) {
130
+ let parsed: Record<string, unknown>;
131
+ try {
132
+ parsed = JSON.parse(line) as Record<string, unknown>;
133
+ } catch {
134
+ continue; // skip any non-JSON noise that lands on stdout
135
+ }
136
+ const sid = this.spec.extractSessionId(parsed);
137
+ if (sid) sessionId = sid;
138
+ for (const msg of this.spec.mapEvent(parsed)) {
139
+ if (msg.type === "error") sawError = true;
140
+ yield msg;
141
+ }
142
+ }
143
+
144
+ const res = await runPromise;
145
+ if (res.exitCode !== 0 && !sawError) {
146
+ const tail = (res.stderr ?? "").slice(-2000);
147
+ yield { type: "error", text: `${this.spec.kind} exited with code ${res.exitCode}${tail ? `: ${tail}` : ""}`, timestamp: now() };
148
+ return;
149
+ }
150
+ if (!sawError) yield { type: "done", sessionId: sessionId ?? "", timestamp: now() };
151
+ } catch (err) {
152
+ yield { type: "error", text: formatError(err), timestamp: now() };
153
+ }
154
+ }
155
+ }
156
+
157
+ export function createCliAgentRuntime(spec: CliAgentSpec, configModel?: string) {
158
+ return defineRuntime({
159
+ create: (sandbox, opts) => new CliAgentRunner(sandbox, opts, spec, configModel),
160
+ });
161
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Amp CLI runtime — drives Sourcegraph's `amp -x --stream-json` agentic CLI
3
+ * inside the sandbox and maps its (Claude-Code-compatible) JSONL stream onto
4
+ * the AgentMessage contract. Built on the shared CLI-agent base; Amp brings its
5
+ * own loop + tools, so we only stream-parse what it prints.
6
+ *
7
+ * Auth: set `AMP_API_KEY` (`sgamp_…`) in the sandbox env via a workflow secret.
8
+ * Requires the `amp` CLI (`@ampcode/cli`) installed in the sandbox image. The
9
+ * model is chosen by the AMP_API_KEY account (e.g. a GPT-only token runs GPT);
10
+ * the runtime doesn't pin a model.
11
+ *
12
+ * ⚠️ NOT verified against a live `amp` run — the thread-continue syntax and the
13
+ * exact assistant/result shapes are mapped from the docs (ampcode.com). Verify
14
+ * before production use.
15
+ */
16
+
17
+ import type { AgentMessage } from "../index.js";
18
+ import { createCliAgentRuntime, shellQuote, type CliAgentSpec } from "./_cli-agent.js";
19
+ import { formatError } from "../utils/errors.js";
20
+
21
+ function now(): string { return new Date().toISOString(); }
22
+
23
+ /** Block array off either `{ message: { content } }` (Claude shape) or a
24
+ * top-level `{ content }`, whichever the stream uses. */
25
+ function blocks(msg: Record<string, unknown>): Array<Record<string, unknown>> {
26
+ const inner = (msg.message as { content?: unknown[] } | undefined)?.content
27
+ ?? (msg.content as unknown[] | undefined)
28
+ ?? [];
29
+ return inner as Array<Record<string, unknown>>;
30
+ }
31
+
32
+ const ampSpec: CliAgentSpec = {
33
+ kind: "amp",
34
+ authEnv: "AMP_API_KEY",
35
+ // `--stream-json-input` reads JSON Lines user messages from stdin; write one.
36
+ // amp's --stream-json-input wants Claude-shaped content blocks, not a bare
37
+ // string (it rejects a string `content` with "expected array, received string").
38
+ promptPayload: (prompt) =>
39
+ JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: prompt }] } }) + "\n",
40
+ buildCommand: ({ promptPath, sessionId }) => {
41
+ // Continue the prior thread by id when we have one; else start fresh.
42
+ const cont = sessionId ? `threads continue ${shellQuote(sessionId)} ` : "";
43
+ return `amp ${cont}-x --stream-json --stream-json-input < ${shellQuote(promptPath)}`;
44
+ },
45
+ // Amp stamps `session_id` (a "T-…" thread id) on every message.
46
+ extractSessionId: (p) => (typeof p.session_id === "string" ? p.session_id : undefined),
47
+ mapEvent: (p): AgentMessage[] => {
48
+ const ts = now();
49
+ if (p.type === "assistant") {
50
+ return blocks(p).flatMap((b): AgentMessage[] => {
51
+ if (b.type === "text") return [{ type: "text", text: String(b.text ?? ""), timestamp: ts }];
52
+ if (b.type === "thinking") return [{ type: "thinking", text: String(b.thinking ?? ""), timestamp: ts }];
53
+ if (b.type === "tool_use") return [{ type: "tool_use", toolName: String(b.name ?? ""), toolInput: (b.input ?? {}) as Record<string, unknown>, toolUseId: String(b.id ?? ""), timestamp: ts }];
54
+ return [];
55
+ });
56
+ }
57
+ if (p.type === "user") {
58
+ return blocks(p).flatMap((b): AgentMessage[] =>
59
+ b.type === "tool_result"
60
+ ? [{ type: "tool_result", toolUseId: String(b.tool_use_id ?? ""), output: typeof b.content === "string" ? b.content : JSON.stringify(b.content ?? ""), isError: Boolean(b.is_error), timestamp: ts }]
61
+ : []);
62
+ }
63
+ if (p.type === "result") {
64
+ const out: AgentMessage[] = [];
65
+ const u = p.usage as Record<string, number> | undefined;
66
+ if (u) out.push({
67
+ type: "usage",
68
+ inputTokens: u.input_tokens ?? 0,
69
+ outputTokens: u.output_tokens ?? 0,
70
+ cacheReadTokens: u.cache_read_input_tokens ?? 0,
71
+ cacheCreationTokens: u.cache_creation_input_tokens ?? 0,
72
+ durationMs: Number(p.duration_ms ?? 0),
73
+ numTurns: Number(p.num_turns ?? 0),
74
+ timestamp: ts,
75
+ });
76
+ if (p.is_error || p.subtype === "error") {
77
+ out.push({ type: "error", text: formatError(p.result ?? p.error), timestamp: ts });
78
+ }
79
+ return out;
80
+ }
81
+ return []; // "system" → session id captured by extractSessionId; init/done owned by the base
82
+ },
83
+ };
84
+
85
+ export interface AmpRuntimeConfig {
86
+ /** Amp uses its configured model; reserved for forward-compatibility. */
87
+ model?: string;
88
+ }
89
+
90
+ export function createAmpRuntime(config: AmpRuntimeConfig = {}) {
91
+ return createCliAgentRuntime(ampSpec, config.model);
92
+ }
93
+
94
+ export default createAmpRuntime();