@agent-compose/sdk 0.5.9 → 0.6.0
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/agent/agent-loop.d.ts +0 -8
- package/dist/agent/pause-client.d.ts +50 -0
- package/dist/index.d.ts +6 -6
- package/dist/index.js +269 -101
- package/dist/processors/gate-pause.d.ts +46 -0
- package/dist/processors/gate-pause.test.d.ts +1 -0
- package/dist/processors/index.d.ts +2 -0
- package/dist/runtimes/openai-desktop.js +265 -99
- package/dist/step-invocation/__tests__/background-invoker.test.d.ts +1 -0
- package/dist/step-invocation/index.d.ts +2 -1
- package/dist/step-invocation/invoker.d.ts +36 -0
- package/dist/types/runtime.d.ts +7 -0
- package/dist/types/sandbox.d.ts +45 -0
- package/package.json +1 -1
- package/src/agent/agent-loop.ts +4 -31
- package/src/agent/pause-client.ts +108 -0
- package/src/agent/run-agent.ts +7 -6
- package/src/index.ts +14 -4
- package/src/processors/gate-pause.ts +94 -0
- package/src/processors/index.ts +6 -0
- package/src/runtimes/_cli-agent.ts +1 -3
- package/src/runtimes/claude.ts +10 -6
- package/src/sandbox.ts +66 -3
- package/src/step-invocation/index.ts +2 -1
- package/src/step-invocation/invoker.ts +195 -84
- package/src/types/runtime.ts +7 -0
- package/src/types/sandbox.ts +44 -0
- package/dist/agent/local-pause-request.d.ts +0 -49
- package/src/agent/local-pause-request.ts +0 -90
- /package/dist/agent/{local-pause-request.test.d.ts → pause-client.test.d.ts} +0 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-sandbox pause client (ADR-0028). The single way an agent pauses its OWN
|
|
3
|
+
* run: `agentc pause` and the gate-pause processor both call
|
|
4
|
+
* `requestPauseAndAwait`, which drives the server pause API and blocks until a
|
|
5
|
+
* human resolves it.
|
|
6
|
+
*
|
|
7
|
+
* Create-then-poll, NOT a held connection:
|
|
8
|
+
* 1. POST /pauses creates the pending row. The server wakes the step
|
|
9
|
+
* activity, which freezes the live VM in place (E2B native suspend). The
|
|
10
|
+
* poll loop below is part of that frozen process — between polls it costs
|
|
11
|
+
* ZERO compute while the run is parked.
|
|
12
|
+
* 2. GET /pauses/:id long-polls (each request a bounded ~5s server-side
|
|
13
|
+
* LISTEN race) until the row is terminal, then returns the human's answer.
|
|
14
|
+
*
|
|
15
|
+
* There is no marker file and no exception threaded through workflow code — the
|
|
16
|
+
* pause is a server operation, so nothing a `try/catch` can swallow.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface PauseDecision {
|
|
20
|
+
status: "resolved" | "expired" | "cancelled";
|
|
21
|
+
/** The human's answer (present on `resolved`). Caller-defined shape; the
|
|
22
|
+
* dashboard sends `{ decision: string }`. Null on expiry/cancel. */
|
|
23
|
+
decision: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RequestPauseOptions {
|
|
27
|
+
/** `AGENT_COMPOSE_URL` — the server base URL injected into the sandbox. */
|
|
28
|
+
baseUrl: string;
|
|
29
|
+
/** `AGENT_COMPOSE_RUN_TOKEN` — the run credential the agent already carries. */
|
|
30
|
+
token: string;
|
|
31
|
+
/** `RUN_ID`. */
|
|
32
|
+
runId: string;
|
|
33
|
+
/** Human-readable reason shown on the pause feed / approval UI. */
|
|
34
|
+
reason: string;
|
|
35
|
+
/** Optional pause TTL; the workflow auto-expires the pause after this. */
|
|
36
|
+
ttlMs?: number;
|
|
37
|
+
/** Optional decision options the human picks from (e.g. approve / deny). */
|
|
38
|
+
options?: Array<{ id: string; label: string }>;
|
|
39
|
+
/** The action under review (e.g. the tool call), surfaced to the human. */
|
|
40
|
+
action?: Record<string, unknown>;
|
|
41
|
+
/** Optional second-key resume route. */
|
|
42
|
+
correlationKey?: string;
|
|
43
|
+
/** Abort the wait (the agent loop's signal). */
|
|
44
|
+
signal?: AbortSignal;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Inject a custom fetch (tests). Defaults to global fetch. */
|
|
48
|
+
type FetchFn = typeof fetch;
|
|
49
|
+
|
|
50
|
+
const isAbort = (signal: AbortSignal | undefined) => Boolean(signal?.aborted);
|
|
51
|
+
|
|
52
|
+
export async function requestPauseAndAwait(
|
|
53
|
+
opts: RequestPauseOptions,
|
|
54
|
+
fetchImpl: FetchFn = fetch,
|
|
55
|
+
): Promise<PauseDecision> {
|
|
56
|
+
const base = opts.baseUrl.replace(/\/+$/, "");
|
|
57
|
+
const headers = {
|
|
58
|
+
authorization: `Bearer ${opts.token}`,
|
|
59
|
+
"content-type": "application/json",
|
|
60
|
+
accept: "application/json",
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
// 1. Create the pending pause. The server wakes the step activity → the live
|
|
64
|
+
// VM suspends in place. 202 → { pauseId }.
|
|
65
|
+
const createRes = await fetchImpl(`${base}/api/v1/runs/${opts.runId}/pauses`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers,
|
|
68
|
+
body: JSON.stringify({
|
|
69
|
+
reason: opts.reason,
|
|
70
|
+
...(opts.ttlMs !== undefined ? { ttlMs: opts.ttlMs } : {}),
|
|
71
|
+
...(opts.options ? { options: opts.options } : {}),
|
|
72
|
+
...(opts.action ? { action: opts.action } : {}),
|
|
73
|
+
...(opts.correlationKey ? { correlationKey: opts.correlationKey } : {}),
|
|
74
|
+
}),
|
|
75
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
76
|
+
});
|
|
77
|
+
if (!createRes.ok) {
|
|
78
|
+
const text = await createRes.text().catch(() => "");
|
|
79
|
+
throw new Error(`pause create failed (${createRes.status}): ${text.slice(0, 300)}`);
|
|
80
|
+
}
|
|
81
|
+
const { pauseId } = await createRes.json() as { pauseId: string };
|
|
82
|
+
|
|
83
|
+
// 2. Poll until terminal. Each GET is a bounded server long-poll; the loop
|
|
84
|
+
// reissues. A transient error backs off and retries — the durable row is
|
|
85
|
+
// the source of truth, so a dropped poll never loses the decision.
|
|
86
|
+
const pollUrl = `${base}/api/v1/runs/${opts.runId}/pauses/${pauseId}`;
|
|
87
|
+
let backoffMs = 0;
|
|
88
|
+
for (;;) {
|
|
89
|
+
if (isAbort(opts.signal)) throw new Error("pause wait aborted");
|
|
90
|
+
if (backoffMs > 0) await new Promise((r) => setTimeout(r, backoffMs));
|
|
91
|
+
let res: Awaited<ReturnType<FetchFn>>;
|
|
92
|
+
try {
|
|
93
|
+
res = await fetchImpl(pollUrl, { method: "GET", headers, ...(opts.signal ? { signal: opts.signal } : {}) });
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (isAbort(opts.signal)) throw err;
|
|
96
|
+
backoffMs = Math.min(8_000, (backoffMs || 500) * 2);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (!res.ok) {
|
|
100
|
+
backoffMs = Math.min(8_000, (backoffMs || 500) * 2);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
backoffMs = 0;
|
|
104
|
+
const body = await res.json() as { status: string; resumePayload?: unknown };
|
|
105
|
+
if (body.status === "pending") continue;
|
|
106
|
+
return { status: body.status as PauseDecision["status"], decision: body.resumePayload ?? null };
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/agent/run-agent.ts
CHANGED
|
@@ -16,13 +16,12 @@ import { getActiveStep, nextAgentCallInActiveStep } from "../active-step.js";
|
|
|
16
16
|
import { corePause, type PauseRequest } from "../pause/pause-core.js";
|
|
17
17
|
import { agentLoop } from "./agent-loop.js";
|
|
18
18
|
import { consumeSteerPending, runControlPoller } from "./steer-control.js";
|
|
19
|
-
import { consumeLocalPauseRequest } from "./local-pause-request.js";
|
|
20
19
|
import type { AgentLifecycleEvent, AgentLoopResult } from "./agent-loop.js";
|
|
21
20
|
import { AsyncQueue } from "./async-queue.js";
|
|
22
21
|
import type { AgentMessage, AgentStatus } from "../types/protocol.js";
|
|
23
22
|
import type { AgentRuntime, RuntimeOptions } from "../types/runtime.js";
|
|
24
23
|
import type { SandboxProvider } from "../types/sandbox.js";
|
|
25
|
-
import { writeAgentContext } from "./agent-context.js";
|
|
24
|
+
import { writeAgentContext, buildAgentContextDoc } from "./agent-context.js";
|
|
26
25
|
import type { AgentBudget } from "../types/workflow.js";
|
|
27
26
|
import type { Processor } from "../processors/processor.js";
|
|
28
27
|
import { RequestContext } from "../request-context/request-context.js";
|
|
@@ -334,7 +333,12 @@ export async function agent<T = unknown>(opts: AgentOpts<T>): Promise<AgentLoopR
|
|
|
334
333
|
|
|
335
334
|
try {
|
|
336
335
|
return await agentLoop({
|
|
337
|
-
|
|
336
|
+
// Inject the platform manual into every runtime create() so a runtime that
|
|
337
|
+
// supports a system-prompt append (claude) carries it IN CONTEXT — not
|
|
338
|
+
// dependent on the agent choosing to `cat` AGENTS.md (which the SDK doesn't
|
|
339
|
+
// auto-load) or on writeAgentContext succeeding (it EACCES's on a read-only
|
|
340
|
+
// /workspace). buildAgentContextDoc is the same content writeAgentContext writes.
|
|
341
|
+
runtime: (runtimeOpts: RuntimeOptions) => opts.runtime.create(opts.sandbox, { ...runtimeOpts, agentManual: buildAgentContextDoc(process.env) }),
|
|
338
342
|
agentId,
|
|
339
343
|
...(opts.label !== undefined ? { label: opts.label } : {}),
|
|
340
344
|
...(opts.budget?.turnsPerIteration !== undefined ? { turnsPerIteration: opts.budget.turnsPerIteration } : {}),
|
|
@@ -366,9 +370,6 @@ export async function agent<T = unknown>(opts: AgentOpts<T>): Promise<AgentLoopR
|
|
|
366
370
|
// PR 7 steer-pause wiring.
|
|
367
371
|
mode,
|
|
368
372
|
consumeSteerPending: () => consumeSteerPending(agentId),
|
|
369
|
-
// `agentc pause` self-pause: the loop reads the marker this agent's CLI
|
|
370
|
-
// dropped in the state dir and stages a snapshot-release pause from it.
|
|
371
|
-
consumeLocalPauseRequest: () => consumeLocalPauseRequest(agentId),
|
|
372
373
|
...(steerPause ? { pause: steerPause } : {}),
|
|
373
374
|
});
|
|
374
375
|
} finally {
|
package/src/index.ts
CHANGED
|
@@ -76,12 +76,16 @@ export {
|
|
|
76
76
|
humanApproval,
|
|
77
77
|
requireScope,
|
|
78
78
|
redactPattern,
|
|
79
|
+
createGatePauseProcessor,
|
|
79
80
|
} from "./processors/index.js";
|
|
80
81
|
export type {
|
|
81
82
|
Processor,
|
|
82
83
|
ProcessorContext,
|
|
83
84
|
ProcessorVerdict,
|
|
84
85
|
ToolCall,
|
|
86
|
+
GatePausePolicy,
|
|
87
|
+
GatePauseApproval,
|
|
88
|
+
GatePauseConnection,
|
|
85
89
|
} from "./processors/index.js";
|
|
86
90
|
|
|
87
91
|
// Protocol types (agent-loop input/output shapes)
|
|
@@ -232,6 +236,8 @@ export type {
|
|
|
232
236
|
// `invokeStep` (server) and `serveStep` (runner).
|
|
233
237
|
export {
|
|
234
238
|
invokeStep,
|
|
239
|
+
launchStep,
|
|
240
|
+
reconnectStep,
|
|
235
241
|
serveStep,
|
|
236
242
|
parseStepResult,
|
|
237
243
|
buildStepEnvs,
|
|
@@ -256,10 +262,12 @@ export {
|
|
|
256
262
|
export type { PauseErrorCode } from "./pause/errors.js";
|
|
257
263
|
export type { PauseRequest } from "./pause/pause-core.js";
|
|
258
264
|
export type { WaitForEventRequest } from "./pause/wrappers.js";
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
265
|
+
// ADR-0028 — the in-sandbox pause client. `agentc pause` and the gate-pause
|
|
266
|
+
// processor both pause their OWN run through the server pause API and block
|
|
267
|
+
// until a human answers (create-then-poll; the VM suspends in place while
|
|
268
|
+
// parked). No marker, no exception threaded through workflow code.
|
|
269
|
+
export { requestPauseAndAwait } from "./agent/pause-client.js";
|
|
270
|
+
export type { PauseDecision, RequestPauseOptions } from "./agent/pause-client.js";
|
|
263
271
|
export type {
|
|
264
272
|
StepRequest,
|
|
265
273
|
StepResult,
|
|
@@ -268,6 +276,8 @@ export type {
|
|
|
268
276
|
StepHandler,
|
|
269
277
|
StepHandlerResult,
|
|
270
278
|
ServeStepRequest,
|
|
279
|
+
RunningStep,
|
|
280
|
+
InvokeStepOptions,
|
|
271
281
|
} from "./step-invocation/index.js";
|
|
272
282
|
|
|
273
283
|
// Agent loop — for workflows that embed an LLM agent in their run() body.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate-pause processor (ADR-0028).
|
|
3
|
+
*
|
|
4
|
+
* A pre-tool gate that, when its `policy` flags a tool call as needing human
|
|
5
|
+
* approval, pauses the run as a SERVER operation (`requestPauseAndAwait`) and
|
|
6
|
+
* blocks until a human answers — then allows or denies the tool.
|
|
7
|
+
*
|
|
8
|
+
* It pauses through the server pause API (`requestPauseAndAwait`), so the pause
|
|
9
|
+
* is a server operation no `try/catch` can swallow. It does NOT use today's
|
|
10
|
+
* `ctx.pause`, which still throws `PauseSignal` — exactly the swallowable path
|
|
11
|
+
* ADR-0028 supersedes for agents. (Routing `ctx.pause` itself through this same
|
|
12
|
+
* server pause is the natural unification — ADR-0028 open question — at which
|
|
13
|
+
* point "the pause API" and "ctx.pause" become one thing.) Because it lives in
|
|
14
|
+
* the shared `gateToolCall` chain, one implementation covers every runtime — the
|
|
15
|
+
* Claude Agent SDK `PreToolUse` hook and the ACP `session/request_permission`
|
|
16
|
+
* path both route through it.
|
|
17
|
+
*
|
|
18
|
+
* Opt-in: the workflow/agent supplies the `policy` (which may be a heuristic or
|
|
19
|
+
* an async classifier agent). The connection defaults to the run credential in
|
|
20
|
+
* the sandbox env; absent it (local / non-sandbox), the gate is a no-op and
|
|
21
|
+
* tool calls pass through untouched.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { Processor, ProcessorContext, ToolCall } from "./processor.js";
|
|
25
|
+
import { Verdict } from "./processor.js";
|
|
26
|
+
import { requestPauseAndAwait } from "../agent/pause-client.js";
|
|
27
|
+
|
|
28
|
+
export interface GatePauseApproval {
|
|
29
|
+
/** Human-readable question shown on the approval UI. */
|
|
30
|
+
reason: string;
|
|
31
|
+
/** Decision options the human picks from. Defaults to Approve / Deny. */
|
|
32
|
+
options?: Array<{ id: string; label: string }>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Decide whether a tool call needs human approval. Return `false` to let it
|
|
36
|
+
* through untouched, or an approval request to pause the run until a human
|
|
37
|
+
* answers. May be async (e.g. a small classifier agent). */
|
|
38
|
+
export type GatePausePolicy = (
|
|
39
|
+
call: ToolCall,
|
|
40
|
+
ctx: ProcessorContext,
|
|
41
|
+
) => (false | GatePauseApproval) | Promise<false | GatePauseApproval>;
|
|
42
|
+
|
|
43
|
+
export interface GatePauseConnection { baseUrl: string; token: string; runId: string }
|
|
44
|
+
|
|
45
|
+
const DENY_ANSWERS = new Set(["deny", "no", "reject", "decline", "block"]);
|
|
46
|
+
|
|
47
|
+
/** Unwrap the dashboard's `{ decision }` resume payload to the raw answer text. */
|
|
48
|
+
function answerText(decision: unknown): string {
|
|
49
|
+
const raw = decision !== null && typeof decision === "object" && "decision" in decision
|
|
50
|
+
? (decision as { decision: unknown }).decision
|
|
51
|
+
: decision;
|
|
52
|
+
return typeof raw === "string" ? raw.trim() : raw == null ? "" : JSON.stringify(raw);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createGatePauseProcessor(opts: {
|
|
56
|
+
policy: GatePausePolicy;
|
|
57
|
+
/** Override the server connection (defaults to the sandbox run-credential env). */
|
|
58
|
+
connection?: GatePauseConnection;
|
|
59
|
+
}): Processor {
|
|
60
|
+
return {
|
|
61
|
+
name: "gate-pause",
|
|
62
|
+
async processToolCall(call: ToolCall, ctx: ProcessorContext) {
|
|
63
|
+
const approval = await opts.policy(call, ctx);
|
|
64
|
+
if (!approval) return Verdict.continue(call);
|
|
65
|
+
|
|
66
|
+
const conn = opts.connection ?? {
|
|
67
|
+
baseUrl: process.env.AGENT_COMPOSE_URL ?? "",
|
|
68
|
+
token: process.env.AGENT_COMPOSE_RUN_TOKEN ?? "",
|
|
69
|
+
runId: process.env.RUN_ID ?? "",
|
|
70
|
+
};
|
|
71
|
+
// No run credential (local / non-sandbox) — can't pause; let it through
|
|
72
|
+
// rather than hard-failing a dev invocation.
|
|
73
|
+
if (!conn.baseUrl || !conn.token || !conn.runId) return Verdict.continue(call);
|
|
74
|
+
|
|
75
|
+
const decision = await requestPauseAndAwait({
|
|
76
|
+
baseUrl: conn.baseUrl, token: conn.token, runId: conn.runId,
|
|
77
|
+
reason: approval.reason,
|
|
78
|
+
action: { tool: call.toolName, input: call.toolInput },
|
|
79
|
+
options: approval.options ?? [{ id: "approve", label: "Approve" }, { id: "deny", label: "Deny" }],
|
|
80
|
+
signal: ctx.abortSignal,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const answer = answerText(decision.decision);
|
|
84
|
+
// Approve unless the human explicitly denied (or no answer came back on
|
|
85
|
+
// expiry/cancel). A free-form answer that isn't a deny word lets the tool
|
|
86
|
+
// run, with the human's guidance available to the model on the next turn.
|
|
87
|
+
if (decision.status === "resolved" && !DENY_ANSWERS.has(answer.toLowerCase())) {
|
|
88
|
+
return Verdict.continue(call);
|
|
89
|
+
}
|
|
90
|
+
const why = decision.status === "resolved" ? `denied: ${answer}` : `${decision.status} with no approval`;
|
|
91
|
+
return Verdict.deny(`Human ${why}. Tool "${call.toolName}" was not run — adjust course or ask again.`);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
package/src/processors/index.ts
CHANGED
|
@@ -14,3 +14,9 @@ export {
|
|
|
14
14
|
requireScope,
|
|
15
15
|
redactPattern,
|
|
16
16
|
} from "./builtins.js";
|
|
17
|
+
|
|
18
|
+
// ADR-0028 — server-driven human-approval gate. Pauses the run through the
|
|
19
|
+
// server pause API and blocks until a human answers; covers every runtime
|
|
20
|
+
// through the shared gateToolCall chain.
|
|
21
|
+
export { createGatePauseProcessor } from "./gate-pause.js";
|
|
22
|
+
export type { GatePausePolicy, GatePauseApproval, GatePauseConnection } from "./gate-pause.js";
|
|
@@ -502,9 +502,7 @@ export class CliAgentRunner implements ModelExecutionContract {
|
|
|
502
502
|
}
|
|
503
503
|
|
|
504
504
|
const cmd = `${acp.command} ${acp.args.map(shellQuote).join(" ")}`.trim();
|
|
505
|
-
|
|
506
|
-
// spawns) so `agentc pause` scopes its state-dir marker to this agent.
|
|
507
|
-
const agentEnv = { ...acp.env, ...(this.options.agentId ? { AGENT_COMPOSE_AGENT_ID: this.options.agentId } : {}) };
|
|
505
|
+
const agentEnv = { ...acp.env };
|
|
508
506
|
const proc = spawnDuplex.call(this.sandbox.commands, cmd, {
|
|
509
507
|
...(this.options.cwd ? { cwd: this.options.cwd } : {}),
|
|
510
508
|
...(Object.keys(agentEnv).length > 0 ? { envs: agentEnv } : {}),
|
package/src/runtimes/claude.ts
CHANGED
|
@@ -265,6 +265,14 @@ export class ClaudeRunner implements ModelExecutionContract {
|
|
|
265
265
|
else opts.signal.addEventListener("abort", onLoopAbort, { once: true });
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
// System-prompt append: the platform manual (agentManual, threaded from
|
|
269
|
+
// agent()) + any caller-supplied claudeMdContent. The Agent SDK does NOT
|
|
270
|
+
// auto-load CLAUDE.md from cwd (no settingSources), so without this the
|
|
271
|
+
// agent never reliably sees the manual — including how to pause.
|
|
272
|
+
const systemPromptAppend = [this.config.claudeMdContent, this.options.agentManual]
|
|
273
|
+
.filter((s): s is string => Boolean(s && s.trim()))
|
|
274
|
+
.join("\n\n");
|
|
275
|
+
|
|
268
276
|
try {
|
|
269
277
|
let emittedAssistantText = false;
|
|
270
278
|
for await (const message of query({
|
|
@@ -279,15 +287,11 @@ export class ClaudeRunner implements ModelExecutionContract {
|
|
|
279
287
|
effort: this.config.effort,
|
|
280
288
|
cwd: this.options.cwd,
|
|
281
289
|
abortController: queryAbort,
|
|
282
|
-
|
|
283
|
-
// can scope its state-dir pause-request marker to the right agent
|
|
284
|
-
// (concurrent agent() calls share one sandbox). The loop's agentId is
|
|
285
|
-
// authoritative, so it wins over a stale env / config value.
|
|
286
|
-
env: { ...process.env, ...(this.config.env ?? {}), ...(this.options.agentId ? { AGENT_COMPOSE_AGENT_ID: this.options.agentId } : {}) },
|
|
290
|
+
env: { ...process.env, ...(this.config.env ?? {}) },
|
|
287
291
|
pathToClaudeCodeExecutable: this.config.pathToClaudeCodeExecutable
|
|
288
292
|
?? process.env.CLAUDE_CODE_EXECUTABLE
|
|
289
293
|
?? DEFAULT_CLAUDE_PATH,
|
|
290
|
-
...(
|
|
294
|
+
...(systemPromptAppend ? { systemPrompt: { type: "preset" as const, preset: "claude_code" as const, append: systemPromptAppend } } : {}),
|
|
291
295
|
// Turn skills ON (and auto-add the `Skill` tool). The agent-env
|
|
292
296
|
// bakes the `/ac:*` skills; without this the Agent SDK leaves
|
|
293
297
|
// them un-enabled and the agent can't invoke them.
|
package/src/sandbox.ts
CHANGED
|
@@ -10,12 +10,12 @@ import { dirname } from "node:path";
|
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
11
|
import { Readable, Writable } from "node:stream";
|
|
12
12
|
import { Sandbox, SandboxNotFoundError, RateLimitError } from "e2b";
|
|
13
|
-
import type { SandboxNetworkOpts as E2bNetworkOpts, SandboxNetworkRule as E2bNetworkRule } from "e2b";
|
|
13
|
+
import type { SandboxNetworkOpts as E2bNetworkOpts, SandboxNetworkRule as E2bNetworkRule, CommandHandle } from "e2b";
|
|
14
14
|
import { Sandbox as Desktop } from "@e2b/desktop";
|
|
15
15
|
import pRetry from "p-retry";
|
|
16
16
|
import type { FailedAttemptError } from "p-retry";
|
|
17
17
|
import { SandboxUnavailableError } from "./sandbox-errors.js";
|
|
18
|
-
import type { SandboxProvider, DesktopSandboxProvider, SandboxCommandResult } from "./types/sandbox.js";
|
|
18
|
+
import type { SandboxProvider, DesktopSandboxProvider, SandboxCommandResult, SandboxCommandRunOptions, SandboxBackgroundProcess } from "./types/sandbox.js";
|
|
19
19
|
import type { ConnectorRequestRules } from "./types/workflow-metadata.js";
|
|
20
20
|
import type { NetworkPolicy as VercelNetworkPolicy, NetworkPolicyRule as VercelNetworkPolicyRule } from "@vercel/sandbox";
|
|
21
21
|
|
|
@@ -363,14 +363,69 @@ function withSnapshotRetry(p: SandboxProvider): SandboxProvider {
|
|
|
363
363
|
return { ...p, snapshot: () => withSandboxRetry(snapshot) };
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
+
/** Wrap an E2B `CommandHandle` as a provider-agnostic background process.
|
|
367
|
+
* `wait()` is normalised NOT to throw on a non-zero exit (mirroring the
|
|
368
|
+
* `commands.run` contract in `makeSandboxProvider`) so callers branch on
|
|
369
|
+
* `exitCode` instead of catching. */
|
|
370
|
+
function wrapE2bBackgroundProcess(handle: CommandHandle): SandboxBackgroundProcess {
|
|
371
|
+
return {
|
|
372
|
+
pid: handle.pid,
|
|
373
|
+
async wait() {
|
|
374
|
+
try {
|
|
375
|
+
const r = await handle.wait();
|
|
376
|
+
return { exitCode: r.exitCode ?? 0, stdout: r.stdout, stderr: r.stderr };
|
|
377
|
+
} catch (e) {
|
|
378
|
+
const ce = e as { exitCode?: unknown; stdout?: unknown; stderr?: unknown };
|
|
379
|
+
if (typeof ce.exitCode === "number") {
|
|
380
|
+
return {
|
|
381
|
+
exitCode: ce.exitCode,
|
|
382
|
+
stdout: typeof ce.stdout === "string" ? ce.stdout : "",
|
|
383
|
+
stderr: typeof ce.stderr === "string" ? ce.stderr : "",
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
throw e;
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
async kill() { await handle.kill(); },
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
366
393
|
/** E2B base provider + live-filesystem snapshot. E2B's `createSnapshot()` captures
|
|
367
394
|
* the running sandbox as a persistent snapshot whose id is usable as a
|
|
368
395
|
* `Sandbox.create()` source and outlives the origin sandbox — so E2B reaches
|
|
369
396
|
* snapshot / `bootFrom` parity with Vercel, and snapshot-backed `ctx.pause` works
|
|
370
397
|
* on E2B. (Desktop intentionally omits this — it is registry-only, not selectable.) */
|
|
371
398
|
function makeE2bSandboxProvider(sb: Sandbox): SandboxProvider {
|
|
399
|
+
const base = makeSandboxProvider(sb);
|
|
372
400
|
return {
|
|
373
|
-
...
|
|
401
|
+
...base,
|
|
402
|
+
commands: {
|
|
403
|
+
...base.commands,
|
|
404
|
+
// ADR-0028: background launch + reconnect-by-pid. The step runner runs as
|
|
405
|
+
// a background command so a server-driven pause can freeze it mid-turn
|
|
406
|
+
// (pauseProcess) and the resume activity can re-attach by pid and await
|
|
407
|
+
// its exit — continuing the SAME process, no re-run. E2B-only; Vercel's
|
|
408
|
+
// provider omits these and pauses via snapshot + re-run.
|
|
409
|
+
async runBackground(cmd, opts) {
|
|
410
|
+
const { sudo, onStdout, onStderr, ...rest } = (opts ?? {}) as SandboxCommandRunOptions;
|
|
411
|
+
const handle = await sb.commands.run(cmd, {
|
|
412
|
+
...rest,
|
|
413
|
+
background: true,
|
|
414
|
+
...(sudo ? { user: "root" } : {}),
|
|
415
|
+
...(onStdout ? { onStdout } : {}),
|
|
416
|
+
...(onStderr ? { onStderr } : {}),
|
|
417
|
+
});
|
|
418
|
+
return wrapE2bBackgroundProcess(handle);
|
|
419
|
+
},
|
|
420
|
+
async connectProcess(pid, opts) {
|
|
421
|
+
const handle = await sb.commands.connect(pid, {
|
|
422
|
+
...(opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
|
|
423
|
+
...(opts?.onStdout ? { onStdout: opts.onStdout } : {}),
|
|
424
|
+
...(opts?.onStderr ? { onStderr: opts.onStderr } : {}),
|
|
425
|
+
});
|
|
426
|
+
return wrapE2bBackgroundProcess(handle);
|
|
427
|
+
},
|
|
428
|
+
},
|
|
374
429
|
async snapshot() {
|
|
375
430
|
// Raw capture — the transient pause/reclaim race is retried centrally:
|
|
376
431
|
// createSandbox/reconnectSandbox wrap every provider's snapshot() in
|
|
@@ -379,6 +434,14 @@ function makeE2bSandboxProvider(sb: Sandbox): SandboxProvider {
|
|
|
379
434
|
const { snapshotId } = await sb.createSnapshot();
|
|
380
435
|
return { snapshotId };
|
|
381
436
|
},
|
|
437
|
+
// ADR-0027: native VM-suspend. Freezes the live process in place (zero
|
|
438
|
+
// compute) and returns the sandbox id as the resume handle — resume is
|
|
439
|
+
// `reconnectSandbox(...)` (Sandbox.connect), which auto-resumes a paused VM.
|
|
440
|
+
// Distinct from snapshot(): no FS image, no kill, no re-run from the top.
|
|
441
|
+
async pauseProcess() {
|
|
442
|
+
await sb.pause();
|
|
443
|
+
return { resumeHandle: sb.sandboxId };
|
|
444
|
+
},
|
|
382
445
|
// Push a freshly-resolved egress policy onto the live sandbox via E2B's
|
|
383
446
|
// native `updateNetwork` — the E2B analogue of Vercel's `update({
|
|
384
447
|
// networkPolicy })`. Lets the server re-resolve the run policy (re-minting
|
|
@@ -27,7 +27,8 @@ export {
|
|
|
27
27
|
stepInputPath,
|
|
28
28
|
requestContextPath,
|
|
29
29
|
} from "./protocol.js";
|
|
30
|
-
export { invokeStep, parseStepResult, buildStepEnvs } from "./invoker.js";
|
|
30
|
+
export { invokeStep, launchStep, reconnectStep, parseStepResult, buildStepEnvs } from "./invoker.js";
|
|
31
|
+
export type { RunningStep, InvokeStepOptions } from "./invoker.js";
|
|
31
32
|
export { serveStep } from "./server.js";
|
|
32
33
|
export type { StepHandler, ServeStepRequest, StepHandlerResult } from "./server.js";
|
|
33
34
|
export { StepExecutionError } from "./types.js";
|