Skip to content

Commit 2d56954

Browse files
kapaleshreyasclaude
andcommitted
feat(sandboxes): plumb sandbox.ttlMs to E2B's external timer
E2B sandboxes have their own service-enforced idle timer (default 300s = 5 min). Without bumping it, any warm-sandbox ttlMs > 300s gets the substrate killed externally before our reaper sees it. The flag has been in E2BSubstrateOptions since runtime-e2b was built; the example server just wasn't forwarding it. Extend the substrate factory signature from () => Substrate to ((opts?: { timeoutMs?: number }) => Substrate). Non-e2b substrates (bwrap, local) ignore the arg. The e2b registration in main() forwards opts.timeoutMs into E2BSubstrateOptions when present. All three sandbox construction sites — POST /sandboxes, createRestoredSandbox, replaceAgentInPlace — now compute substrateTimeoutMs = ttlMs + 30s grace and pass it to the factory. /run + /tasks pass nothing (default E2B behavior preserved; out of scope for this fix). scripts/test-sandboxes.py gains --runtime ∈ {bwrap, local, e2b} so the suite can be retargeted without code edits. Live verification on api.clawagent.sh (E2B Firecracker, real substrate): e2b × claude-agent-sdk: 12/12 (cold 32.9s → warm 3.1s — 10× speedup) e2b × gitagent: 12/12 (cold 28.3s → warm 2.2s) Warm-pool value prop is dramatic on E2B: every avoided cold-boot saves ~30s of Firecracker spin-up + npm install + harness boot. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 2885e0c commit 2d56954

2 files changed

Lines changed: 38 additions & 14 deletions

File tree

