@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.
- package/dist/active-step.d.ts +60 -0
- package/dist/agent/agent-loop-steer.test.d.ts +1 -0
- package/dist/agent/agent-loop.d.ts +46 -0
- package/dist/agent/async-queue.d.ts +29 -0
- package/dist/agent/protocol.d.ts +9 -1
- package/dist/agent/resolve-agent-id.test.d.ts +1 -0
- package/dist/agent/run-agent.d.ts +16 -3
- package/dist/agent/steer-control.d.ts +57 -0
- package/dist/agent/steer-control.test.d.ts +1 -0
- package/dist/client.d.ts +161 -0
- package/dist/index.d.ts +16 -6
- package/dist/index.js +1586 -158
- package/dist/pause/__tests__/agent-loop-checkpoint.test.d.ts +1 -0
- package/dist/pause/__tests__/checkpoint.test.d.ts +1 -0
- package/dist/pause/__tests__/errors.test.d.ts +1 -0
- package/dist/pause/__tests__/manager.test.d.ts +1 -0
- package/dist/pause/__tests__/pause-core.test.d.ts +1 -0
- package/dist/pause/__tests__/state-dir.test.d.ts +1 -0
- package/dist/pause/__tests__/wrappers.test.d.ts +1 -0
- package/dist/pause/checkpoint.d.ts +28 -0
- package/dist/pause/errors.d.ts +52 -0
- package/dist/pause/manager.d.ts +63 -0
- package/dist/pause/pause-core.d.ts +101 -0
- package/dist/pause/state-dir.d.ts +80 -0
- package/dist/pause/wrappers.d.ts +41 -0
- package/dist/request-context/request-context.d.ts +12 -0
- package/dist/runtimes/_cli-agent.d.ts +72 -0
- package/dist/runtimes/amp.d.ts +22 -0
- package/dist/runtimes/claude.d.ts +6 -0
- package/dist/runtimes/codex.d.ts +20 -0
- package/dist/runtimes/openai-desktop.d.ts +2 -0
- package/dist/runtimes/openai-desktop.js +1578 -157
- package/dist/runtimes/vercel.d.ts +53 -1
- package/dist/runtimes/vercel.js +60 -8
- package/dist/runtimes/vercel.test.d.ts +1 -0
- package/dist/sse.d.ts +2 -3
- package/dist/step-invocation/index.d.ts +2 -2
- package/dist/step-invocation/invoker.d.ts +3 -0
- package/dist/step-invocation/protocol.d.ts +12 -0
- package/dist/step-invocation/server.d.ts +1 -0
- package/dist/step-invocation/types.d.ts +40 -5
- package/dist/types/events.d.ts +9 -0
- package/dist/types/execution-context.d.ts +25 -0
- package/dist/types/protocol.d.ts +8 -0
- package/dist/types/runtime.d.ts +55 -0
- package/dist/types/sandbox.d.ts +4 -4
- package/dist/utils/schemas.d.ts +2 -0
- package/dist/workflow-steps/__tests__/pause-wiring.test.d.ts +1 -0
- package/dist/workflow-steps/index.d.ts +2 -0
- package/dist/workflow-steps/observability.d.ts +43 -11
- package/dist/workflow-steps/run-callback.d.ts +39 -0
- package/dist/workflow-steps/runner.d.ts +8 -0
- package/package.json +1 -1
- package/src/active-step.ts +124 -0
- package/src/agent/agent-loop.ts +253 -19
- package/src/agent/async-queue.ts +61 -0
- package/src/agent/protocol.ts +12 -2
- package/src/agent/run-agent.ts +184 -8
- package/src/agent/steer-control.ts +125 -0
- package/src/client.ts +277 -0
- package/src/index.ts +38 -4
- package/src/pause/checkpoint.ts +44 -0
- package/src/pause/errors.ts +70 -0
- package/src/pause/manager.ts +177 -0
- package/src/pause/pause-core.ts +267 -0
- package/src/pause/state-dir.ts +262 -0
- package/src/pause/wrappers.ts +79 -0
- package/src/request-context/request-context.ts +17 -2
- package/src/runtimes/_cli-agent.ts +161 -0
- package/src/runtimes/amp.ts +94 -0
- package/src/runtimes/claude.ts +101 -6
- package/src/runtimes/codex.ts +109 -0
- package/src/runtimes/openai-desktop.ts +11 -0
- package/src/runtimes/vercel.ts +78 -2
- package/src/sandbox.ts +39 -20
- package/src/sse.ts +8 -6
- package/src/step-invocation/index.ts +2 -1
- package/src/step-invocation/invoker.ts +107 -29
- package/src/step-invocation/protocol.ts +16 -0
- package/src/step-invocation/server.ts +45 -12
- package/src/step-invocation/types.ts +43 -7
- package/src/tools/coding.ts +16 -5
- package/src/types/events.ts +9 -0
- package/src/types/execution-context.ts +25 -0
- package/src/types/protocol.ts +8 -0
- package/src/types/runtime.ts +52 -0
- package/src/types/sandbox.ts +8 -4
- package/src/types/workflow.ts +6 -1
- package/src/utils/bundler.ts +8 -3
- package/src/utils/schemas.ts +2 -0
- package/src/workflow-steps/index.ts +3 -0
- package/src/workflow-steps/observability.ts +84 -13
- package/src/workflow-steps/run-callback.ts +72 -0
- package/src/workflow-steps/runner.ts +70 -8
- package/dist/utils/discovery.d.ts +0 -2
- 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
|
-
{ ...
|
|
190
|
-
new Map(Object.entries(
|
|
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();
|