@agent-compose/sdk 0.2.1 → 0.2.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.
- package/package.json +10 -2
- package/src/agent/agent-loop.ts +131 -0
- package/src/agent/protocol-suffix.md +57 -0
- package/src/agent/protocol.ts +22 -0
- package/src/agent/run-agent.ts +141 -0
- package/src/client.ts +446 -0
- package/src/env.d.ts +5 -0
- package/src/errors.ts +10 -0
- package/src/index.ts +111 -0
- package/src/runtimes/claude.ts +305 -0
- package/src/runtimes/openai-desktop.ts +151 -0
- package/src/sandbox.ts +458 -0
- package/src/sse.ts +56 -0
- package/src/types/events.ts +51 -0
- package/src/types/protocol.ts +74 -0
- package/src/types/runtime.ts +59 -0
- package/src/types/sandbox-environment.ts +64 -0
- package/src/types/sandbox.ts +51 -0
- package/src/types/workflow.ts +128 -0
- package/src/utils/bundler.ts +81 -0
- package/src/utils/discovery.ts +4 -0
- package/src/utils/errors.ts +4 -0
- package/src/utils/schemas.ts +10 -0
- package/src/utils/source-loader.ts +16 -0
- package/src/workflows/engine.ts +110 -0
package/src/sandbox.ts
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox provider registry — creates and manages isolated execution environments.
|
|
3
|
+
*
|
|
4
|
+
* Providers: "vercel" (Vercel Sandbox), "e2b" (E2B), "e2b-desktop" (E2B Desktop).
|
|
5
|
+
* Each provider validates its required env vars at creation time.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { promises as fs } from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { Sandbox } from "e2b";
|
|
12
|
+
import { Sandbox as Desktop } from "@e2b/desktop";
|
|
13
|
+
import pRetry from "p-retry";
|
|
14
|
+
import type { SandboxProvider, DesktopSandboxProvider } from "./types/sandbox.js";
|
|
15
|
+
|
|
16
|
+
export type { SandboxProvider, DesktopSandboxProvider } from "./types/sandbox.js";
|
|
17
|
+
|
|
18
|
+
// Fleet-wide tag, NOT machine-scoped. Previously we embedded FLY_MACHINE_ID
|
|
19
|
+
// so each replica scoped its own E2B sandboxes at boot, but that made
|
|
20
|
+
// cross-replica orphan reconciliation impossible: when a replica hard-died,
|
|
21
|
+
// its E2B sandboxes became invisible to every surviving replica and stayed
|
|
22
|
+
// running until E2B's own lifetime cap. With a fleet-wide tag the provider
|
|
23
|
+
// reconciler (`listOwned` + DB cross-ref) handles cleanup correctly across
|
|
24
|
+
// all replicas. Boot-time `killAllSandboxes` was deliberately removed when
|
|
25
|
+
// this changed — killing at boot with a shared tag would nuke live sandboxes
|
|
26
|
+
// owned by other replicas.
|
|
27
|
+
export const AGENT_COMPOSE_TAG = process.env.AGENT_COMPOSE_TAG ??
|
|
28
|
+
`agent-compose-${process.env.AGENT_COMPOSE_ENV ?? "dev"}`;
|
|
29
|
+
|
|
30
|
+
/** Upper bound for how old a live Vercel sandbox can be: Pro/Enterprise plan
|
|
31
|
+
* cap (5h) + 1h slack for clock skew and `extendTimeout()` calls. */
|
|
32
|
+
const VERCEL_VM_LIFETIME_WINDOW_MS = 6 * 60 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Vercel-compatible network policy for outbound HTTPS requests.
|
|
36
|
+
* When a sandbox makes a request matching a domain in `allow`, the Vercel
|
|
37
|
+
* firewall injects the specified headers before forwarding — credentials never
|
|
38
|
+
* exist inside the VM. E2B ignores this field (future self-hosted mapping TBD).
|
|
39
|
+
*/
|
|
40
|
+
export type SandboxNetworkPolicy =
|
|
41
|
+
| "allow-all"
|
|
42
|
+
| "deny-all"
|
|
43
|
+
| {
|
|
44
|
+
allow?: string[] | Record<string, Array<{ transform?: Array<{ headers?: Record<string, string> }> }>>;
|
|
45
|
+
subnets?: { allow?: string[]; deny?: string[] };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export interface SandboxCreateOpts {
|
|
49
|
+
envs: Record<string, string>;
|
|
50
|
+
metadata: Record<string, string>;
|
|
51
|
+
timeoutMs: number;
|
|
52
|
+
/** Provider-specific template/snapshot identifier. E2B: template ID; Vercel: snapshot ID. */
|
|
53
|
+
template?: string;
|
|
54
|
+
/** Outbound request policy. Vercel only — E2B silently ignores. */
|
|
55
|
+
networkPolicy?: SandboxNetworkPolicy;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What the provider reports as "currently alive" — the single input to orphan
|
|
60
|
+
* reconciliation. `metadata` is best-effort: E2B populates it from sandbox
|
|
61
|
+
* labels, Vercel leaves it empty (the API has no metadata field). Callers
|
|
62
|
+
* that need runId correlation cross-reference `sandboxId` against their own
|
|
63
|
+
* state (server: `workflow_runs.sandbox_id` + `run_agent_sandboxes.provider_sandbox_id`).
|
|
64
|
+
*/
|
|
65
|
+
export interface OwnedSandbox {
|
|
66
|
+
sandboxId: string;
|
|
67
|
+
createdAt: Date;
|
|
68
|
+
metadata: Record<string, string>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface SandboxProviderDef {
|
|
72
|
+
requiredEnv: Record<string, string>;
|
|
73
|
+
create: (opts: SandboxCreateOpts, env: Record<string, string>) => Promise<SandboxProvider>;
|
|
74
|
+
reconnect?: (sandboxId: string) => Promise<SandboxProvider>;
|
|
75
|
+
killAll?: (env: Record<string, string>) => Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Returns the number of sandboxes currently running against this provider.
|
|
78
|
+
* Used for quota observability — account-wide, not per-instance.
|
|
79
|
+
*
|
|
80
|
+
* E2B scopes by our AGENT_COMPOSE_TAG metadata so each machine only reports
|
|
81
|
+
* its own sandboxes; DD aggregates across instances with `sum by {provider}`.
|
|
82
|
+
* Vercel has no user metadata support, so it counts every running sandbox in
|
|
83
|
+
* the configured project (assumed dedicated to agent-compose).
|
|
84
|
+
*/
|
|
85
|
+
getActiveCount?: (env: Record<string, string>) => Promise<number>;
|
|
86
|
+
/**
|
|
87
|
+
* Provider's view of what's currently alive for our account/fleet.
|
|
88
|
+
* The reconciliation SoT — a sandbox missing from this list IS dead,
|
|
89
|
+
* regardless of what our DB says. Implemented by listing the provider's
|
|
90
|
+
* running sandboxes; E2B filters by metadata tag, Vercel lists the whole
|
|
91
|
+
* project (it has no metadata search). 200-row cap applies to Vercel.
|
|
92
|
+
*/
|
|
93
|
+
listOwned?: (env: Record<string, string>) => Promise<OwnedSandbox[]>;
|
|
94
|
+
/**
|
|
95
|
+
* Delete a snapshot by id. Called from the customer-initiated
|
|
96
|
+
* `DELETE /workflows/:runId/snapshot` route. Best-effort — the DB is
|
|
97
|
+
* source of truth; an orphan on the provider side is benign and cleaned
|
|
98
|
+
* up eventually by the provider's own retention.
|
|
99
|
+
*/
|
|
100
|
+
deleteSnapshot?: (snapshotId: string, env: Record<string, string>) => Promise<void>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── E2B helpers ───────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
export function makeSandboxProvider(sb: Sandbox | Desktop): SandboxProvider {
|
|
106
|
+
return { sandboxId: sb.sandboxId, commands: sb.commands, files: sb.files, kill: () => sb.kill() };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function makeDesktopSandboxProvider(sb: Desktop): DesktopSandboxProvider {
|
|
110
|
+
return {
|
|
111
|
+
...makeSandboxProvider(sb),
|
|
112
|
+
screenshot: () => sb.screenshot() as Promise<Buffer>,
|
|
113
|
+
leftClick: (x, y) => sb.leftClick(x, y),
|
|
114
|
+
doubleClick: (x, y) => sb.doubleClick(x, y),
|
|
115
|
+
rightClick: (x, y) => sb.rightClick(x, y),
|
|
116
|
+
middleClick: (x, y) => sb.middleClick(x, y),
|
|
117
|
+
moveMouse: (x, y) => sb.moveMouse(x, y),
|
|
118
|
+
write: (text) => sb.write(text),
|
|
119
|
+
press: (key) => sb.press(key),
|
|
120
|
+
scroll: (dir, ticks) => sb.scroll(dir, ticks),
|
|
121
|
+
drag: (from, to) => sb.drag(from, to),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── SSE helper (used by broker clients in runner.ts) ─────────────────────────
|
|
126
|
+
|
|
127
|
+
type SseEvent = { type: string; data?: string; exitCode?: number };
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Parse an SSE exec stream from a ReadableStream.
|
|
131
|
+
* Used by the agent sandbox broker in runner.ts.
|
|
132
|
+
*/
|
|
133
|
+
export async function parseSseExecStream(
|
|
134
|
+
body: ReadableStream<Uint8Array>,
|
|
135
|
+
opts?: { onStdout?: (d: string) => void; onStderr?: (d: string) => void },
|
|
136
|
+
): Promise<{ exitCode: number; stdout: string }> {
|
|
137
|
+
let stdout = "", exitCode = 0, exited = false;
|
|
138
|
+
const reader = body.getReader(), decoder = new TextDecoder();
|
|
139
|
+
let buf = "";
|
|
140
|
+
while (true) {
|
|
141
|
+
const { done, value } = await reader.read();
|
|
142
|
+
if (done) break;
|
|
143
|
+
buf += decoder.decode(value, { stream: true });
|
|
144
|
+
const lines = buf.replace(/\r\n/g, "\n").split("\n");
|
|
145
|
+
buf = lines.pop() ?? "";
|
|
146
|
+
for (const line of lines) {
|
|
147
|
+
if (!line.startsWith("data: ")) continue;
|
|
148
|
+
let event: SseEvent;
|
|
149
|
+
try { event = JSON.parse(line.slice(6)) as SseEvent; } catch { continue; }
|
|
150
|
+
if (event.type === "stdout") { stdout += event.data ?? ""; opts?.onStdout?.(event.data ?? ""); }
|
|
151
|
+
else if (event.type === "stderr") { opts?.onStderr?.(event.data ?? ""); }
|
|
152
|
+
else if (event.type === "exit") { exitCode = event.exitCode ?? 0; exited = true; }
|
|
153
|
+
else if (event.type === "error") { throw new Error(`Sandbox exec error: ${event.data}`); }
|
|
154
|
+
}
|
|
155
|
+
// Stop reading as soon as the exit event is received — don't wait for stream close.
|
|
156
|
+
// QUIC tunnels can delay the stream-end signal even after all data has arrived.
|
|
157
|
+
if (exited) break;
|
|
158
|
+
}
|
|
159
|
+
return { exitCode, stdout };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Vercel helpers ────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
165
|
+
function makeVercelSandboxProvider(sb: any, globalEnvs?: Record<string, string>): SandboxProvider {
|
|
166
|
+
// Vercel's Sandbox.create({ env }) does NOT flow to runCommand subprocesses — they start fresh shells.
|
|
167
|
+
// Capture the sandbox-level envs and merge them into every runCommand call so that env vars like
|
|
168
|
+
// ANTHROPIC_API_KEY (placeholder for network policy injection) are actually visible to subprocesses.
|
|
169
|
+
const mergeEnvs = (cmdEnvs?: Record<string, string>) =>
|
|
170
|
+
globalEnvs ? { ...globalEnvs, ...cmdEnvs } : cmdEnvs;
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
sandboxId: sb.sandboxId,
|
|
174
|
+
commands: {
|
|
175
|
+
async run(cmd, opts) {
|
|
176
|
+
if (opts?.background) {
|
|
177
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
178
|
+
void (sb.runCommand({ cmd: "sh", args: ["-c", cmd], cwd: opts.cwd, env: mergeEnvs(opts.envs), detached: true }) as Promise<any>).catch(() => {});
|
|
179
|
+
return { exitCode: 0, stdout: "" };
|
|
180
|
+
}
|
|
181
|
+
const signal = opts?.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined;
|
|
182
|
+
let stdout = "";
|
|
183
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
184
|
+
const handle: any = await sb.runCommand({ cmd: "sh", args: ["-c", cmd], cwd: opts?.cwd, env: mergeEnvs(opts?.envs), detached: true, signal });
|
|
185
|
+
// Reconnect to the already-running command on transient stream failures (e.g. BrotliDecompressionError).
|
|
186
|
+
await pRetry(async (attempt) => {
|
|
187
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
188
|
+
const h: any = attempt === 1 ? handle : await sb.getCommand(handle.cmdId);
|
|
189
|
+
for await (const log of h.logs()) {
|
|
190
|
+
if (log.stream === "stdout") { stdout += log.data; opts?.onStdout?.(log.data); }
|
|
191
|
+
else { opts?.onStderr?.(log.data); }
|
|
192
|
+
}
|
|
193
|
+
}, { retries: 3, minTimeout: 1_000, factor: 2 });
|
|
194
|
+
const finished = await handle.wait();
|
|
195
|
+
return { exitCode: finished.exitCode, stdout };
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
files: {
|
|
199
|
+
async write(path: string, content: string) {
|
|
200
|
+
await sb.writeFiles([{ path, content }]);
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
// Propagate errors — `killAllRunSandboxes` relies on kill failures being
|
|
204
|
+
// observable so it can leave `sandbox_id` set for `findOrphanedSandboxes`
|
|
205
|
+
// to retry on next boot. Swallowing here makes the orphan retry loop blind.
|
|
206
|
+
async kill() { await sb.stop(); },
|
|
207
|
+
// Vercel native snapshot — used by sandbox-environments to capture the
|
|
208
|
+
// configured VM after the customer's `setup()` completes. `expiration: 0`
|
|
209
|
+
// is Vercel's "never expires" value; env snapshots are long-lived by
|
|
210
|
+
// design (they ARE the env) so we always pass it.
|
|
211
|
+
async snapshot() {
|
|
212
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
213
|
+
const res: any = await sb.snapshot({ expiration: 0 });
|
|
214
|
+
return { snapshotId: res.snapshotId as string };
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Local provider (runner's own VM) ──────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
/** Minimal SandboxProvider that targets the current process's host VM.
|
|
222
|
+
* `commands.run` → `child_process.spawn`; `files.write` → `fs.writeFile`;
|
|
223
|
+
* `kill` is a no-op because the caller IS the VM. Used by
|
|
224
|
+
* `defineSandboxEnvironment` so setup recipes read like imperative
|
|
225
|
+
* provisioning scripts. Uses `node:child_process` — works under both Node
|
|
226
|
+
* (the runner executes `node /tmp/runner.bundle.js`) and Bun. */
|
|
227
|
+
export function makeLocalSandboxProvider(): SandboxProvider {
|
|
228
|
+
return {
|
|
229
|
+
sandboxId: "local",
|
|
230
|
+
commands: {
|
|
231
|
+
run(cmd, opts) {
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
const proc = spawn("sh", ["-c", cmd], {
|
|
234
|
+
...(opts?.cwd ? { cwd: opts.cwd } : {}),
|
|
235
|
+
env: { ...process.env, ...(opts?.envs ?? {}) },
|
|
236
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
237
|
+
});
|
|
238
|
+
let stdout = "";
|
|
239
|
+
proc.stdout?.setEncoding("utf8");
|
|
240
|
+
proc.stderr?.setEncoding("utf8");
|
|
241
|
+
proc.stdout?.on("data", (chunk: string) => { stdout += chunk; opts?.onStdout?.(chunk); });
|
|
242
|
+
proc.stderr?.on("data", (chunk: string) => { opts?.onStderr?.(chunk); });
|
|
243
|
+
proc.on("error", reject);
|
|
244
|
+
proc.on("close", (code) => resolve({ exitCode: code ?? 0, stdout }));
|
|
245
|
+
});
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
files: {
|
|
249
|
+
async write(path, content) {
|
|
250
|
+
await fs.mkdir(dirname(path), { recursive: true });
|
|
251
|
+
await fs.writeFile(path, content);
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
async kill() { /* caller IS the sandbox — killing it is the server's job */ },
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Provider registry ─────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
const SANDBOX_PROVIDERS: Record<string, SandboxProviderDef> = {
|
|
261
|
+
"vercel": {
|
|
262
|
+
requiredEnv: {
|
|
263
|
+
VERCEL_ACCESS_TOKEN: "Vercel access token — vercel.com/account/tokens",
|
|
264
|
+
VERCEL_TEAM_ID: "Vercel team ID — vercel.com/account/settings",
|
|
265
|
+
VERCEL_PROJECT_ID: "Vercel project ID — vercel.com/[team]/[project]/settings",
|
|
266
|
+
},
|
|
267
|
+
create: async (opts, env) => {
|
|
268
|
+
// Dynamic import keeps @vercel/sandbox out of runner.bundle.js (runner never calls this path)
|
|
269
|
+
const { Sandbox: VercelSandbox } = await import("@vercel/sandbox");
|
|
270
|
+
const creds = { token: env.VERCEL_ACCESS_TOKEN, teamId: env.VERCEL_TEAM_ID, projectId: env.VERCEL_PROJECT_ID };
|
|
271
|
+
// `template` is a snapshot id (runtime is baked in); absent → fresh node24.
|
|
272
|
+
const np = opts.networkPolicy;
|
|
273
|
+
const sb = await VercelSandbox.create(opts.template
|
|
274
|
+
? { source: { type: "snapshot" as const, snapshotId: opts.template }, timeout: opts.timeoutMs, env: opts.envs, ...(np ? { networkPolicy: np } : {}), ...creds }
|
|
275
|
+
: { runtime: "node24" as const, timeout: opts.timeoutMs, env: opts.envs, ...(np ? { networkPolicy: np } : {}), ...creds },
|
|
276
|
+
);
|
|
277
|
+
// Pass envs as globalEnvs so they're injected into every runCommand subprocess.
|
|
278
|
+
// (Vercel's Sandbox.create env parameter does not flow to runCommand subprocesses.)
|
|
279
|
+
return makeVercelSandboxProvider(sb, opts.envs);
|
|
280
|
+
},
|
|
281
|
+
reconnect: async (sandboxId) => {
|
|
282
|
+
const { Sandbox: VercelSandbox } = await import("@vercel/sandbox");
|
|
283
|
+
const creds = {
|
|
284
|
+
token: process.env.VERCEL_ACCESS_TOKEN ?? "",
|
|
285
|
+
teamId: process.env.VERCEL_TEAM_ID ?? "",
|
|
286
|
+
projectId: process.env.VERCEL_PROJECT_ID ?? "",
|
|
287
|
+
};
|
|
288
|
+
return makeVercelSandboxProvider(await VercelSandbox.get({ sandboxId, ...creds }));
|
|
289
|
+
},
|
|
290
|
+
getActiveCount: async (env) => (await SANDBOX_PROVIDERS.vercel.listOwned!(env)).length,
|
|
291
|
+
listOwned: async (env) => {
|
|
292
|
+
const { Sandbox: VercelSandbox } = await import("@vercel/sandbox");
|
|
293
|
+
const creds = { token: env.VERCEL_ACCESS_TOKEN, teamId: env.VERCEL_TEAM_ID, projectId: env.VERCEL_PROJECT_ID };
|
|
294
|
+
// Bounds the scan to `VERCEL_VM_LIFETIME_WINDOW_MS` — Pro/Enterprise caps
|
|
295
|
+
// VM lifetime at 5h, +1h slack for clock skew and `extendTimeout()`.
|
|
296
|
+
// Without this, projects with thousands of historical terminal sandboxes
|
|
297
|
+
// page forever (10k+ in v0.6.28). Truncating silently would hide orphans,
|
|
298
|
+
// so we throw at the 50-page cap instead. Each page is wrapped in pRetry
|
|
299
|
+
// so a transient 5xx/429/network blip doesn't cost a reconcile tick.
|
|
300
|
+
const since = Date.now() - VERCEL_VM_LIFETIME_WINDOW_MS;
|
|
301
|
+
const out: OwnedSandbox[] = [];
|
|
302
|
+
let until: number | undefined;
|
|
303
|
+
for (let page = 0; page < 50; page++) {
|
|
304
|
+
const { json } = await pRetry(
|
|
305
|
+
() => VercelSandbox.list({ ...creds, limit: 200, since, ...(until !== undefined ? { until } : {}) }),
|
|
306
|
+
{ retries: 3, minTimeout: 500, factor: 2 },
|
|
307
|
+
);
|
|
308
|
+
for (const sb of json.sandboxes) if (sb.status === "running") {
|
|
309
|
+
out.push({ sandboxId: sb.id, createdAt: new Date(sb.createdAt), metadata: {} });
|
|
310
|
+
}
|
|
311
|
+
if (json.pagination.next === null) return out;
|
|
312
|
+
until = json.pagination.next;
|
|
313
|
+
}
|
|
314
|
+
throw new Error("Vercel listSandboxes exceeded 50 pages within the lifetime window — refuse to silently truncate");
|
|
315
|
+
},
|
|
316
|
+
deleteSnapshot: async (snapshotId, env) => {
|
|
317
|
+
const { Snapshot } = await import("@vercel/sandbox");
|
|
318
|
+
const creds = { token: env.VERCEL_ACCESS_TOKEN, teamId: env.VERCEL_TEAM_ID, projectId: env.VERCEL_PROJECT_ID };
|
|
319
|
+
const snap = await Snapshot.get({ snapshotId, ...creds });
|
|
320
|
+
await snap.delete();
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
"e2b": {
|
|
324
|
+
requiredEnv: { E2B_API_KEY: "E2B API key — e2b.dev/dashboard" },
|
|
325
|
+
create: async ({ template, ...sandboxOpts }) => {
|
|
326
|
+
if (!template) throw new Error("E2B provider requires an explicit `template` (Dockerfile-based — no default base image)");
|
|
327
|
+
return makeSandboxProvider(await Sandbox.create(template, sandboxOpts));
|
|
328
|
+
},
|
|
329
|
+
reconnect: async (sandboxId) => makeSandboxProvider(
|
|
330
|
+
await Sandbox.connect(sandboxId, { apiKey: process.env.E2B_API_KEY ?? "", timeoutMs: 60 * 60 * 1000 }),
|
|
331
|
+
),
|
|
332
|
+
killAll: async () => {
|
|
333
|
+
const paginator = Sandbox.list({ query: { metadata: { executor: AGENT_COMPOSE_TAG } } });
|
|
334
|
+
const sandboxes = [];
|
|
335
|
+
while (paginator.hasNext) sandboxes.push(...await paginator.nextItems());
|
|
336
|
+
if (sandboxes.length === 0) return;
|
|
337
|
+
await Promise.all(sandboxes.map(s => Sandbox.kill(s.sandboxId).catch(() => {})));
|
|
338
|
+
console.info(`[sandbox] killed ${sandboxes.length} stale E2B sandboxes (tag: ${AGENT_COMPOSE_TAG})`);
|
|
339
|
+
},
|
|
340
|
+
getActiveCount: async () => (await SANDBOX_PROVIDERS.e2b.listOwned!({})).length,
|
|
341
|
+
listOwned: async () => {
|
|
342
|
+
const paginator = Sandbox.list({ query: { metadata: { executor: AGENT_COMPOSE_TAG }, state: ["running"] } });
|
|
343
|
+
const out: OwnedSandbox[] = [];
|
|
344
|
+
while (paginator.hasNext) {
|
|
345
|
+
const items = await pRetry(() => paginator.nextItems(), { retries: 3, minTimeout: 500, factor: 2 });
|
|
346
|
+
for (const sb of items) {
|
|
347
|
+
out.push({
|
|
348
|
+
sandboxId: sb.sandboxId,
|
|
349
|
+
createdAt: new Date(sb.startedAt),
|
|
350
|
+
metadata: sb.metadata ?? {},
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return out;
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
"e2b-desktop": {
|
|
358
|
+
requiredEnv: { E2B_API_KEY: "E2B API key — e2b.dev/dashboard" },
|
|
359
|
+
create: async ({ template, ...sandboxOpts }) => {
|
|
360
|
+
if (!template) throw new Error("E2B Desktop provider requires an explicit `template` (Dockerfile-based — no default base image)");
|
|
361
|
+
return makeDesktopSandboxProvider(await Desktop.create(template, sandboxOpts));
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
/** Provision a sandbox for the named provider. */
|
|
367
|
+
export async function createSandbox(provider: string, opts: SandboxCreateOpts): Promise<SandboxProvider> {
|
|
368
|
+
const def = SANDBOX_PROVIDERS[provider];
|
|
369
|
+
if (!def) throw new Error(`Unknown sandbox provider: "${provider}". Known: ${Object.keys(SANDBOX_PROVIDERS).join(", ")}`);
|
|
370
|
+
const missing = Object.entries(def.requiredEnv)
|
|
371
|
+
.filter(([key]) => !process.env[key])
|
|
372
|
+
.map(([key, desc]) => ` ${key} — ${desc}`);
|
|
373
|
+
if (missing.length > 0) throw new Error(`Sandbox provider "${provider}" requires env vars:\n${missing.join("\n")}`);
|
|
374
|
+
return def.create(opts, Object.fromEntries(Object.keys(def.requiredEnv).map(k => [k, process.env[k]!])));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Reconnect to an existing sandbox by provider + provider-native sandbox ID. */
|
|
378
|
+
export async function reconnectSandbox(provider: string, sandboxId: string): Promise<SandboxProvider> {
|
|
379
|
+
const def = SANDBOX_PROVIDERS[provider];
|
|
380
|
+
if (!def?.reconnect) throw new Error(`Provider "${provider}" does not support reconnect`);
|
|
381
|
+
return def.reconnect(sandboxId);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Delete a snapshot by id on the named provider. No live sandbox needed. */
|
|
385
|
+
export async function deleteSandboxSnapshot(provider: string, snapshotId: string): Promise<void> {
|
|
386
|
+
const def = SANDBOX_PROVIDERS[provider];
|
|
387
|
+
if (!def?.deleteSnapshot) throw new Error(`Provider "${provider}" does not support deleteSnapshot`);
|
|
388
|
+
const missing = Object.entries(def.requiredEnv)
|
|
389
|
+
.filter(([k]) => !process.env[k])
|
|
390
|
+
.map(([k, desc]) => ` ${k} — ${desc}`);
|
|
391
|
+
if (missing.length > 0) throw new Error(`Sandbox provider "${provider}" requires env vars:\n${missing.join("\n")}`);
|
|
392
|
+
return def.deleteSnapshot(snapshotId, Object.fromEntries(Object.keys(def.requiredEnv).map(k => [k, process.env[k]!])));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Current active-sandbox count per configured provider, for quota gauges.
|
|
397
|
+
* Skips providers whose required env isn't set or which don't implement
|
|
398
|
+
* `getActiveCount`. Errors are surfaced per-provider so one flaky provider
|
|
399
|
+
* doesn't silence the rest.
|
|
400
|
+
*/
|
|
401
|
+
export async function getSandboxQuotas(): Promise<Record<string, number | Error>> {
|
|
402
|
+
const out: Record<string, number | Error> = {};
|
|
403
|
+
await Promise.all(
|
|
404
|
+
Object.entries(SANDBOX_PROVIDERS).map(async ([name, def]) => {
|
|
405
|
+
if (!def.getActiveCount) return;
|
|
406
|
+
if (Object.keys(def.requiredEnv).some(k => !process.env[k])) return;
|
|
407
|
+
const env = Object.fromEntries(Object.keys(def.requiredEnv).map(k => [k, process.env[k]!]));
|
|
408
|
+
try { out[name] = await def.getActiveCount(env); }
|
|
409
|
+
catch (err) { out[name] = err instanceof Error ? err : new Error(String(err)); }
|
|
410
|
+
}),
|
|
411
|
+
);
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* List every alive sandbox owned by this fleet, across all configured
|
|
417
|
+
* providers — the orphan-reconciler SoT. Providers without `listOwned`
|
|
418
|
+
* or missing env are skipped. Errors propagate per-provider so one flaky
|
|
419
|
+
* provider doesn't silence the rest.
|
|
420
|
+
*/
|
|
421
|
+
export async function listOwnedSandboxes(): Promise<Record<string, OwnedSandbox[] | Error>> {
|
|
422
|
+
const out: Record<string, OwnedSandbox[] | Error> = {};
|
|
423
|
+
await Promise.all(
|
|
424
|
+
Object.entries(SANDBOX_PROVIDERS).map(async ([name, def]) => {
|
|
425
|
+
if (!def.listOwned) return;
|
|
426
|
+
if (Object.keys(def.requiredEnv).some(k => !process.env[k])) return;
|
|
427
|
+
const env = Object.fromEntries(Object.keys(def.requiredEnv).map(k => [k, process.env[k]!]));
|
|
428
|
+
try { out[name] = await def.listOwned(env); }
|
|
429
|
+
catch (err) { out[name] = err instanceof Error ? err : new Error(String(err)); }
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Kill a sandbox by provider + native ID. Used by the orphan reconciler. */
|
|
436
|
+
export async function killSandboxById(provider: string, sandboxId: string): Promise<void> {
|
|
437
|
+
const def = SANDBOX_PROVIDERS[provider];
|
|
438
|
+
if (!def?.reconnect) throw new Error(`Provider "${provider}" does not support reconnect (required for kill-by-id)`);
|
|
439
|
+
const sb = await def.reconnect(sandboxId);
|
|
440
|
+
await sb.kill();
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Kill all sandboxes across all registered providers. Call at startup to clean up after crashes. */
|
|
444
|
+
export async function killAllSandboxes(
|
|
445
|
+
onError?: (provider: string, err: unknown) => void,
|
|
446
|
+
): Promise<void> {
|
|
447
|
+
await Promise.allSettled(
|
|
448
|
+
Object.entries(SANDBOX_PROVIDERS)
|
|
449
|
+
.filter(([, def]) => def.killAll)
|
|
450
|
+
.map(([name, def]) => {
|
|
451
|
+
const env = Object.fromEntries(Object.keys(def.requiredEnv).map(k => [k, process.env[k] ?? ""]));
|
|
452
|
+
if (Object.keys(def.requiredEnv).some(k => !process.env[k])) return Promise.resolve();
|
|
453
|
+
return def.killAll!(env).catch(err => onError?.(name, err));
|
|
454
|
+
}),
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export { SANDBOX_PROVIDERS };
|
package/src/sse.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic Server-Sent Events (SSE) parser.
|
|
3
|
+
*
|
|
4
|
+
* Consumes a `ReadableStream<Uint8Array>` and yields parsed events one at a
|
|
5
|
+
* time. Each event carries:
|
|
6
|
+
* - `id`: numeric (from `id:` line; `0` if absent)
|
|
7
|
+
* - `event`: event name (from `event:` line; `""` if absent)
|
|
8
|
+
* - `data`: parsed JSON payload (the SDK's stream events are always JSON)
|
|
9
|
+
*
|
|
10
|
+
* Malformed payloads are silently skipped — same behaviour as the previous
|
|
11
|
+
* CLI-side parser. Caller drives the loop via `for await (...)` and is
|
|
12
|
+
* responsible for breaking on terminal events.
|
|
13
|
+
*
|
|
14
|
+
* Output type stays loose (`Record<string, unknown>`) on purpose: typed
|
|
15
|
+
* unions like `RunEvent` aren't structurally narrowable from
|
|
16
|
+
* `Record<string, unknown>`, so callers that want a discriminated union
|
|
17
|
+
* cast at the consumption site (see `streamRunLogs`). Keeping the parser
|
|
18
|
+
* payload-agnostic also means it's reusable for future SSE endpoints with
|
|
19
|
+
* different shapes.
|
|
20
|
+
*/
|
|
21
|
+
export async function* parseSseStream(
|
|
22
|
+
body: ReadableStream<Uint8Array>,
|
|
23
|
+
): AsyncGenerator<{ id: number; event: string; data: Record<string, unknown> }> {
|
|
24
|
+
const reader = body.getReader();
|
|
25
|
+
const decoder = new TextDecoder();
|
|
26
|
+
let buffer = "";
|
|
27
|
+
let event = "";
|
|
28
|
+
let seq = 0;
|
|
29
|
+
let dataLines: string[] = [];
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
while (true) {
|
|
33
|
+
const { done, value } = await reader.read();
|
|
34
|
+
if (done) break;
|
|
35
|
+
|
|
36
|
+
buffer += decoder.decode(value, { stream: true });
|
|
37
|
+
const lines = buffer.split("\n");
|
|
38
|
+
buffer = lines.pop() ?? "";
|
|
39
|
+
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
if (line.startsWith("id:")) { seq = Number(line.slice(3).trim()); continue; }
|
|
42
|
+
if (line.startsWith("event:")) { event = line.slice(6).trim(); continue; }
|
|
43
|
+
if (line.startsWith("data:")) { dataLines.push(line.slice(5).trim()); continue; }
|
|
44
|
+
if (line === "" && dataLines.length > 0) {
|
|
45
|
+
try {
|
|
46
|
+
const data = JSON.parse(dataLines.join("\n")) as Record<string, unknown>;
|
|
47
|
+
yield { id: seq, event, data };
|
|
48
|
+
} catch { /* malformed — skip */ }
|
|
49
|
+
event = ""; seq = 0; dataLines = [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} finally {
|
|
54
|
+
reader.releaseLock();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RunEvent — the canonical streaming contract for agent-compose SSE streams.
|
|
3
|
+
*
|
|
4
|
+
* Emitted by the server via pg_notify and forwarded to clients over
|
|
5
|
+
* GET /api/v1/workflows/:id/stream. Every event carries `runId`, `at` (unix ms),
|
|
6
|
+
* and `seq` (the DB row id for Last-Event-ID resumption).
|
|
7
|
+
*
|
|
8
|
+
* The `event` field doubles as the SSE `event:` name.
|
|
9
|
+
*/
|
|
10
|
+
export type RunEvent =
|
|
11
|
+
| {
|
|
12
|
+
event: "agent_event";
|
|
13
|
+
runId: string;
|
|
14
|
+
at: number;
|
|
15
|
+
seq?: number;
|
|
16
|
+
msgType: string;
|
|
17
|
+
toolName?: string;
|
|
18
|
+
toolDetail?: string; // truncated key info: Bash command, file path, URL
|
|
19
|
+
isError?: boolean;
|
|
20
|
+
}
|
|
21
|
+
| {
|
|
22
|
+
event: "agent_spawned";
|
|
23
|
+
runId: string;
|
|
24
|
+
at: number;
|
|
25
|
+
seq?: number;
|
|
26
|
+
agentIndex: number;
|
|
27
|
+
agentId: string;
|
|
28
|
+
label: string;
|
|
29
|
+
sandboxId: string;
|
|
30
|
+
agentName: string;
|
|
31
|
+
parentAgentId: string | null;
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
event: "agent_settled";
|
|
35
|
+
runId: string;
|
|
36
|
+
at: number;
|
|
37
|
+
seq?: number;
|
|
38
|
+
agentIndex: number;
|
|
39
|
+
agentId: string;
|
|
40
|
+
label: string;
|
|
41
|
+
outcome: "success" | "failed";
|
|
42
|
+
durationMs: number;
|
|
43
|
+
failureReason?: string;
|
|
44
|
+
}
|
|
45
|
+
| { event: "step_started"; runId: string; at: number; seq?: number; step: string }
|
|
46
|
+
| { event: "step_completed"; runId: string; at: number; seq?: number; step: string; durationMs: number }
|
|
47
|
+
| { event: "step_failed"; runId: string; at: number; seq?: number; step: string; durationMs: number; reason: string }
|
|
48
|
+
| { event: "run_complete"; runId: string; at: number; seq?: number }
|
|
49
|
+
| { event: "run_failed"; runId: string; at: number; seq?: number; reason: string }
|
|
50
|
+
| { event: "run_canceled"; runId: string; at: number; seq?: number }
|
|
51
|
+
| { event: "workflow_log"; runId: string; at: number; seq?: number; level: "info" | "warn" | "error"; msg: string };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent message protocol — the stream of events emitted by a runtime's ModelExecutionContract.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
interface AgentMessageBase {
|
|
6
|
+
timestamp: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AgentMessageInit extends AgentMessageBase {
|
|
10
|
+
type: "init";
|
|
11
|
+
sessionId: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AgentMessageText extends AgentMessageBase {
|
|
15
|
+
type: "text";
|
|
16
|
+
text: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AgentMessageThinking extends AgentMessageBase {
|
|
20
|
+
type: "thinking";
|
|
21
|
+
text: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AgentMessageToolUse extends AgentMessageBase {
|
|
25
|
+
type: "tool_use";
|
|
26
|
+
toolName: string;
|
|
27
|
+
toolInput: Record<string, unknown>;
|
|
28
|
+
toolUseId: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AgentMessageToolResult extends AgentMessageBase {
|
|
32
|
+
type: "tool_result";
|
|
33
|
+
toolUseId: string;
|
|
34
|
+
output: string;
|
|
35
|
+
isError: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AgentMessageDone extends AgentMessageBase {
|
|
39
|
+
type: "done";
|
|
40
|
+
sessionId: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AgentMessageError extends AgentMessageBase {
|
|
44
|
+
type: "error";
|
|
45
|
+
text: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AgentMessageUsage extends AgentMessageBase {
|
|
49
|
+
type: "usage";
|
|
50
|
+
inputTokens: number;
|
|
51
|
+
outputTokens: number;
|
|
52
|
+
cacheReadTokens: number;
|
|
53
|
+
cacheCreationTokens: number;
|
|
54
|
+
durationMs: number;
|
|
55
|
+
numTurns: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type AgentMessage =
|
|
59
|
+
| AgentMessageInit
|
|
60
|
+
| AgentMessageText
|
|
61
|
+
| AgentMessageThinking
|
|
62
|
+
| AgentMessageToolUse
|
|
63
|
+
| AgentMessageToolResult
|
|
64
|
+
| AgentMessageDone
|
|
65
|
+
| AgentMessageError
|
|
66
|
+
| AgentMessageUsage;
|
|
67
|
+
|
|
68
|
+
/** Status block the agent emits to signal iteration completion or blockers. */
|
|
69
|
+
export interface AgentStatus {
|
|
70
|
+
summary: string;
|
|
71
|
+
completed: string[];
|
|
72
|
+
blockers: string[];
|
|
73
|
+
exit_signal: boolean;
|
|
74
|
+
}
|