examples/computeragent-server.ts

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,14 @@ export interface ComputerAgentServerOptions {
8989
* If both `substrates` and the legacy `substrate` are absent, requests use a
9090
* fresh LocalSubstrate per call (matches the v0 default).
9191
*/
92-
readonly substrates?: Readonly<Record<string, () => Substrate>>;
92+
/**
93+
* Substrate factory signature accepts an optional per-call `timeoutMs`.
94+
* Used by warm sandboxes to bump E2B's external idle timer (default 5m)
95+
* up to match our sandbox.ttlMs + a grace window — otherwise the E2B
96+
* service kills the substrate before our TTL fires.
97+
* Substrates that have no external timer (bwrap, local) ignore the arg.
98+
*/
99+
readonly substrates?: Readonly<Record<string, (opts?: { timeoutMs?: number }) => Substrate>>;
93100
/**
94101
* Default `runtime` when the request body omits it. Must be a key in
95102
* `substrates`. If unset, the first registered key wins.
@@ -99,7 +106,7 @@ export interface ComputerAgentServerOptions {
99106
* @deprecated Pass via `substrates: { default: () => ... }` and `defaultRuntime: "default"` instead.
100107
* Kept for backward compatibility with the singular-substrate v0 shape.
101108
*/
102-
readonly substrate?: () => Substrate;
109+
readonly substrate?: (opts?: { timeoutMs?: number }) => Substrate;
103110
/**
104111
* Hard cap on concurrent agent runs. Beyond this, /run returns 429.
105112
* Default: 4 (LocalSubstrate spawns a Node process per agent).
@@ -1092,11 +1099,14 @@ export class ComputerAgentServer {
10921099

10931100
const source = applyGitToken(normalizeSource(body.source), body.gitToken);
10941101
// Build the agent but DO NOT call chat() yet — that's the substrate boot
1095-
// signal. The first POST /sandboxes/:id/chat triggers it.
1102+
// signal. The first POST /sandboxes/:id/chat triggers it. Per-call
1103+
// timeoutMs = sandbox.ttlMs + 30s grace so E2B's external 5m default
1104+
// doesn't pre-empt our own TTL. Non-e2b substrates ignore the arg.
1105+
const substrateTimeoutMs = resolvedTtl.ttlMs + 30_000;
10961106
const agent = new ComputerAgent({
10971107
source,
10981108
harness: body.harness as never,
1099-
runtime: runtimeResult.factory(),
1109+
runtime: runtimeResult.factory({ timeoutMs: substrateTimeoutMs }),
11001110
envs,
11011111
sessionId,
11021112
...(body.options ? { options: body.options } : {}),
@@ -1560,10 +1570,15 @@ export class ComputerAgentServer {
15601570
? { kind: snap.sessionStoreRef.kind, options: snap.sessionStoreRef.options }
15611571
: (cfg.sessionStore as { kind: string; options?: unknown } | undefined);
15621572

1573+
// Resolve TTL first so we can pass the e2b timeout when building the substrate.
1574+
const now = new Date();
1575+
const sandboxCfg = this.opts.sandbox ?? {};
1576+
const ttl = resolveSandboxTtl(overrides.idleTtlMs ?? (cfg.idleTtlMs as number | undefined), overrides.ttlMs ?? (cfg.ttlMs as number | undefined), sandboxCfg);
1577+
15631578
const agent = new ComputerAgent({
15641579
source: cfg.source as never,
15651580
harness: cfg.harness as never,
1566-
runtime: runtimeResult.factory(),
1581+
runtime: runtimeResult.factory({ timeoutMs: ttl.ttlMs + 30_000 }),
15671582
envs,
15681583
sessionId,
15691584
...(cfg.options ? { options: cfg.options } : {}),
@@ -1575,10 +1590,6 @@ export class ComputerAgentServer {
15751590
attachments,
15761591
});
15771592

1578-
const now = new Date();
1579-
const sandboxCfg = this.opts.sandbox ?? {};
1580-
const ttl = resolveSandboxTtl(overrides.idleTtlMs ?? (cfg.idleTtlMs as number | undefined), overrides.ttlMs ?? (cfg.ttlMs as number | undefined), sandboxCfg);
1581-
15821593
const autoSave = overrides.autoSave ?? cfg.autoSave;
15831594
const sandbox: LiveSandbox = {
15841595
id: sandboxId,
@@ -1643,11 +1654,14 @@ export class ComputerAgentServer {
16431654
: (cfg.sessionStore as { kind: string; options?: unknown } | undefined);
16441655

16451656
// Build new agent BEFORE disposing old one so a build error doesn't
1646-
// leave us with a torn-down substrate + no replacement.
1657+
// leave us with a torn-down substrate + no replacement. The new substrate
1658+
// inherits the existing slot's remaining ttlMs window — that's what the
1659+
// caller is paying for in terms of warmth.
1660+
const inPlaceTimeoutMs = (overrides.ttlMs ?? sb.ttlMs) + 30_000;
16471661
const newAgent = new ComputerAgent({
16481662
source: cfg.source as never,
16491663
harness: cfg.harness as never,
1650-
runtime: runtimeResult.factory(),
1664+
runtime: runtimeResult.factory({ timeoutMs: inPlaceTimeoutMs }),
16511665
envs,
16521666
sessionId,
16531667
...(cfg.options ? { options: cfg.options } : {}),
@@ -1782,7 +1796,7 @@ export class ComputerAgentServer {
17821796
private resolveRuntime(
17831797
requested: string | undefined,
17841798
):
1785-
| { ok: true; factory: () => Substrate }
1799+
| { ok: true; factory: (opts?: { timeoutMs?: number }) => Substrate }
17861800
| { ok: false; error: { code: string; message: string; available: string[] } } {
17871801
const registry = this.opts.substrates;
17881802
const fallback = this.opts.substrate;
@@ -2013,7 +2027,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
20132027
const { BwrapSubstrate } = await import("@computeragent/runtime-bwrap");
20142028
const { E2BSubstrate } = await import("@computeragent/runtime-e2b");
20152029

2016-
const substrates: Record<string, () => Substrate> = {
2030+
const substrates: Record<string, (opts?: { timeoutMs?: number }) => Substrate> = {
20172031
local: () => new LocalSubstrate(),
20182032
};
20192033

@@ -2030,7 +2044,13 @@ if (import.meta.url === `file://${process.argv[1]}`) {
20302044
// e2b: Register when E2B_API_KEY is set. Each session spins up its own
20312045
// Firecracker microVM via E2B's infra.
20322046
if (process.env.E2B_API_KEY) {
2033-
substrates.e2b = () => new E2BSubstrate({ apiKey: process.env.E2B_API_KEY });
2047+
// Forward per-call timeoutMs (warm sandboxes pass ttlMs + grace so E2B's
2048+
// 5min default idle-killer doesn't bury a longer-lived sandbox). For /run
2049+
// + /tasks the arg is undefined and E2B uses its built-in default.
2050+
substrates.e2b = (opts) => new E2BSubstrate({
2051+
apiKey: process.env.E2B_API_KEY,
2052+
...(opts?.timeoutMs ? { timeoutMs: opts.timeoutMs } : {}),
2053+
});
20342054
}
20352055

20362056
// Auto-forward selected host env vars into every spawned substrate. Bwrap

scripts/test-sandboxes.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,11 +682,15 @@ def main():
682682
p.add_argument("--harness", default="claude-agent-sdk",
683683
choices=["claude-agent-sdk", "gitagent", "deepagents"],
684684
help="which harness to drive the LLM-using tests with")
685+
p.add_argument("--runtime", default="bwrap",
686+
choices=["bwrap", "local", "e2b"],
687+
help="which substrate to spawn the agent in")
685688
p.add_argument("--source", default=None,
686689
help="override the source repo (defaults to the harness-appropriate one)")
687690
args = p.parse_args()
688691

689692
DEFAULT_BODY["harness"] = args.harness
693+
DEFAULT_BODY["runtime"] = args.runtime
690694
if args.source:
691695
DEFAULT_BODY["source"] = args.source
692696

0 commit comments

Comments
 (0)