@tanstack/ai-sandbox-cloudflare 0.3.12 → 0.4.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/esm/chat-coordinator.js +19 -16
- package/dist/esm/chat-coordinator.js.map +1 -1
- package/dist/esm/container-coordinator.js +20 -17
- package/dist/esm/container-coordinator.js.map +1 -1
- package/dist/esm/coordinator-callbacks.d.ts +4 -0
- package/dist/esm/coordinator-callbacks.js +45 -0
- package/dist/esm/coordinator-callbacks.js.map +1 -0
- package/dist/esm/coordinator.d.ts +7 -2
- package/dist/esm/coordinator.js +43 -28
- package/dist/esm/coordinator.js.map +1 -1
- package/dist/esm/factory.d.ts +5 -0
- package/dist/esm/factory.js +8 -1
- package/dist/esm/factory.js.map +1 -1
- package/dist/esm/preview-tool.js +77 -1
- package/dist/esm/preview-tool.js.map +1 -1
- package/dist/esm/run-log-do.d.ts +4 -0
- package/dist/esm/run-log-do.js +59 -9
- package/dist/esm/run-log-do.js.map +1 -1
- package/dist/esm/run-log.d.ts +9 -4
- package/dist/esm/run-log.js +24 -1
- package/dist/esm/run-log.js.map +1 -1
- package/package.json +5 -5
- package/src/chat-coordinator.ts +25 -16
- package/src/container-coordinator.ts +37 -24
- package/src/coordinator-callbacks.ts +58 -0
- package/src/coordinator.ts +66 -30
- package/src/factory.ts +15 -1
- package/src/preview-tool.ts +133 -1
- package/src/run-log-do.ts +71 -7
- package/src/run-log.ts +46 -5
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { runWithCallbackActivity } from "./coordinator-callbacks.js";
|
|
1
2
|
import { resolveBridgeOrigin } from "./public-host.js";
|
|
2
3
|
import { SandboxCoordinator } from "./coordinator.js";
|
|
3
4
|
import { timingSafeBearerEqualWeb } from "./web-crypto.js";
|
|
@@ -123,22 +124,24 @@ var ChatSandboxCoordinator = class extends SandboxCoordinator {
|
|
|
123
124
|
const bridge = this.bridges.get(runId);
|
|
124
125
|
if (!bridge) return new Response("no active bridge for run", { status: 404 });
|
|
125
126
|
if (!timingSafeBearerEqualWeb(request.headers.get("authorization") ?? void 0, bridge.token)) return new Response("unauthorized", { status: 401 });
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
127
|
+
return runWithCallbackActivity(this, runId, (id) => this.log.touch(id), async () => {
|
|
128
|
+
let message;
|
|
129
|
+
try {
|
|
130
|
+
message = await request.json();
|
|
131
|
+
} catch {
|
|
132
|
+
return this.jsonResponse({
|
|
133
|
+
jsonrpc: "2.0",
|
|
134
|
+
id: null,
|
|
135
|
+
error: {
|
|
136
|
+
code: -32700,
|
|
137
|
+
message: "Parse error"
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const reply = await handleBridgeJsonRpc(bridge.core, message);
|
|
142
|
+
if (reply === null) return new Response(null, { status: 202 });
|
|
143
|
+
return this.jsonResponse(reply);
|
|
144
|
+
});
|
|
142
145
|
}
|
|
143
146
|
};
|
|
144
147
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chat-coordinator.js","names":[],"sources":["../../src/chat-coordinator.ts"],"sourcesContent":["/**\n * `ChatSandboxCoordinator` — the concrete {@link SandboxCoordinator} for the\n * DO-DRIVES model: the Durable Object runs `chat()` ITSELF and hosts the MCP\n * tool-bridge from its own `fetch` handler.\n *\n * Worker (stateless trigger)\n * → ChatSandboxCoordinator (this DO: runs chat(), owns the sandbox + log)\n * → Cloudflare Sandbox (the container the agent executes in)\n *\n * It implements the one per-model seam, {@link buildRunStream}, by running\n * `chat()` in the DO with two middlewares: our DO-backed tool-bridge provisioner\n * (so the bridge is served from this DO instead of a `node:http` listener) and\n * `withSandbox(...)` (the handle the harness adapter needs). The per-run config —\n * which adapter, which sandbox, which chat()-tools — is the subclass's\n * {@link config} method; everything else (run-log, streaming tail, watchdog) is\n * inherited from the base.\n *\n * The MCP tool-bridge lives at `/_bridge/:runId`, gated by a per-run bearer\n * token, served from {@link handleRoute}. The in-sandbox agent reaches it via\n * the Worker's public hostname.\n *\n * NOTE: Workers-runtime code — compiles against the real Cloudflare + TanStack\n * AI types; not runtime-verified in this repo (no Workers runtime here).\n */\nimport { chat, defineChatMiddleware } from '@tanstack/ai'\nimport {\n ToolBridgeProvisionerCapability,\n createToolBridgeCore,\n handleBridgeJsonRpc,\n withSandbox,\n} from '@tanstack/ai-sandbox'\nimport { SandboxCoordinator, resolveBridgeOrigin } from './coordinator'\nimport { timingSafeBearerEqualWeb } from './web-crypto'\nimport type { StartRunInput } from './coordinator'\nimport type {\n AnyTextAdapter,\n AnyTool,\n StreamChunk,\n SystemPrompt,\n} from '@tanstack/ai'\nimport type {\n ProvisionedBridge,\n SandboxDefinition,\n ToolBridgeCore,\n ToolBridgeProvisioner,\n} from '@tanstack/ai-sandbox'\n\n/**\n * The Env bindings a {@link ChatSandboxCoordinator} requires. The bridge origin the\n * SANDBOX calls back on needs a hostname; `PUBLIC_HOSTNAME` is OPTIONAL — when\n * unset, the coordinator derives it from the trigger request (locally →\n * `host.docker.internal`; safe on Cloudflare). See {@link resolveBridgeOrigin}.\n */\nexport interface ChatCoordinatorEnv {\n /**\n * Hostname the CONTAINER uses to reach the Worker's tool-bridge (`/_bridge`).\n * Optional: unset → derived from each trigger request (deployed: the request\n * host; local dev: `host.docker.internal`). Set it only to override — e.g. a\n * stable named-tunnel host. See {@link resolveBridgeOrigin}. (Browser-facing\n * preview URLs use a separate `PREVIEW_HOSTNAME`; see {@link resolvePreviewHost}.)\n */\n PUBLIC_HOSTNAME?: string\n}\n\n/** What {@link ChatSandboxCoordinator.config} returns for one run. */\nexport interface ChatRunConfig {\n /** The harness/text adapter `chat()` runs (e.g. `claudeCodeText('sonnet')`). */\n adapter: AnyTextAdapter\n /** The sandbox the agent executes in, projected by `withSandbox`. */\n sandbox: SandboxDefinition\n /** chat()-provided server tools bridged into the harness over MCP. */\n tools?: Array<AnyTool>\n /** Base system prompts prepended to the run's `chat()` (e.g. `[PREVIEW_GUIDANCE]`). */\n systemPrompts?: Array<SystemPrompt>\n}\n\n/** Per-run bridge state so `/_bridge/:runId` can authenticate + serve. */\ninterface BridgeState {\n token: string\n core: ToolBridgeCore\n}\n\nexport abstract class ChatSandboxCoordinator<\n TEnv extends ChatCoordinatorEnv = ChatCoordinatorEnv,\n> extends SandboxCoordinator<TEnv> {\n /**\n * Live per-run bridges, keyed by runId. In-memory by design: a bridge is only\n * reachable while its run is in flight, and `ctx.waitUntil(done)` keeps THIS\n * instance alive (un-hibernated) for the run's whole lifetime — so the agent's\n * MCP calls always hit the instance that provisioned the bridge. A request for\n * a run with no live bridge (finished, or never started here) is a hard 404,\n * not a silent re-provision.\n */\n private readonly bridges = new Map<string, BridgeState>()\n\n // ===========================================================================\n // Subclass seam: the per-run configuration\n // ===========================================================================\n\n /**\n * Resolve the adapter, sandbox, and chat()-tools for one run. Implemented by\n * the app subclass (or supplied by {@link createCloudflareSandboxAgent}); this\n * is the only model-specific input the DO-drives coordinator needs.\n */\n protected abstract config(input: StartRunInput): ChatRunConfig\n\n // ===========================================================================\n // The one per-model seam: run chat() in the DO\n // ===========================================================================\n\n /**\n * Run `chat()` IN the DO, streaming its `StreamChunk`s. `stream: true` (with no\n * outputSchema) makes chat() return an `AsyncIterable<StreamChunk>` directly —\n * no cast needed for the run driver. Both middlewares run `setup` before\n * streaming begins: our middleware provides the DO-backed bridge provisioner,\n * and `withSandbox` provides the sandbox handle the harness adapter needs.\n */\n protected override buildRunStream(\n input: StartRunInput,\n ): AsyncIterable<StreamChunk> {\n const { adapter, sandbox, tools, systemPrompts } = this.config(input)\n const sessionId = input.metadata?.sessionId\n const modelOptions =\n typeof sessionId === 'string' && sessionId !== ''\n ? { sessionId }\n : undefined\n return chat({\n threadId: input.threadId,\n adapter,\n messages: input.messages,\n stream: true,\n ...(tools !== undefined ? { tools } : {}),\n ...(systemPrompts !== undefined ? { systemPrompts } : {}),\n ...(modelOptions !== undefined ? { modelOptions } : {}),\n middleware: [\n this.bridgeProvisionerMiddleware(input),\n withSandbox(sandbox),\n ],\n })\n }\n\n /** Drop the per-run bridge once the run is terminal (override from base). */\n protected override onRunSettled(runId: string): void {\n this.bridges.delete(runId)\n }\n\n // ===========================================================================\n // The DO-backed tool-bridge provisioner + endpoint\n // ===========================================================================\n\n /**\n * A tiny middleware that PROVIDES our DO-backed {@link ToolBridgeProvisioner}.\n * The harness adapter reads it via `getOptional` and falls back to the\n * `node:http` host transport when absent — here we override that so the bridge\n * is served from this DO's `fetch` handler instead of a TCP listener.\n */\n private bridgeProvisionerMiddleware(input: StartRunInput) {\n const provisioner = this.makeBridgeProvisioner(input)\n return defineChatMiddleware({\n name: 'do-tool-bridge-provisioner',\n provides: [ToolBridgeProvisionerCapability],\n setup: (ctx) => {\n ctx.provide(ToolBridgeProvisionerCapability, provisioner)\n },\n })\n }\n\n /**\n * Stand up the per-run bridge: register the tool core + a fresh bearer token\n * on this DO, and hand back a URL the SANDBOX can reach — the Worker's public\n * hostname routed to `/_bridge/:runId`. The `threadId` query lets the Worker\n * route the agent's MCP calls back to THIS coordinator. No raw socket is opened.\n */\n private makeBridgeProvisioner(input: StartRunInput): ToolBridgeProvisioner {\n const env = this.env\n const bridges = this.bridges\n const { runId, threadId } = input\n // Container→Worker origin: `PUBLIC_HOSTNAME` if set, else derived from the\n // trigger request (locally → host.docker.internal). The bearer token rides\n // this URL. See `resolveBridgeOrigin`.\n const origin = resolveBridgeOrigin(env, input)\n return {\n provision(tools, options): Promise<ProvisionedBridge> {\n const token =\n crypto.randomUUID() + crypto.randomUUID().replace(/-/g, '')\n const core = createToolBridgeCore(tools, {\n ...(options.context !== undefined\n ? { context: options.context }\n : {}),\n ...(options.signal !== undefined ? { signal: options.signal } : {}),\n ...(options.permission !== undefined\n ? { permission: options.permission }\n : {}),\n })\n bridges.set(runId, { token, core })\n return Promise.resolve({\n name: 'tanstack',\n url: `${origin}/_bridge/${runId}?threadId=${encodeURIComponent(threadId)}`,\n token,\n close: () => {\n bridges.delete(runId)\n return Promise.resolve()\n },\n })\n },\n }\n }\n\n /** Serve `/_bridge/:runId` (the in-sandbox agent's MCP calls) from the base fetch. */\n protected override handleRoute(\n request: Request,\n parts: Array<string>,\n ): Promise<Response> | Response {\n if (parts[0] === '_bridge' && typeof parts[1] === 'string') {\n return this.serveBridge(parts[1], request)\n }\n return super.handleRoute(request, parts)\n }\n\n /** Serve one MCP JSON-RPC request for a run after a constant-time token check. */\n private async serveBridge(\n runId: string,\n request: Request,\n ): Promise<Response> {\n const bridge = this.bridges.get(runId)\n if (!bridge)\n return new Response('no active bridge for run', { status: 404 })\n if (\n !timingSafeBearerEqualWeb(\n request.headers.get('authorization') ?? undefined,\n bridge.token,\n )\n ) {\n return new Response('unauthorized', { status: 401 })\n }\n let message: unknown\n try {\n message = await request.json()\n } catch {\n // A malformed body must still produce a valid JSON-RPC error so the agent's\n // MCP client can react, rather than an opaque DO 500 that can wedge the run.\n return this.jsonResponse({\n jsonrpc: '2.0',\n id: null,\n error: { code: -32700, message: 'Parse error' },\n })\n }\n const reply = await handleBridgeJsonRpc(bridge.core, message)\n // A notification (no id) yields null → MCP expects an empty 202 ack.\n if (reply === null) return new Response(null, { status: 202 })\n return this.jsonResponse(reply)\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"chat-coordinator.js","names":[],"sources":["../../src/chat-coordinator.ts"],"sourcesContent":["/**\n * `ChatSandboxCoordinator` — the concrete {@link SandboxCoordinator} for the\n * DO-DRIVES model: the Durable Object runs `chat()` ITSELF and hosts the MCP\n * tool-bridge from its own `fetch` handler.\n *\n * Worker (stateless trigger)\n * → ChatSandboxCoordinator (this DO: runs chat(), owns the sandbox + log)\n * → Cloudflare Sandbox (the container the agent executes in)\n *\n * It implements the one per-model seam, {@link buildRunStream}, by running\n * `chat()` in the DO with two middlewares: our DO-backed tool-bridge provisioner\n * (so the bridge is served from this DO instead of a `node:http` listener) and\n * `withSandbox(...)` (the handle the harness adapter needs). The per-run config —\n * which adapter, which sandbox, which chat()-tools — is the subclass's\n * {@link config} method; everything else (run-log, streaming tail, watchdog) is\n * inherited from the base.\n *\n * The MCP tool-bridge lives at `/_bridge/:runId`, gated by a per-run bearer\n * token, served from {@link handleRoute}. The in-sandbox agent reaches it via\n * the Worker's public hostname.\n *\n * NOTE: Workers-runtime code — compiles against the real Cloudflare + TanStack\n * AI types; not runtime-verified in this repo (no Workers runtime here).\n */\nimport { chat, defineChatMiddleware } from '@tanstack/ai'\nimport {\n ToolBridgeProvisionerCapability,\n createToolBridgeCore,\n handleBridgeJsonRpc,\n withSandbox,\n} from '@tanstack/ai-sandbox'\nimport { SandboxCoordinator, resolveBridgeOrigin } from './coordinator'\nimport { runWithCallbackActivity } from './coordinator-callbacks'\nimport { timingSafeBearerEqualWeb } from './web-crypto'\nimport type { StartRunInput } from './coordinator'\nimport type {\n AnyTextAdapter,\n AnyTool,\n StreamChunk,\n SystemPrompt,\n} from '@tanstack/ai'\nimport type {\n ProvisionedBridge,\n SandboxDefinition,\n ToolBridgeCore,\n ToolBridgeProvisioner,\n} from '@tanstack/ai-sandbox'\n\n/**\n * The Env bindings a {@link ChatSandboxCoordinator} requires. The bridge origin the\n * SANDBOX calls back on needs a hostname; `PUBLIC_HOSTNAME` is OPTIONAL — when\n * unset, the coordinator derives it from the trigger request (locally →\n * `host.docker.internal`; safe on Cloudflare). See {@link resolveBridgeOrigin}.\n */\nexport interface ChatCoordinatorEnv {\n /**\n * Hostname the CONTAINER uses to reach the Worker's tool-bridge (`/_bridge`).\n * Optional: unset → derived from each trigger request (deployed: the request\n * host; local dev: `host.docker.internal`). Set it only to override — e.g. a\n * stable named-tunnel host. See {@link resolveBridgeOrigin}. (Browser-facing\n * preview URLs use a separate `PREVIEW_HOSTNAME`; see {@link resolvePreviewHost}.)\n */\n PUBLIC_HOSTNAME?: string\n}\n\n/** What {@link ChatSandboxCoordinator.config} returns for one run. */\nexport interface ChatRunConfig {\n /** The harness/text adapter `chat()` runs (e.g. `claudeCodeText('sonnet')`). */\n adapter: AnyTextAdapter\n /** The sandbox the agent executes in, projected by `withSandbox`. */\n sandbox: SandboxDefinition\n /** chat()-provided server tools bridged into the harness over MCP. */\n tools?: Array<AnyTool>\n /** Base system prompts prepended to the run's `chat()` (e.g. `[PREVIEW_GUIDANCE]`). */\n systemPrompts?: Array<SystemPrompt>\n}\n\n/** Per-run bridge state so `/_bridge/:runId` can authenticate + serve. */\ninterface BridgeState {\n token: string\n core: ToolBridgeCore\n}\n\nexport abstract class ChatSandboxCoordinator<\n TEnv extends ChatCoordinatorEnv = ChatCoordinatorEnv,\n> extends SandboxCoordinator<TEnv> {\n /**\n * Live per-run bridges, keyed by runId. In-memory by design: a bridge is only\n * reachable while its run is in flight, and `ctx.waitUntil(done)` keeps THIS\n * instance alive (un-hibernated) for the run's whole lifetime — so the agent's\n * MCP calls always hit the instance that provisioned the bridge. A request for\n * a run with no live bridge (finished, or never started here) is a hard 404,\n * not a silent re-provision.\n */\n private readonly bridges = new Map<string, BridgeState>()\n\n // ===========================================================================\n // Subclass seam: the per-run configuration\n // ===========================================================================\n\n /**\n * Resolve the adapter, sandbox, and chat()-tools for one run. Implemented by\n * the app subclass (or supplied by {@link createCloudflareSandboxAgent}); this\n * is the only model-specific input the DO-drives coordinator needs.\n */\n protected abstract config(input: StartRunInput): ChatRunConfig\n\n // ===========================================================================\n // The one per-model seam: run chat() in the DO\n // ===========================================================================\n\n /**\n * Run `chat()` IN the DO, streaming its `StreamChunk`s. `stream: true` (with no\n * outputSchema) makes chat() return an `AsyncIterable<StreamChunk>` directly —\n * no cast needed for the run driver. Both middlewares run `setup` before\n * streaming begins: our middleware provides the DO-backed bridge provisioner,\n * and `withSandbox` provides the sandbox handle the harness adapter needs.\n */\n protected override buildRunStream(\n input: StartRunInput,\n ): AsyncIterable<StreamChunk> {\n const { adapter, sandbox, tools, systemPrompts } = this.config(input)\n const sessionId = input.metadata?.sessionId\n const modelOptions =\n typeof sessionId === 'string' && sessionId !== ''\n ? { sessionId }\n : undefined\n return chat({\n threadId: input.threadId,\n adapter,\n messages: input.messages,\n stream: true,\n ...(tools !== undefined ? { tools } : {}),\n ...(systemPrompts !== undefined ? { systemPrompts } : {}),\n ...(modelOptions !== undefined ? { modelOptions } : {}),\n middleware: [\n this.bridgeProvisionerMiddleware(input),\n withSandbox(sandbox),\n ],\n })\n }\n\n /** Drop the per-run bridge once the run is terminal (override from base). */\n protected override onRunSettled(runId: string): void {\n this.bridges.delete(runId)\n }\n\n // ===========================================================================\n // The DO-backed tool-bridge provisioner + endpoint\n // ===========================================================================\n\n /**\n * A tiny middleware that PROVIDES our DO-backed {@link ToolBridgeProvisioner}.\n * The harness adapter reads it via `getOptional` and falls back to the\n * `node:http` host transport when absent — here we override that so the bridge\n * is served from this DO's `fetch` handler instead of a TCP listener.\n */\n private bridgeProvisionerMiddleware(input: StartRunInput) {\n const provisioner = this.makeBridgeProvisioner(input)\n return defineChatMiddleware({\n name: 'do-tool-bridge-provisioner',\n provides: [ToolBridgeProvisionerCapability],\n setup: (ctx) => {\n ctx.provide(ToolBridgeProvisionerCapability, provisioner)\n },\n })\n }\n\n /**\n * Stand up the per-run bridge: register the tool core + a fresh bearer token\n * on this DO, and hand back a URL the SANDBOX can reach — the Worker's public\n * hostname routed to `/_bridge/:runId`. The `threadId` query lets the Worker\n * route the agent's MCP calls back to THIS coordinator. No raw socket is opened.\n */\n private makeBridgeProvisioner(input: StartRunInput): ToolBridgeProvisioner {\n const env = this.env\n const bridges = this.bridges\n const { runId, threadId } = input\n // Container→Worker origin: `PUBLIC_HOSTNAME` if set, else derived from the\n // trigger request (locally → host.docker.internal). The bearer token rides\n // this URL. See `resolveBridgeOrigin`.\n const origin = resolveBridgeOrigin(env, input)\n return {\n provision(tools, options): Promise<ProvisionedBridge> {\n const token =\n crypto.randomUUID() + crypto.randomUUID().replace(/-/g, '')\n const core = createToolBridgeCore(tools, {\n ...(options.context !== undefined\n ? { context: options.context }\n : {}),\n ...(options.signal !== undefined ? { signal: options.signal } : {}),\n ...(options.permission !== undefined\n ? { permission: options.permission }\n : {}),\n })\n bridges.set(runId, { token, core })\n return Promise.resolve({\n name: 'tanstack',\n url: `${origin}/_bridge/${runId}?threadId=${encodeURIComponent(threadId)}`,\n token,\n close: () => {\n bridges.delete(runId)\n return Promise.resolve()\n },\n })\n },\n }\n }\n\n /** Serve `/_bridge/:runId` (the in-sandbox agent's MCP calls) from the base fetch. */\n protected override handleRoute(\n request: Request,\n parts: Array<string>,\n ): Promise<Response> | Response {\n if (parts[0] === '_bridge' && typeof parts[1] === 'string') {\n return this.serveBridge(parts[1], request)\n }\n return super.handleRoute(request, parts)\n }\n\n /** Serve one MCP JSON-RPC request for a run after a constant-time token check. */\n private async serveBridge(\n runId: string,\n request: Request,\n ): Promise<Response> {\n const bridge = this.bridges.get(runId)\n if (!bridge)\n return new Response('no active bridge for run', { status: 404 })\n if (\n !timingSafeBearerEqualWeb(\n request.headers.get('authorization') ?? undefined,\n bridge.token,\n )\n ) {\n return new Response('unauthorized', { status: 401 })\n }\n return runWithCallbackActivity(\n this,\n runId,\n (id) => this.log.touch(id),\n async () => {\n let message: unknown\n try {\n message = await request.json()\n } catch {\n // A malformed body must still produce a valid JSON-RPC error so the\n // agent's MCP client can react, rather than an opaque DO 500 that can\n // wedge the run.\n return this.jsonResponse({\n jsonrpc: '2.0',\n id: null,\n error: { code: -32700, message: 'Parse error' },\n })\n }\n const reply = await handleBridgeJsonRpc(bridge.core, message)\n // A notification (no id) yields null → MCP expects an empty 202 ack.\n if (reply === null) return new Response(null, { status: 202 })\n return this.jsonResponse(reply)\n },\n )\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmFA,IAAsB,yBAAtB,cAEU,mBAAyB;;;;;;;;;CASjC,0BAA2B,IAAI,IAAyB;;;;;;;;CAwBxD,eACE,OAC4B;EAC5B,MAAM,EAAE,SAAS,SAAS,OAAO,kBAAkB,KAAK,OAAO,KAAK;EACpE,MAAM,YAAY,MAAM,UAAU;EAClC,MAAM,eACJ,OAAO,cAAc,YAAY,cAAc,KAC3C,EAAE,UAAU,IACZ,KAAA;EACN,OAAO,KAAK;GACV,UAAU,MAAM;GAChB;GACA,UAAU,MAAM;GAChB,QAAQ;GACR,GAAI,UAAU,KAAA,IAAY,EAAE,MAAM,IAAI,CAAC;GACvC,GAAI,kBAAkB,KAAA,IAAY,EAAE,cAAc,IAAI,CAAC;GACvD,GAAI,iBAAiB,KAAA,IAAY,EAAE,aAAa,IAAI,CAAC;GACrD,YAAY,CACV,KAAK,4BAA4B,KAAK,GACtC,YAAY,OAAO,CACrB;EACF,CAAC;CACH;;CAGA,aAAgC,OAAqB;EACnD,KAAK,QAAQ,OAAO,KAAK;CAC3B;;;;;;;CAYA,4BAAoC,OAAsB;EACxD,MAAM,cAAc,KAAK,sBAAsB,KAAK;EACpD,OAAO,qBAAqB;GAC1B,MAAM;GACN,UAAU,CAAC,+BAA+B;GAC1C,QAAQ,QAAQ;IACd,IAAI,QAAQ,iCAAiC,WAAW;GAC1D;EACF,CAAC;CACH;;;;;;;CAQA,sBAA8B,OAA6C;EACzE,MAAM,MAAM,KAAK;EACjB,MAAM,UAAU,KAAK;EACrB,MAAM,EAAE,OAAO,aAAa;EAI5B,MAAM,SAAS,oBAAoB,KAAK,KAAK;EAC7C,OAAO,EACL,UAAU,OAAO,SAAqC;GACpD,MAAM,QACJ,OAAO,WAAW,IAAI,OAAO,WAAW,CAAC,CAAC,QAAQ,MAAM,EAAE;GAC5D,MAAM,OAAO,qBAAqB,OAAO;IACvC,GAAI,QAAQ,YAAY,KAAA,IACpB,EAAE,SAAS,QAAQ,QAAQ,IAC3B,CAAC;IACL,GAAI,QAAQ,WAAW,KAAA,IAAY,EAAE,QAAQ,QAAQ,OAAO,IAAI,CAAC;IACjE,GAAI,QAAQ,eAAe,KAAA,IACvB,EAAE,YAAY,QAAQ,WAAW,IACjC,CAAC;GACP,CAAC;GACD,QAAQ,IAAI,OAAO;IAAE;IAAO;GAAK,CAAC;GAClC,OAAO,QAAQ,QAAQ;IACrB,MAAM;IACN,KAAK,GAAG,OAAO,WAAW,MAAM,YAAY,mBAAmB,QAAQ;IACvE;IACA,aAAa;KACX,QAAQ,OAAO,KAAK;KACpB,OAAO,QAAQ,QAAQ;IACzB;GACF,CAAC;EACH,EACF;CACF;;CAGA,YACE,SACA,OAC8B;EAC9B,IAAI,MAAM,OAAO,aAAa,OAAO,MAAM,OAAO,UAChD,OAAO,KAAK,YAAY,MAAM,IAAI,OAAO;EAE3C,OAAO,MAAM,YAAY,SAAS,KAAK;CACzC;;CAGA,MAAc,YACZ,OACA,SACmB;EACnB,MAAM,SAAS,KAAK,QAAQ,IAAI,KAAK;EACrC,IAAI,CAAC,QACH,OAAO,IAAI,SAAS,4BAA4B,EAAE,QAAQ,IAAI,CAAC;EACjE,IACE,CAAC,yBACC,QAAQ,QAAQ,IAAI,eAAe,KAAK,KAAA,GACxC,OAAO,KACT,GAEA,OAAO,IAAI,SAAS,gBAAgB,EAAE,QAAQ,IAAI,CAAC;EAErD,OAAO,wBACL,MACA,QACC,OAAO,KAAK,IAAI,MAAM,EAAE,GACzB,YAAY;GACV,IAAI;GACJ,IAAI;IACF,UAAU,MAAM,QAAQ,KAAK;GAC/B,QAAQ;IAIN,OAAO,KAAK,aAAa;KACvB,SAAS;KACT,IAAI;KACJ,OAAO;MAAE,MAAM;MAAQ,SAAS;KAAc;IAChD,CAAC;GACH;GACA,MAAM,QAAQ,MAAM,oBAAoB,OAAO,MAAM,OAAO;GAE5D,IAAI,UAAU,MAAM,OAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;GAC7D,OAAO,KAAK,aAAa,KAAK;EAChC,CACF;CACF;AACF"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { runWithCallbackActivity } from "./coordinator-callbacks.js";
|
|
1
2
|
import { resolveBridgeOrigin } from "./public-host.js";
|
|
2
3
|
import { SandboxCoordinator } from "./coordinator.js";
|
|
3
4
|
import { timingSafeBearerEqualWeb } from "./web-crypto.js";
|
|
@@ -235,23 +236,25 @@ var ContainerSandboxCoordinator = class extends SandboxCoordinator {
|
|
|
235
236
|
const state = this.toolExec.get(runId);
|
|
236
237
|
if (!state) return new Response("no active run", { status: 404 });
|
|
237
238
|
if (!timingSafeBearerEqualWeb(request.headers.get("authorization") ?? void 0, state.token)) return new Response("unauthorized", { status: 401 });
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
239
|
+
return runWithCallbackActivity(this, runId, (id) => this.log.touch(id), async () => {
|
|
240
|
+
let payload;
|
|
241
|
+
try {
|
|
242
|
+
payload = await request.json();
|
|
243
|
+
} catch {
|
|
244
|
+
return this.jsonResponse({ error: "body must be valid JSON" }, 400);
|
|
245
|
+
}
|
|
246
|
+
if (!isToolExecRequest(payload)) return this.jsonResponse({ error: "body must be { name, args }" }, 400);
|
|
247
|
+
try {
|
|
248
|
+
const result = await executeHostTool(state.hostTools, payload.name, payload.args, {
|
|
249
|
+
...state.context !== void 0 ? { context: state.context } : {},
|
|
250
|
+
signal: state.abort.signal
|
|
251
|
+
});
|
|
252
|
+
return this.jsonResponse({ result });
|
|
253
|
+
} catch (error) {
|
|
254
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
255
|
+
return this.jsonResponse({ error: message }, 500);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
255
258
|
}
|
|
256
259
|
};
|
|
257
260
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"container-coordinator.js","names":[],"sources":["../../src/container-coordinator.ts"],"sourcesContent":["/**\n * `ContainerSandboxCoordinator` — the concrete {@link SandboxCoordinator} for\n * the CO-LOCATED (\"combined\") model: the harness loop AND its MCP tool-bridge\n * run INSIDE the container; this DO stays OUTSIDE as a thin durable coordinator.\n *\n * Worker (stateless trigger)\n * → ContainerSandboxCoordinator (this DO: thin durable coordinator)\n * → Container (runs the in-container harness runner that runs chat())\n *\n * The defining difference from {@link ChatSandboxCoordinator}: this DO does NOT\n * call `chat()` / the adapter itself. It implements the one per-model seam,\n * {@link buildRunStream}, by POSTing `/run` to the in-container runner over the\n * sandbox binding and adapting its NDJSON `StreamChunk` stream — so the base's\n * `RunController` / run-log / streaming tail all work unchanged.\n *\n * TWO channels cross the container ↔ DO boundary; everything else (the MCP\n * transport, native stdin) is in-container localhost:\n * • events OUT: runner → DO (NDJSON of StreamChunk, appended to the run-log)\n * • host-tool EXECUTION: container → DO (`/tool-exec/:runId`, bearer-gated) —\n * the REAL tool `execute()` (DB / secrets / app state) lives HERE.\n *\n * The per-run config — host tools, workspace, harness, model — is the subclass's\n * {@link config} method.\n *\n * NOTE: Workers-runtime code — compiles against the real Cloudflare + TanStack\n * AI types; not runtime-verified in this repo (no Workers runtime / container\n * build here). It follows the proven run-log / remote-tool contracts.\n */\nimport { EventType } from '@tanstack/ai'\nimport {\n executeHostTool,\n isToolExecRequest,\n toolDescriptors,\n} from '@tanstack/ai-sandbox'\nimport { getSandbox } from '@cloudflare/sandbox'\nimport { SandboxCoordinator, resolveBridgeOrigin } from './coordinator'\nimport { timingSafeBearerEqualWeb } from './web-crypto'\nimport type { StartRunInput } from './coordinator'\nimport type { ContainerRunRequest, HarnessId } from './protocol'\nimport type { AnyTool, StreamChunk } from '@tanstack/ai'\nimport type { WorkspaceDefinition } from '@tanstack/ai-sandbox'\nimport type { Sandbox } from '@cloudflare/sandbox'\n\n/** Port the in-container runner listens on (matches RUNNER_PORT in the image). */\nconst RUNNER_PORT = 8080\n\n/**\n * The Env bindings a {@link ContainerSandboxCoordinator} requires. The\n * `tool-exec` URL the CONTAINER calls back on needs a hostname; `PUBLIC_HOSTNAME`\n * is OPTIONAL (request-derived when unset; locally → `host.docker.internal` — see\n * {@link resolveBridgeOrigin}).\n *\n * Auth is HARNESS-AGNOSTIC: the in-container CLI's API key is NOT a fixed field on\n * this env. Instead each run's workspace DECLARES the secret names it needs (via\n * `createSecrets`), and the coordinator copies those names out of the Worker `env`\n * into the container env at boot. So a Claude run declares `ANTHROPIC_API_KEY`, a\n * codex run declares `CODEX_API_KEY`, and neither name is baked into the package —\n * the concrete key binding lives on the APP's env type, not here.\n */\nexport interface ContainerCoordinatorEnv {\n /** The `@cloudflare/sandbox` Sandbox DO namespace (the container hosts). */\n Sandbox: DurableObjectNamespace<Sandbox>\n /**\n * Hostname the container uses to reach the DO's `/tool-exec` endpoint. Optional:\n * unset → derived from the trigger request (deployed: request host; local dev:\n * `host.docker.internal`). Set it only to override. See {@link resolveBridgeOrigin}.\n */\n PUBLIC_HOSTNAME?: string\n}\n\n/** What {@link ContainerSandboxCoordinator.config} returns for one run. */\nexport interface ContainerRunConfig {\n /**\n * The REAL host tools. Their `execute()` runs HERE, in the DO — the\n * in-container agent only ever reaches them via `/tool-exec/:runId`. Only the\n * serialized descriptors cross to the container.\n */\n hostTools: Array<AnyTool>\n /** Workspace the in-container runner bootstraps for the agent. */\n workspace: WorkspaceDefinition\n /** Which in-sandbox harness the runner spawns. */\n harness: HarnessId\n /** Model id passed to that harness. */\n model: string\n /** Runtime context forwarded to each host tool's `execute()` (DB / app state). */\n context?: unknown\n}\n\n/** Per-run tool-exec state; gates `/tool-exec/:runId` and runs the host tools. */\ninterface ToolExecState {\n token: string\n hostTools: Array<AnyTool>\n context?: unknown\n /** Aborted once the run is terminal so a still-running host tool is cancelled. */\n abort: AbortController\n}\n\n/** Narrow one NDJSON line into a StreamChunk (project rule: no `as`). */\nfunction isStreamChunk(value: unknown): value is StreamChunk {\n return value !== null && typeof value === 'object' && 'type' in value\n}\n\n/**\n * Adapt the runner's NDJSON response body into an `AsyncIterable<StreamChunk>`\n * so the DO can drive it through the SAME base `RunController` / `pipeToRunLog`\n * the DO-drives coordinator uses — terminal-status handling, RUN_ERROR\n * detection, and never-rejects semantics all come for free. A malformed line\n * (unparseable JSON, or valid JSON that isn't a chunk) is surfaced as a terminal\n * RUN_ERROR chunk, never silently dropped.\n */\nasync function* ndjsonToChunks(\n body: ReadableStream<Uint8Array>,\n): AsyncIterable<StreamChunk> {\n const reader = body.getReader()\n // Decode incrementally with `stream: true` so a multi-byte char split across\n // two reads is reassembled correctly (TextDecoderStream's DOM/Workers typings\n // disagree across versions; a plain TextDecoder is version-robust and no-cast).\n const decoder = new TextDecoder()\n let buffer = ''\n let result = await reader.read()\n while (!result.done) {\n buffer += decoder.decode(result.value, { stream: true })\n let newline = buffer.indexOf('\\n')\n while (newline !== -1) {\n const line = buffer.slice(0, newline).trim()\n buffer = buffer.slice(newline + 1)\n newline = buffer.indexOf('\\n')\n if (line === '') continue\n const chunk = parseChunkLine(line)\n yield chunk\n if (chunk.type === EventType.RUN_ERROR) return\n }\n result = await reader.read()\n }\n buffer += decoder.decode()\n const tail = buffer.trim()\n if (tail !== '') yield parseChunkLine(tail)\n}\n\n/**\n * Parse one NDJSON line into a {@link StreamChunk}, turning a truncated/garbled\n * line (a crashed container's last write) or a non-chunk object into a terminal\n * RUN_ERROR chunk rather than throwing or silently dropping it.\n */\nfunction parseChunkLine(line: string): StreamChunk {\n let parsed: unknown\n try {\n parsed = JSON.parse(line)\n } catch {\n return {\n type: EventType.RUN_ERROR,\n message: `runner sent unparseable NDJSON: ${line.slice(0, 200)}`,\n }\n }\n if (!isStreamChunk(parsed)) {\n return {\n type: EventType.RUN_ERROR,\n message: 'runner sent a non-chunk line',\n }\n }\n return parsed\n}\n\nexport abstract class ContainerSandboxCoordinator<\n TEnv extends ContainerCoordinatorEnv = ContainerCoordinatorEnv,\n> extends SandboxCoordinator<TEnv> {\n /**\n * Live per-run tool-exec tokens, keyed by runId. In-memory by design: a run's\n * tool-exec endpoint is only reachable while the run is in flight, and\n * `ctx.waitUntil(done)` keeps THIS instance alive for the run's lifetime, so\n * the container's callbacks always hit the instance that minted the token.\n */\n private readonly toolExec = new Map<string, ToolExecState>()\n\n /**\n * In-flight runner boot, memoized so two runs starting near-simultaneously on\n * this instance don't both spawn `container-runner` (the second would hit\n * EADDRINUSE on RUNNER_PORT). Cleared once boot settles.\n */\n private runnerBoot?: Promise<void>\n\n /** Last `/health` probe error, surfaced if the runner never comes up. */\n private lastProbeError?: unknown\n\n // ===========================================================================\n // Subclass seam: the per-run configuration\n // ===========================================================================\n\n /**\n * Resolve the host tools, workspace, harness, and model for one run.\n * Implemented by the app subclass (or supplied by\n * {@link createCloudflareSandboxAgent}).\n */\n protected abstract config(input: StartRunInput): ContainerRunConfig\n\n // ===========================================================================\n // The one per-model seam: drive the in-container runner\n // ===========================================================================\n\n /**\n * Mint the per-run tool-exec token, POST `/run` to the in-container runner, and\n * yield its NDJSON chunks. The token is registered BEFORE the container is told\n * to run, so a tool callback can never arrive before the token exists.\n */\n protected override buildRunStream(\n input: StartRunInput,\n ): AsyncIterable<StreamChunk> {\n const runConfig = this.config(input)\n // Mint the token BEFORE driving the container, registering the real tools so\n // `/tool-exec/:runId` can execute them.\n const token = crypto.randomUUID() + crypto.randomUUID().replace(/-/g, '')\n this.toolExec.set(input.runId, {\n token,\n hostTools: runConfig.hostTools,\n ...(runConfig.context !== undefined\n ? { context: runConfig.context }\n : {}),\n abort: new AbortController(),\n })\n return this.driveContainer(input, runConfig, token)\n }\n\n /**\n * Once the run is terminal, abort any host tool still running on its behalf\n * (so a tool that outlived the run doesn't leak), then drop the per-run state.\n */\n protected override onRunSettled(runId: string): void {\n const state = this.toolExec.get(runId)\n if (state) state.abort.abort()\n this.toolExec.delete(runId)\n }\n\n /**\n * POST `/run` to the in-container runner and yield its NDJSON chunks. The DO\n * reaches the runner DIRECTLY over the sandbox binding (`containerFetch` to\n * RUNNER_PORT) — this internal channel needs no public hostname. The runner\n * gets the host-tool descriptors plus the `/tool-exec` URL + token it calls\n * back on.\n */\n private async *driveContainer(\n input: StartRunInput,\n runConfig: ContainerRunConfig,\n token: string,\n ): AsyncIterable<StreamChunk> {\n const sandbox = getSandbox(this.env.Sandbox, input.threadId)\n await this.ensureRunner(sandbox, runConfig.workspace)\n // Container→Worker origin: `PUBLIC_HOSTNAME` if set, else derived from the\n // trigger request (locally → host.docker.internal). The tool-exec token rides\n // this URL. See `resolveBridgeOrigin`.\n const origin = resolveBridgeOrigin(this.env, input)\n const body: ContainerRunRequest = {\n runId: input.runId,\n threadId: input.threadId,\n messages: input.messages,\n harness: runConfig.harness,\n model: runConfig.model,\n workspace: runConfig.workspace,\n // Serialize the DO's real tools to wire descriptors for the container.\n toolDescriptors: toolDescriptors(runConfig.hostTools),\n // The container calls back here for host-tool EXECUTION. It must be a URL\n // the CONTAINER can reach, so it goes via the Worker's public hostname.\n toolExecUrl: `${origin}/tool-exec/${input.runId}?threadId=${encodeURIComponent(input.threadId)}`,\n toolExecToken: token,\n }\n const response = await sandbox.containerFetch(\n 'http://runner/run',\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n },\n RUNNER_PORT,\n )\n if (!response.ok || !response.body) {\n const text = await response.text()\n // Surface as a terminal RUN_ERROR chunk; the base run driver finishes the\n // run as `error` and tailing clients observe it.\n yield {\n type: EventType.RUN_ERROR,\n message: `container runner failed: ${response.status} ${text.slice(0, 200)}`,\n }\n return\n }\n yield* ndjsonToChunks(response.body)\n }\n\n /**\n * Ensure the in-container runner is listening on RUNNER_PORT. The base image's\n * ENTRYPOINT is the sandbox CONTROL server, not our runner — so we start the\n * bundled runner as a background process via that control server. Idempotent\n * for a thread-reused container: if `/health` already answers, we skip spawn.\n */\n private ensureRunner(\n sandbox: Sandbox,\n workspace: WorkspaceDefinition,\n ): Promise<void> {\n // Memoize so concurrent runs on this instance share ONE boot.\n if (this.runnerBoot) return this.runnerBoot\n const boot = this.bootRunner(sandbox, workspace).finally(() => {\n this.runnerBoot = undefined\n })\n this.runnerBoot = boot\n return boot\n }\n\n /**\n * Copy the run's DECLARED secret names out of the Worker `env` into a plain\n * record for the container env. The workspace's `createSecrets` carries only the\n * names across the `/run` boundary; the VALUES come from `env` by that name —\n * which is how `ANTHROPIC_API_KEY` / `CODEX_API_KEY` / any harness key reach the\n * CLI without the package hardcoding which one. A declared name missing from\n * `env` is skipped here and fails loudly later in the runner's\n * `reconstituteWorkspace` (never a silent keyless run).\n */\n private secretEnvFromWorkspace(\n workspace: WorkspaceDefinition,\n ): Record<string, string> {\n const env = this.env as Record<string, unknown>\n const out: Record<string, string> = {}\n for (const name of Object.keys(workspace.secrets ?? {})) {\n const value = env[name]\n if (typeof value === 'string' && value !== '') out[name] = value\n }\n return out\n }\n\n private async bootRunner(\n sandbox: Sandbox,\n workspace: WorkspaceDefinition,\n ): Promise<void> {\n if (await this.runnerHealthy(sandbox)) return\n // Inject the run's declared secrets into the container env so the in-container\n // CLI can authenticate. Values never land in argv or the run-log. Harness-\n // agnostic: whichever secret names the workspace declared (ANTHROPIC_API_KEY,\n // CODEX_API_KEY, …) are read from `env` by name. The runner process inherits\n // this env at boot, so secrets must be set BEFORE startProcess.\n const secretEnv = this.secretEnvFromWorkspace(workspace)\n if (Object.keys(secretEnv).length > 0) {\n await sandbox.setEnvVars(secretEnv)\n }\n // The Dockerfile copies the bundled runner to /app/container-runner.mjs.\n await sandbox.startProcess(`node /app/container-runner.mjs`, {\n env: { RUNNER_PORT: String(RUNNER_PORT) },\n })\n // Poll until it answers /health (container cold-start + node boot). A run\n // that never comes up surfaces as a failed containerFetch above — not a hang.\n for (let attempt = 0; attempt < 20; attempt += 1) {\n if (await this.runnerHealthy(sandbox)) return\n await new Promise((resolve) => setTimeout(resolve, 250))\n }\n // Include the last probe error so a real misconfig (missing binding, image\n // without the runner) is distinguishable from a plain slow cold-start.\n const detail =\n this.lastProbeError instanceof Error\n ? `: ${this.lastProbeError.message}`\n : this.lastProbeError !== undefined\n ? `: ${String(this.lastProbeError)}`\n : ''\n throw new Error(\n `in-container runner did not become healthy in time${detail}`,\n )\n }\n\n private async runnerHealthy(sandbox: Sandbox): Promise<boolean> {\n try {\n const res = await sandbox.containerFetch(\n 'http://runner/health',\n { method: 'GET' },\n RUNNER_PORT,\n )\n return res.ok\n } catch (error) {\n this.lastProbeError = error\n return false\n }\n }\n\n // ===========================================================================\n // The host-tool-exec callback (`/tool-exec/:runId`), from the base fetch\n // ===========================================================================\n\n protected override handleRoute(\n request: Request,\n parts: Array<string>,\n ): Promise<Response> | Response {\n if (parts[0] === 'tool-exec' && typeof parts[1] === 'string') {\n return this.serveToolExec(parts[1], request)\n }\n return super.handleRoute(request, parts)\n }\n\n /**\n * Execute a host tool the in-container agent called back for. The token gates\n * it (constant-time Web Crypto compare); the REAL tool's `execute()` runs here\n * via {@link executeHostTool} and its raw result returns as `{ result }`. An\n * unknown tool or a thrown `execute()` is surfaced as a 4xx/5xx, never masked.\n */\n private async serveToolExec(\n runId: string,\n request: Request,\n ): Promise<Response> {\n const state = this.toolExec.get(runId)\n if (!state) return new Response('no active run', { status: 404 })\n if (\n !timingSafeBearerEqualWeb(\n request.headers.get('authorization') ?? undefined,\n state.token,\n )\n ) {\n return new Response('unauthorized', { status: 401 })\n }\n let payload: unknown\n try {\n payload = await request.json()\n } catch {\n return this.jsonResponse({ error: 'body must be valid JSON' }, 400)\n }\n if (!isToolExecRequest(payload)) {\n return this.jsonResponse({ error: 'body must be { name, args }' }, 400)\n }\n try {\n const result = await executeHostTool(\n state.hostTools,\n payload.name,\n payload.args,\n {\n ...(state.context !== undefined ? { context: state.context } : {}),\n signal: state.abort.signal,\n },\n )\n return this.jsonResponse({ result })\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n return this.jsonResponse({ error: message }, 500)\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4CA,IAAM,cAAc;;AAsDpB,SAAS,cAAc,OAAsC;CAC3D,OAAO,UAAU,QAAQ,OAAO,UAAU,YAAY,UAAU;AAClE;;;;;;;;;AAUA,gBAAgB,eACd,MAC4B;CAC5B,MAAM,SAAS,KAAK,UAAU;CAI9B,MAAM,UAAU,IAAI,YAAY;CAChC,IAAI,SAAS;CACb,IAAI,SAAS,MAAM,OAAO,KAAK;CAC/B,OAAO,CAAC,OAAO,MAAM;EACnB,UAAU,QAAQ,OAAO,OAAO,OAAO,EAAE,QAAQ,KAAK,CAAC;EACvD,IAAI,UAAU,OAAO,QAAQ,IAAI;EACjC,OAAO,YAAY,IAAI;GACrB,MAAM,OAAO,OAAO,MAAM,GAAG,OAAO,CAAC,CAAC,KAAK;GAC3C,SAAS,OAAO,MAAM,UAAU,CAAC;GACjC,UAAU,OAAO,QAAQ,IAAI;GAC7B,IAAI,SAAS,IAAI;GACjB,MAAM,QAAQ,eAAe,IAAI;GACjC,MAAM;GACN,IAAI,MAAM,SAAS,UAAU,WAAW;EAC1C;EACA,SAAS,MAAM,OAAO,KAAK;CAC7B;CACA,UAAU,QAAQ,OAAO;CACzB,MAAM,OAAO,OAAO,KAAK;CACzB,IAAI,SAAS,IAAI,MAAM,eAAe,IAAI;AAC5C;;;;;;AAOA,SAAS,eAAe,MAA2B;CACjD,IAAI;CACJ,IAAI;EACF,SAAS,KAAK,MAAM,IAAI;CAC1B,QAAQ;EACN,OAAO;GACL,MAAM,UAAU;GAChB,SAAS,mCAAmC,KAAK,MAAM,GAAG,GAAG;EAC/D;CACF;CACA,IAAI,CAAC,cAAc,MAAM,GACvB,OAAO;EACL,MAAM,UAAU;EAChB,SAAS;CACX;CAEF,OAAO;AACT;AAEA,IAAsB,8BAAtB,cAEU,mBAAyB;;;;;;;CAOjC,2BAA4B,IAAI,IAA2B;;;;;;CAO3D;;CAGA;;;;;;CAsBA,eACE,OAC4B;EAC5B,MAAM,YAAY,KAAK,OAAO,KAAK;EAGnC,MAAM,QAAQ,OAAO,WAAW,IAAI,OAAO,WAAW,CAAC,CAAC,QAAQ,MAAM,EAAE;EACxE,KAAK,SAAS,IAAI,MAAM,OAAO;GAC7B;GACA,WAAW,UAAU;GACrB,GAAI,UAAU,YAAY,KAAA,IACtB,EAAE,SAAS,UAAU,QAAQ,IAC7B,CAAC;GACL,OAAO,IAAI,gBAAgB;EAC7B,CAAC;EACD,OAAO,KAAK,eAAe,OAAO,WAAW,KAAK;CACpD;;;;;CAMA,aAAgC,OAAqB;EACnD,MAAM,QAAQ,KAAK,SAAS,IAAI,KAAK;EACrC,IAAI,OAAO,MAAM,MAAM,MAAM;EAC7B,KAAK,SAAS,OAAO,KAAK;CAC5B;;;;;;;;CASA,OAAe,eACb,OACA,WACA,OAC4B;EAC5B,MAAM,UAAU,WAAW,KAAK,IAAI,SAAS,MAAM,QAAQ;EAC3D,MAAM,KAAK,aAAa,SAAS,UAAU,SAAS;EAIpD,MAAM,SAAS,oBAAoB,KAAK,KAAK,KAAK;EAClD,MAAM,OAA4B;GAChC,OAAO,MAAM;GACb,UAAU,MAAM;GAChB,UAAU,MAAM;GAChB,SAAS,UAAU;GACnB,OAAO,UAAU;GACjB,WAAW,UAAU;GAErB,iBAAiB,gBAAgB,UAAU,SAAS;GAGpD,aAAa,GAAG,OAAO,aAAa,MAAM,MAAM,YAAY,mBAAmB,MAAM,QAAQ;GAC7F,eAAe;EACjB;EACA,MAAM,WAAW,MAAM,QAAQ,eAC7B,qBACA;GACE,QAAQ;GACR,SAAS,EAAE,gBAAgB,mBAAmB;GAC9C,MAAM,KAAK,UAAU,IAAI;EAC3B,GACA,WACF;EACA,IAAI,CAAC,SAAS,MAAM,CAAC,SAAS,MAAM;GAClC,MAAM,OAAO,MAAM,SAAS,KAAK;GAGjC,MAAM;IACJ,MAAM,UAAU;IAChB,SAAS,4BAA4B,SAAS,OAAO,GAAG,KAAK,MAAM,GAAG,GAAG;GAC3E;GACA;EACF;EACA,OAAO,eAAe,SAAS,IAAI;CACrC;;;;;;;CAQA,aACE,SACA,WACe;EAEf,IAAI,KAAK,YAAY,OAAO,KAAK;EACjC,MAAM,OAAO,KAAK,WAAW,SAAS,SAAS,CAAC,CAAC,cAAc;GAC7D,KAAK,aAAa,KAAA;EACpB,CAAC;EACD,KAAK,aAAa;EAClB,OAAO;CACT;;;;;;;;;;CAWA,uBACE,WACwB;EACxB,MAAM,MAAM,KAAK;EACjB,MAAM,MAA8B,CAAC;EACrC,KAAK,MAAM,QAAQ,OAAO,KAAK,UAAU,WAAW,CAAC,CAAC,GAAG;GACvD,MAAM,QAAQ,IAAI;GAClB,IAAI,OAAO,UAAU,YAAY,UAAU,IAAI,IAAI,QAAQ;EAC7D;EACA,OAAO;CACT;CAEA,MAAc,WACZ,SACA,WACe;EACf,IAAI,MAAM,KAAK,cAAc,OAAO,GAAG;EAMvC,MAAM,YAAY,KAAK,uBAAuB,SAAS;EACvD,IAAI,OAAO,KAAK,SAAS,CAAC,CAAC,SAAS,GAClC,MAAM,QAAQ,WAAW,SAAS;EAGpC,MAAM,QAAQ,aAAa,kCAAkC,EAC3D,KAAK,EAAE,aAAa,OAAO,WAAW,EAAE,EAC1C,CAAC;EAGD,KAAK,IAAI,UAAU,GAAG,UAAU,IAAI,WAAW,GAAG;GAChD,IAAI,MAAM,KAAK,cAAc,OAAO,GAAG;GACvC,MAAM,IAAI,SAAS,YAAY,WAAW,SAAS,GAAG,CAAC;EACzD;EAGA,MAAM,SACJ,KAAK,0BAA0B,QAC3B,KAAK,KAAK,eAAe,YACzB,KAAK,mBAAmB,KAAA,IACtB,KAAK,OAAO,KAAK,cAAc,MAC/B;EACR,MAAM,IAAI,MACR,qDAAqD,QACvD;CACF;CAEA,MAAc,cAAc,SAAoC;EAC9D,IAAI;GAMF,QAAO,MALW,QAAQ,eACxB,wBACA,EAAE,QAAQ,MAAM,GAChB,WACF,EAAA,CACW;EACb,SAAS,OAAO;GACd,KAAK,iBAAiB;GACtB,OAAO;EACT;CACF;CAMA,YACE,SACA,OAC8B;EAC9B,IAAI,MAAM,OAAO,eAAe,OAAO,MAAM,OAAO,UAClD,OAAO,KAAK,cAAc,MAAM,IAAI,OAAO;EAE7C,OAAO,MAAM,YAAY,SAAS,KAAK;CACzC;;;;;;;CAQA,MAAc,cACZ,OACA,SACmB;EACnB,MAAM,QAAQ,KAAK,SAAS,IAAI,KAAK;EACrC,IAAI,CAAC,OAAO,OAAO,IAAI,SAAS,iBAAiB,EAAE,QAAQ,IAAI,CAAC;EAChE,IACE,CAAC,yBACC,QAAQ,QAAQ,IAAI,eAAe,KAAK,KAAA,GACxC,MAAM,KACR,GAEA,OAAO,IAAI,SAAS,gBAAgB,EAAE,QAAQ,IAAI,CAAC;EAErD,IAAI;EACJ,IAAI;GACF,UAAU,MAAM,QAAQ,KAAK;EAC/B,QAAQ;GACN,OAAO,KAAK,aAAa,EAAE,OAAO,0BAA0B,GAAG,GAAG;EACpE;EACA,IAAI,CAAC,kBAAkB,OAAO,GAC5B,OAAO,KAAK,aAAa,EAAE,OAAO,8BAA8B,GAAG,GAAG;EAExE,IAAI;GACF,MAAM,SAAS,MAAM,gBACnB,MAAM,WACN,QAAQ,MACR,QAAQ,MACR;IACE,GAAI,MAAM,YAAY,KAAA,IAAY,EAAE,SAAS,MAAM,QAAQ,IAAI,CAAC;IAChE,QAAQ,MAAM,MAAM;GACtB,CACF;GACA,OAAO,KAAK,aAAa,EAAE,OAAO,CAAC;EACrC,SAAS,OAAO;GACd,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;GACrE,OAAO,KAAK,aAAa,EAAE,OAAO,QAAQ,GAAG,GAAG;EAClD;CACF;AACF"}
|
|
1
|
+
{"version":3,"file":"container-coordinator.js","names":[],"sources":["../../src/container-coordinator.ts"],"sourcesContent":["/**\n * `ContainerSandboxCoordinator` — the concrete {@link SandboxCoordinator} for\n * the CO-LOCATED (\"combined\") model: the harness loop AND its MCP tool-bridge\n * run INSIDE the container; this DO stays OUTSIDE as a thin durable coordinator.\n *\n * Worker (stateless trigger)\n * → ContainerSandboxCoordinator (this DO: thin durable coordinator)\n * → Container (runs the in-container harness runner that runs chat())\n *\n * The defining difference from {@link ChatSandboxCoordinator}: this DO does NOT\n * call `chat()` / the adapter itself. It implements the one per-model seam,\n * {@link buildRunStream}, by POSTing `/run` to the in-container runner over the\n * sandbox binding and adapting its NDJSON `StreamChunk` stream — so the base's\n * `RunController` / run-log / streaming tail all work unchanged.\n *\n * TWO channels cross the container ↔ DO boundary; everything else (the MCP\n * transport, native stdin) is in-container localhost:\n * • events OUT: runner → DO (NDJSON of StreamChunk, appended to the run-log)\n * • host-tool EXECUTION: container → DO (`/tool-exec/:runId`, bearer-gated) —\n * the REAL tool `execute()` (DB / secrets / app state) lives HERE.\n *\n * The per-run config — host tools, workspace, harness, model — is the subclass's\n * {@link config} method.\n *\n * NOTE: Workers-runtime code — compiles against the real Cloudflare + TanStack\n * AI types; not runtime-verified in this repo (no Workers runtime / container\n * build here). It follows the proven run-log / remote-tool contracts.\n */\nimport { EventType } from '@tanstack/ai'\nimport {\n executeHostTool,\n isToolExecRequest,\n toolDescriptors,\n} from '@tanstack/ai-sandbox'\nimport { getSandbox } from '@cloudflare/sandbox'\nimport { SandboxCoordinator, resolveBridgeOrigin } from './coordinator'\nimport { runWithCallbackActivity } from './coordinator-callbacks'\nimport { timingSafeBearerEqualWeb } from './web-crypto'\nimport type { StartRunInput } from './coordinator'\nimport type { ContainerRunRequest, HarnessId } from './protocol'\nimport type { AnyTool, StreamChunk } from '@tanstack/ai'\nimport type { WorkspaceDefinition } from '@tanstack/ai-sandbox'\nimport type { Sandbox } from '@cloudflare/sandbox'\n\n/** Port the in-container runner listens on (matches RUNNER_PORT in the image). */\nconst RUNNER_PORT = 8080\n\n/**\n * The Env bindings a {@link ContainerSandboxCoordinator} requires. The\n * `tool-exec` URL the CONTAINER calls back on needs a hostname; `PUBLIC_HOSTNAME`\n * is OPTIONAL (request-derived when unset; locally → `host.docker.internal` — see\n * {@link resolveBridgeOrigin}).\n *\n * Auth is HARNESS-AGNOSTIC: the in-container CLI's API key is NOT a fixed field on\n * this env. Instead each run's workspace DECLARES the secret names it needs (via\n * `createSecrets`), and the coordinator copies those names out of the Worker `env`\n * into the container env at boot. So a Claude run declares `ANTHROPIC_API_KEY`, a\n * codex run declares `CODEX_API_KEY`, and neither name is baked into the package —\n * the concrete key binding lives on the APP's env type, not here.\n */\nexport interface ContainerCoordinatorEnv {\n /** The `@cloudflare/sandbox` Sandbox DO namespace (the container hosts). */\n Sandbox: DurableObjectNamespace<Sandbox>\n /**\n * Hostname the container uses to reach the DO's `/tool-exec` endpoint. Optional:\n * unset → derived from the trigger request (deployed: request host; local dev:\n * `host.docker.internal`). Set it only to override. See {@link resolveBridgeOrigin}.\n */\n PUBLIC_HOSTNAME?: string\n}\n\n/** What {@link ContainerSandboxCoordinator.config} returns for one run. */\nexport interface ContainerRunConfig {\n /**\n * The REAL host tools. Their `execute()` runs HERE, in the DO — the\n * in-container agent only ever reaches them via `/tool-exec/:runId`. Only the\n * serialized descriptors cross to the container.\n */\n hostTools: Array<AnyTool>\n /** Workspace the in-container runner bootstraps for the agent. */\n workspace: WorkspaceDefinition\n /** Which in-sandbox harness the runner spawns. */\n harness: HarnessId\n /** Model id passed to that harness. */\n model: string\n /** Runtime context forwarded to each host tool's `execute()` (DB / app state). */\n context?: unknown\n}\n\n/** Per-run tool-exec state; gates `/tool-exec/:runId` and runs the host tools. */\ninterface ToolExecState {\n token: string\n hostTools: Array<AnyTool>\n context?: unknown\n /** Aborted once the run is terminal so a still-running host tool is cancelled. */\n abort: AbortController\n}\n\n/** Narrow one NDJSON line into a StreamChunk (project rule: no `as`). */\nfunction isStreamChunk(value: unknown): value is StreamChunk {\n return value !== null && typeof value === 'object' && 'type' in value\n}\n\n/**\n * Adapt the runner's NDJSON response body into an `AsyncIterable<StreamChunk>`\n * so the DO can drive it through the SAME base `RunController` / `pipeToRunLog`\n * the DO-drives coordinator uses — terminal-status handling, RUN_ERROR\n * detection, and never-rejects semantics all come for free. A malformed line\n * (unparseable JSON, or valid JSON that isn't a chunk) is surfaced as a terminal\n * RUN_ERROR chunk, never silently dropped.\n */\nasync function* ndjsonToChunks(\n body: ReadableStream<Uint8Array>,\n): AsyncIterable<StreamChunk> {\n const reader = body.getReader()\n // Decode incrementally with `stream: true` so a multi-byte char split across\n // two reads is reassembled correctly (TextDecoderStream's DOM/Workers typings\n // disagree across versions; a plain TextDecoder is version-robust and no-cast).\n const decoder = new TextDecoder()\n let buffer = ''\n let result = await reader.read()\n while (!result.done) {\n buffer += decoder.decode(result.value, { stream: true })\n let newline = buffer.indexOf('\\n')\n while (newline !== -1) {\n const line = buffer.slice(0, newline).trim()\n buffer = buffer.slice(newline + 1)\n newline = buffer.indexOf('\\n')\n if (line === '') continue\n const chunk = parseChunkLine(line)\n yield chunk\n if (chunk.type === EventType.RUN_ERROR) return\n }\n result = await reader.read()\n }\n buffer += decoder.decode()\n const tail = buffer.trim()\n if (tail !== '') yield parseChunkLine(tail)\n}\n\n/**\n * Parse one NDJSON line into a {@link StreamChunk}, turning a truncated/garbled\n * line (a crashed container's last write) or a non-chunk object into a terminal\n * RUN_ERROR chunk rather than throwing or silently dropping it.\n */\nfunction parseChunkLine(line: string): StreamChunk {\n let parsed: unknown\n try {\n parsed = JSON.parse(line)\n } catch {\n return {\n type: EventType.RUN_ERROR,\n message: `runner sent unparseable NDJSON: ${line.slice(0, 200)}`,\n }\n }\n if (!isStreamChunk(parsed)) {\n return {\n type: EventType.RUN_ERROR,\n message: 'runner sent a non-chunk line',\n }\n }\n return parsed\n}\n\nexport abstract class ContainerSandboxCoordinator<\n TEnv extends ContainerCoordinatorEnv = ContainerCoordinatorEnv,\n> extends SandboxCoordinator<TEnv> {\n /**\n * Live per-run tool-exec tokens, keyed by runId. In-memory by design: a run's\n * tool-exec endpoint is only reachable while the run is in flight, and\n * `ctx.waitUntil(done)` keeps THIS instance alive for the run's lifetime, so\n * the container's callbacks always hit the instance that minted the token.\n */\n private readonly toolExec = new Map<string, ToolExecState>()\n\n /**\n * In-flight runner boot, memoized so two runs starting near-simultaneously on\n * this instance don't both spawn `container-runner` (the second would hit\n * EADDRINUSE on RUNNER_PORT). Cleared once boot settles.\n */\n private runnerBoot?: Promise<void>\n\n /** Last `/health` probe error, surfaced if the runner never comes up. */\n private lastProbeError?: unknown\n\n // ===========================================================================\n // Subclass seam: the per-run configuration\n // ===========================================================================\n\n /**\n * Resolve the host tools, workspace, harness, and model for one run.\n * Implemented by the app subclass (or supplied by\n * {@link createCloudflareSandboxAgent}).\n */\n protected abstract config(input: StartRunInput): ContainerRunConfig\n\n // ===========================================================================\n // The one per-model seam: drive the in-container runner\n // ===========================================================================\n\n /**\n * Mint the per-run tool-exec token, POST `/run` to the in-container runner, and\n * yield its NDJSON chunks. The token is registered BEFORE the container is told\n * to run, so a tool callback can never arrive before the token exists.\n */\n protected override buildRunStream(\n input: StartRunInput,\n ): AsyncIterable<StreamChunk> {\n const runConfig = this.config(input)\n // Mint the token BEFORE driving the container, registering the real tools so\n // `/tool-exec/:runId` can execute them.\n const token = crypto.randomUUID() + crypto.randomUUID().replace(/-/g, '')\n this.toolExec.set(input.runId, {\n token,\n hostTools: runConfig.hostTools,\n ...(runConfig.context !== undefined\n ? { context: runConfig.context }\n : {}),\n abort: new AbortController(),\n })\n return this.driveContainer(input, runConfig, token)\n }\n\n /**\n * Once the run is terminal, abort any host tool still running on its behalf\n * (so a tool that outlived the run doesn't leak), then drop the per-run state.\n */\n protected override onRunSettled(runId: string): void {\n const state = this.toolExec.get(runId)\n if (state) state.abort.abort()\n this.toolExec.delete(runId)\n }\n\n /**\n * POST `/run` to the in-container runner and yield its NDJSON chunks. The DO\n * reaches the runner DIRECTLY over the sandbox binding (`containerFetch` to\n * RUNNER_PORT) — this internal channel needs no public hostname. The runner\n * gets the host-tool descriptors plus the `/tool-exec` URL + token it calls\n * back on.\n */\n private async *driveContainer(\n input: StartRunInput,\n runConfig: ContainerRunConfig,\n token: string,\n ): AsyncIterable<StreamChunk> {\n const sandbox = getSandbox(this.env.Sandbox, input.threadId)\n await this.ensureRunner(sandbox, runConfig.workspace)\n // Container→Worker origin: `PUBLIC_HOSTNAME` if set, else derived from the\n // trigger request (locally → host.docker.internal). The tool-exec token rides\n // this URL. See `resolveBridgeOrigin`.\n const origin = resolveBridgeOrigin(this.env, input)\n const body: ContainerRunRequest = {\n runId: input.runId,\n threadId: input.threadId,\n messages: input.messages,\n harness: runConfig.harness,\n model: runConfig.model,\n workspace: runConfig.workspace,\n // Serialize the DO's real tools to wire descriptors for the container.\n toolDescriptors: toolDescriptors(runConfig.hostTools),\n // The container calls back here for host-tool EXECUTION. It must be a URL\n // the CONTAINER can reach, so it goes via the Worker's public hostname.\n toolExecUrl: `${origin}/tool-exec/${input.runId}?threadId=${encodeURIComponent(input.threadId)}`,\n toolExecToken: token,\n }\n const response = await sandbox.containerFetch(\n 'http://runner/run',\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n },\n RUNNER_PORT,\n )\n if (!response.ok || !response.body) {\n const text = await response.text()\n // Surface as a terminal RUN_ERROR chunk; the base run driver finishes the\n // run as `error` and tailing clients observe it.\n yield {\n type: EventType.RUN_ERROR,\n message: `container runner failed: ${response.status} ${text.slice(0, 200)}`,\n }\n return\n }\n yield* ndjsonToChunks(response.body)\n }\n\n /**\n * Ensure the in-container runner is listening on RUNNER_PORT. The base image's\n * ENTRYPOINT is the sandbox CONTROL server, not our runner — so we start the\n * bundled runner as a background process via that control server. Idempotent\n * for a thread-reused container: if `/health` already answers, we skip spawn.\n */\n private ensureRunner(\n sandbox: Sandbox,\n workspace: WorkspaceDefinition,\n ): Promise<void> {\n // Memoize so concurrent runs on this instance share ONE boot.\n if (this.runnerBoot) return this.runnerBoot\n const boot = this.bootRunner(sandbox, workspace).finally(() => {\n this.runnerBoot = undefined\n })\n this.runnerBoot = boot\n return boot\n }\n\n /**\n * Copy the run's DECLARED secret names out of the Worker `env` into a plain\n * record for the container env. The workspace's `createSecrets` carries only the\n * names across the `/run` boundary; the VALUES come from `env` by that name —\n * which is how `ANTHROPIC_API_KEY` / `CODEX_API_KEY` / any harness key reach the\n * CLI without the package hardcoding which one. A declared name missing from\n * `env` is skipped here and fails loudly later in the runner's\n * `reconstituteWorkspace` (never a silent keyless run).\n */\n private secretEnvFromWorkspace(\n workspace: WorkspaceDefinition,\n ): Record<string, string> {\n const env = this.env as Record<string, unknown>\n const out: Record<string, string> = {}\n for (const name of Object.keys(workspace.secrets ?? {})) {\n const value = env[name]\n if (typeof value === 'string' && value !== '') out[name] = value\n }\n return out\n }\n\n private async bootRunner(\n sandbox: Sandbox,\n workspace: WorkspaceDefinition,\n ): Promise<void> {\n if (await this.runnerHealthy(sandbox)) return\n // Inject the run's declared secrets into the container env so the in-container\n // CLI can authenticate. Values never land in argv or the run-log. Harness-\n // agnostic: whichever secret names the workspace declared (ANTHROPIC_API_KEY,\n // CODEX_API_KEY, …) are read from `env` by name. The runner process inherits\n // this env at boot, so secrets must be set BEFORE startProcess.\n const secretEnv = this.secretEnvFromWorkspace(workspace)\n if (Object.keys(secretEnv).length > 0) {\n await sandbox.setEnvVars(secretEnv)\n }\n // The Dockerfile copies the bundled runner to /app/container-runner.mjs.\n await sandbox.startProcess(`node /app/container-runner.mjs`, {\n env: { RUNNER_PORT: String(RUNNER_PORT) },\n })\n // Poll until it answers /health (container cold-start + node boot). A run\n // that never comes up surfaces as a failed containerFetch above — not a hang.\n for (let attempt = 0; attempt < 20; attempt += 1) {\n if (await this.runnerHealthy(sandbox)) return\n await new Promise((resolve) => setTimeout(resolve, 250))\n }\n // Include the last probe error so a real misconfig (missing binding, image\n // without the runner) is distinguishable from a plain slow cold-start.\n const detail =\n this.lastProbeError instanceof Error\n ? `: ${this.lastProbeError.message}`\n : this.lastProbeError !== undefined\n ? `: ${String(this.lastProbeError)}`\n : ''\n throw new Error(\n `in-container runner did not become healthy in time${detail}`,\n )\n }\n\n private async runnerHealthy(sandbox: Sandbox): Promise<boolean> {\n try {\n const res = await sandbox.containerFetch(\n 'http://runner/health',\n { method: 'GET' },\n RUNNER_PORT,\n )\n return res.ok\n } catch (error) {\n this.lastProbeError = error\n return false\n }\n }\n\n // ===========================================================================\n // The host-tool-exec callback (`/tool-exec/:runId`), from the base fetch\n // ===========================================================================\n\n protected override handleRoute(\n request: Request,\n parts: Array<string>,\n ): Promise<Response> | Response {\n if (parts[0] === 'tool-exec' && typeof parts[1] === 'string') {\n return this.serveToolExec(parts[1], request)\n }\n return super.handleRoute(request, parts)\n }\n\n /**\n * Execute a host tool the in-container agent called back for. The token gates\n * it (constant-time Web Crypto compare); the REAL tool's `execute()` runs here\n * via {@link executeHostTool} and its raw result returns as `{ result }`. An\n * unknown tool or a thrown `execute()` is surfaced as a 4xx/5xx, never masked.\n */\n private async serveToolExec(\n runId: string,\n request: Request,\n ): Promise<Response> {\n const state = this.toolExec.get(runId)\n if (!state) return new Response('no active run', { status: 404 })\n if (\n !timingSafeBearerEqualWeb(\n request.headers.get('authorization') ?? undefined,\n state.token,\n )\n ) {\n return new Response('unauthorized', { status: 401 })\n }\n return runWithCallbackActivity(\n this,\n runId,\n (id) => this.log.touch(id),\n async () => {\n let payload: unknown\n try {\n payload = await request.json()\n } catch {\n return this.jsonResponse({ error: 'body must be valid JSON' }, 400)\n }\n if (!isToolExecRequest(payload)) {\n return this.jsonResponse(\n { error: 'body must be { name, args }' },\n 400,\n )\n }\n try {\n const result = await executeHostTool(\n state.hostTools,\n payload.name,\n payload.args,\n {\n ...(state.context !== undefined\n ? { context: state.context }\n : {}),\n signal: state.abort.signal,\n },\n )\n return this.jsonResponse({ result })\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n return this.jsonResponse({ error: message }, 500)\n }\n },\n )\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
type TouchRun = (runId: string) => Promise<void>;
|
|
2
|
+
export declare function hasInFlightCallback(owner: object, runId: string): boolean;
|
|
3
|
+
export declare function runWithCallbackActivity<T>(owner: object, runId: string, touch: TouchRun, operation: () => Promise<T>): Promise<T>;
|
|
4
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//#region src/coordinator-callbacks.ts
|
|
2
|
+
var inFlightCallbacks = /* @__PURE__ */ new WeakMap();
|
|
3
|
+
function increment(owner, runId) {
|
|
4
|
+
let runs = inFlightCallbacks.get(owner);
|
|
5
|
+
if (!runs) {
|
|
6
|
+
runs = /* @__PURE__ */ new Map();
|
|
7
|
+
inFlightCallbacks.set(owner, runs);
|
|
8
|
+
}
|
|
9
|
+
runs.set(runId, (runs.get(runId) ?? 0) + 1);
|
|
10
|
+
}
|
|
11
|
+
function decrement(owner, runId) {
|
|
12
|
+
const runs = inFlightCallbacks.get(owner);
|
|
13
|
+
if (!runs) return;
|
|
14
|
+
const count = runs.get(runId) ?? 0;
|
|
15
|
+
if (count > 1) {
|
|
16
|
+
runs.set(runId, count - 1);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
runs.delete(runId);
|
|
20
|
+
if (runs.size === 0) inFlightCallbacks.delete(owner);
|
|
21
|
+
}
|
|
22
|
+
function hasInFlightCallback(owner, runId) {
|
|
23
|
+
return (inFlightCallbacks.get(owner)?.get(runId) ?? 0) > 0;
|
|
24
|
+
}
|
|
25
|
+
async function runWithCallbackActivity(owner, runId, touch, operation) {
|
|
26
|
+
increment(owner, runId);
|
|
27
|
+
try {
|
|
28
|
+
await touch(runId);
|
|
29
|
+
try {
|
|
30
|
+
return await operation();
|
|
31
|
+
} finally {
|
|
32
|
+
try {
|
|
33
|
+
await touch(runId);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error(`[sandbox-coordinator] completion activity touch failed for run ${runId}:`, error);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} finally {
|
|
39
|
+
decrement(owner, runId);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
export { hasInFlightCallback, runWithCallbackActivity };
|
|
44
|
+
|
|
45
|
+
//# sourceMappingURL=coordinator-callbacks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"coordinator-callbacks.js","names":[],"sources":["../../src/coordinator-callbacks.ts"],"sourcesContent":["type TouchRun = (runId: string) => Promise<void>\n\nconst inFlightCallbacks = new WeakMap<object, Map<string, number>>()\n\nfunction increment(owner: object, runId: string): void {\n let runs = inFlightCallbacks.get(owner)\n if (!runs) {\n runs = new Map()\n inFlightCallbacks.set(owner, runs)\n }\n runs.set(runId, (runs.get(runId) ?? 0) + 1)\n}\n\nfunction decrement(owner: object, runId: string): void {\n const runs = inFlightCallbacks.get(owner)\n if (!runs) return\n const count = runs.get(runId) ?? 0\n if (count > 1) {\n runs.set(runId, count - 1)\n return\n }\n runs.delete(runId)\n if (runs.size === 0) inFlightCallbacks.delete(owner)\n}\n\nexport function hasInFlightCallback(owner: object, runId: string): boolean {\n return (inFlightCallbacks.get(owner)?.get(runId) ?? 0) > 0\n}\n\nexport async function runWithCallbackActivity<T>(\n owner: object,\n runId: string,\n touch: TouchRun,\n operation: () => Promise<T>,\n): Promise<T> {\n increment(owner, runId)\n try {\n // Failure here proves liveness could not be persisted, so do not execute the\n // callback operation.\n await touch(runId)\n try {\n return await operation()\n } finally {\n // This is best-effort bookkeeping after an operation has produced its\n // result or error. It must never replace that original outcome.\n try {\n await touch(runId)\n } catch (error) {\n console.error(\n `[sandbox-coordinator] completion activity touch failed for run ${runId}:`,\n error,\n )\n }\n }\n } finally {\n decrement(owner, runId)\n }\n}\n"],"mappings":";AAEA,IAAM,oCAAoB,IAAI,QAAqC;AAEnE,SAAS,UAAU,OAAe,OAAqB;CACrD,IAAI,OAAO,kBAAkB,IAAI,KAAK;CACtC,IAAI,CAAC,MAAM;EACT,uBAAO,IAAI,IAAI;EACf,kBAAkB,IAAI,OAAO,IAAI;CACnC;CACA,KAAK,IAAI,QAAQ,KAAK,IAAI,KAAK,KAAK,KAAK,CAAC;AAC5C;AAEA,SAAS,UAAU,OAAe,OAAqB;CACrD,MAAM,OAAO,kBAAkB,IAAI,KAAK;CACxC,IAAI,CAAC,MAAM;CACX,MAAM,QAAQ,KAAK,IAAI,KAAK,KAAK;CACjC,IAAI,QAAQ,GAAG;EACb,KAAK,IAAI,OAAO,QAAQ,CAAC;EACzB;CACF;CACA,KAAK,OAAO,KAAK;CACjB,IAAI,KAAK,SAAS,GAAG,kBAAkB,OAAO,KAAK;AACrD;AAEA,SAAgB,oBAAoB,OAAe,OAAwB;CACzE,QAAQ,kBAAkB,IAAI,KAAK,CAAC,EAAE,IAAI,KAAK,KAAK,KAAK;AAC3D;AAEA,eAAsB,wBACpB,OACA,OACA,OACA,WACY;CACZ,UAAU,OAAO,KAAK;CACtB,IAAI;EAGF,MAAM,MAAM,KAAK;EACjB,IAAI;GACF,OAAO,MAAM,UAAU;EACzB,UAAU;GAGR,IAAI;IACF,MAAM,MAAM,KAAK;GACnB,SAAS,OAAO;IACd,QAAQ,MACN,kEAAkE,MAAM,IACxE,KACF;GACF;EACF;CACF,UAAU;EACR,UAAU,OAAO,KAAK;CACxB;AACF"}
|
|
@@ -3,6 +3,8 @@ import { RunController } from '@tanstack/ai-sandbox';
|
|
|
3
3
|
import { DurableObjectRunEventLog } from './run-log-do.js';
|
|
4
4
|
import { ModelMessage, StreamChunk } from '@tanstack/ai';
|
|
5
5
|
import { RunLogRecord } from './run-log.js';
|
|
6
|
+
/** @internal Shared validation for direct subclasses and the eager factory path. */
|
|
7
|
+
export declare function normalizeStallTimeoutMs(stallTimeoutMs: number | false | undefined): number | false;
|
|
6
8
|
/** What the Worker hands the coordinator to start a run. */
|
|
7
9
|
export interface StartRunInput {
|
|
8
10
|
runId: string;
|
|
@@ -36,7 +38,8 @@ export declare abstract class SandboxCoordinator<TEnv = unknown> extends Durable
|
|
|
36
38
|
* running — double-delivering events and racing the persisted cursor.
|
|
37
39
|
*/
|
|
38
40
|
private readonly pumping;
|
|
39
|
-
|
|
41
|
+
private readonly stallTimeoutMs;
|
|
42
|
+
constructor(ctx: DurableObjectState, env: TEnv, stallTimeoutMs?: number | false);
|
|
40
43
|
/**
|
|
41
44
|
* Produce the run's `StreamChunk` stream. The ONE model-specific method:
|
|
42
45
|
* `ChatSandboxCoordinator` runs `chat()` here; `ContainerSandboxCoordinator`
|
|
@@ -64,6 +67,8 @@ export declare abstract class SandboxCoordinator<TEnv = unknown> extends Durable
|
|
|
64
67
|
webSocketMessage(ws: WebSocket, _message: string | ArrayBuffer): void;
|
|
65
68
|
webSocketClose(_ws: WebSocket, _code: number, _reason: string): void;
|
|
66
69
|
alarm(): Promise<void>;
|
|
67
|
-
/**
|
|
70
|
+
/** Arm without allowing a new run to postpone an earlier pending check. */
|
|
71
|
+
private armWatchdog;
|
|
72
|
+
/** Mark a strictly stale run as a terminal error if it still qualifies. */
|
|
68
73
|
private failStalledRun;
|
|
69
74
|
}
|
package/dist/esm/coordinator.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { runLogStore, runLogStream } from "./durability.js";
|
|
2
|
+
import { hasInFlightCallback } from "./coordinator-callbacks.js";
|
|
2
3
|
import { DurableObjectRunEventLog } from "./run-log-do.js";
|
|
3
4
|
import { resolveBridgeOrigin, resolvePreviewHost } from "./public-host.js";
|
|
4
5
|
import { RunController } from "@tanstack/ai-sandbox";
|
|
@@ -28,16 +29,17 @@ import { DurableObject } from "cloudflare:workers";
|
|
|
28
29
|
* NOTE: Workers-runtime code — compiles against `@cloudflare/workers-types`; not
|
|
29
30
|
* runtime-verified in this repo.
|
|
30
31
|
*/
|
|
31
|
-
/**
|
|
32
|
+
/** Upper bound on the watchdog check interval while a run is in flight (ms). */
|
|
32
33
|
var WATCHDOG_MS = 3e4;
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
/** Default permitted period without persisted run activity (ms). */
|
|
35
|
+
var DEFAULT_STALL_TIMEOUT_MS = 3e5;
|
|
36
|
+
/** @internal Shared validation for direct subclasses and the eager factory path. */
|
|
37
|
+
function normalizeStallTimeoutMs(stallTimeoutMs) {
|
|
38
|
+
if (stallTimeoutMs === void 0) return DEFAULT_STALL_TIMEOUT_MS;
|
|
39
|
+
if (stallTimeoutMs === false) return false;
|
|
40
|
+
if (!Number.isSafeInteger(stallTimeoutMs) || stallTimeoutMs <= 0) throw new TypeError("stallTimeoutMs must be a positive safe integer or false");
|
|
41
|
+
return stallTimeoutMs;
|
|
42
|
+
}
|
|
41
43
|
function isSocketAttachment(value) {
|
|
42
44
|
return value !== null && typeof value === "object" && "runId" in value && typeof value.runId === "string" && "lastSeq" in value && typeof value.lastSeq === "number";
|
|
43
45
|
}
|
|
@@ -51,8 +53,10 @@ var SandboxCoordinator = class extends DurableObject {
|
|
|
51
53
|
* running — double-delivering events and racing the persisted cursor.
|
|
52
54
|
*/
|
|
53
55
|
pumping = /* @__PURE__ */ new WeakSet();
|
|
54
|
-
|
|
56
|
+
stallTimeoutMs;
|
|
57
|
+
constructor(ctx, env, stallTimeoutMs) {
|
|
55
58
|
super(ctx, env);
|
|
59
|
+
this.stallTimeoutMs = normalizeStallTimeoutMs(stallTimeoutMs);
|
|
56
60
|
this.log = new DurableObjectRunEventLog(ctx.storage);
|
|
57
61
|
this.controller = new RunController({
|
|
58
62
|
runs: runLogStore(this.log),
|
|
@@ -72,7 +76,12 @@ var SandboxCoordinator = class extends DurableObject {
|
|
|
72
76
|
});
|
|
73
77
|
}
|
|
74
78
|
async startRun(input) {
|
|
75
|
-
|
|
79
|
+
const existing = await this.log.get(input.runId);
|
|
80
|
+
if (existing) {
|
|
81
|
+
if (!isTerminalRunStatus(existing.status)) await this.armWatchdog();
|
|
82
|
+
return { runId: input.runId };
|
|
83
|
+
}
|
|
84
|
+
await this.armWatchdog();
|
|
76
85
|
await this.log.open({
|
|
77
86
|
runId: input.runId,
|
|
78
87
|
threadId: input.threadId
|
|
@@ -97,7 +106,6 @@ var SandboxCoordinator = class extends DurableObject {
|
|
|
97
106
|
});
|
|
98
107
|
const settle = () => this.onRunSettled(input.runId);
|
|
99
108
|
this.ctx.waitUntil(done.then(settle, settle));
|
|
100
|
-
await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS);
|
|
101
109
|
return { runId: input.runId };
|
|
102
110
|
}
|
|
103
111
|
async status(runId) {
|
|
@@ -174,35 +182,42 @@ var SandboxCoordinator = class extends DurableObject {
|
|
|
174
182
|
}
|
|
175
183
|
webSocketClose(_ws, _code, _reason) {}
|
|
176
184
|
async alarm() {
|
|
185
|
+
if (this.stallTimeoutMs === false) return;
|
|
177
186
|
try {
|
|
178
187
|
const runs = await this.log.list();
|
|
179
|
-
const
|
|
188
|
+
const cutoff = Date.now() - this.stallTimeoutMs;
|
|
180
189
|
let active = false;
|
|
181
190
|
for (const record of runs) {
|
|
182
191
|
if (isTerminalRunStatus(record.status)) continue;
|
|
183
|
-
if (
|
|
192
|
+
if (hasInFlightCallback(this, record.runId) || record.updatedAt >= cutoff) {
|
|
193
|
+
active = true;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (await this.failStalledRun(record.runId, cutoff)) this.onRunSettled(record.runId);
|
|
184
197
|
else active = true;
|
|
185
198
|
}
|
|
186
|
-
if (active) await this.
|
|
199
|
+
if (active) await this.armWatchdog();
|
|
187
200
|
} catch (error) {
|
|
188
201
|
console.error("[sandbox-coordinator] watchdog alarm failed:", error);
|
|
189
|
-
await this.
|
|
202
|
+
await this.armWatchdog();
|
|
190
203
|
}
|
|
191
204
|
}
|
|
192
|
-
/**
|
|
193
|
-
async
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
205
|
+
/** Arm without allowing a new run to postpone an earlier pending check. */
|
|
206
|
+
async armWatchdog() {
|
|
207
|
+
if (this.stallTimeoutMs === false) return;
|
|
208
|
+
const next = Date.now() + Math.min(WATCHDOG_MS, this.stallTimeoutMs);
|
|
209
|
+
const pending = await this.ctx.storage.getAlarm();
|
|
210
|
+
if (pending === null || pending > next) await this.ctx.storage.setAlarm(next);
|
|
211
|
+
}
|
|
212
|
+
/** Mark a strictly stale run as a terminal error if it still qualifies. */
|
|
213
|
+
failStalledRun(runId, cutoff) {
|
|
214
|
+
return this.log.finishIfStale(runId, cutoff, {
|
|
215
|
+
type: EventType.RUN_ERROR,
|
|
216
|
+
message: "run watchdog: no progress; orchestrator presumed dead"
|
|
217
|
+
});
|
|
203
218
|
}
|
|
204
219
|
};
|
|
205
220
|
//#endregion
|
|
206
|
-
export { SandboxCoordinator, resolveBridgeOrigin, resolvePreviewHost };
|
|
221
|
+
export { SandboxCoordinator, normalizeStallTimeoutMs, resolveBridgeOrigin, resolvePreviewHost };
|
|
207
222
|
|
|
208
223
|
//# sourceMappingURL=coordinator.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"coordinator.js","names":[],"sources":["../../src/coordinator.ts"],"sourcesContent":["/**\n * `SandboxCoordinator` — the abstract Durable Object base for the serverless/\n * edge agent run model. It owns everything the two concrete models share:\n *\n * - a durable, resumable run-log ({@link DurableObjectRunEventLog});\n * - `startRun`: open the run, kick off the model's chunk stream WITHOUT blocking\n * the trigger, start piping it into the log via {@link RunController}, register\n * the resulting `done` promise with `ctx.waitUntil` (keeping the instance alive\n * until the run is terminal rather than letting it hibernate mid-run), and arm\n * a watchdog alarm;\n * - `status` (poll fallback) + a hibernatable WebSocket tail with a resumable\n * cursor (replay after `lastSeq`, then live-tail, reconnect-safe);\n * - routing for `GET /runs/:id` and `GET /runs/:id/stream`, delegating any other\n * path to {@link handleRoute} (which a subclass overrides for e.g. `/_bridge`\n * or `/tool-exec`).\n *\n * Subclasses implement {@link buildRunStream} — the ONE difference between the\n * models: run `chat()` in the DO ({@link ChatSandboxCoordinator}) or drive an\n * in-container runner ({@link ContainerSandboxCoordinator}).\n *\n * NOTE: Workers-runtime code — compiles against `@cloudflare/workers-types`; not\n * runtime-verified in this repo.\n */\nimport { DurableObject } from 'cloudflare:workers'\nimport { EventType, isTerminalRunStatus } from '@tanstack/ai'\n// The PORTABLE run driver: this coordinator is a platform binding of core's\n// `RunController`, not a driver of its own. The DO run log backs both of the\n// driver's seams through the adapters in './durability' — `runLogStore` for\n// the lifecycle record, `runLogStream` for the per-run event log — and the\n// vocabulary is core's throughout (historical `done`/`error` records are\n// migrated on read; see './run-log').\nimport { RunController } from '@tanstack/ai-sandbox'\nimport { runLogStore, runLogStream } from './durability'\nimport { DurableObjectRunEventLog } from './run-log-do'\nimport type { ModelMessage, StreamChunk } from '@tanstack/ai'\nimport type { RunLogRecord } from './run-log'\n\n/** Re-arm window for the liveness watchdog while a run is in flight (ms). */\nconst WATCHDOG_MS = 30_000\n\n/**\n * How long a non-terminal run may go without ANY new event before the watchdog\n * presumes the orchestrator driving it is dead (eviction that lost the\n * `waitUntil` promise, an uncaught fault, a hung container) and fails the run so\n * tailing clients stop waiting forever. Generous so a legitimately slow agent\n * step (a long tool call that emits no chunks) is not killed prematurely.\n */\nconst WATCHDOG_STALL_MS = 5 * 60_000\n\n/** What the Worker hands the coordinator to start a run. */\nexport interface StartRunInput {\n runId: string\n threadId: string\n messages: Array<ModelMessage>\n /**\n * The host the `POST /runs` trigger request arrived on, captured by the Worker\n * (`new URL(request.url).host`). Used to derive the container's callback hosts\n * when `PUBLIC_HOSTNAME` / `PREVIEW_HOSTNAME` are not set — see\n * {@link resolveBridgeOrigin} / {@link resolvePreviewHost} for the rules (and the\n * Cloudflare-specific reason request-derivation is safe to trust).\n */\n publicHost?: string\n /**\n * Free-form per-run input forwarded verbatim from the trigger to the app's\n * `adapter` / `sandbox` / `tools` resolvers (it reaches them through `config`\n * unchanged; it is NOT persisted to the run-log). Use it to carry browser-chosen\n * run options the base trigger has no field for — e.g. which harness to run, or a\n * model id. The package never inspects it; the app validates whatever it reads.\n */\n metadata?: Record<string, unknown>\n}\n\n// Host resolvers live in their own (Workers-free) module so they stay pure and\n// unit-testable; re-exported here because the coordinators build their callback\n// URLs with them. `resolveBridgeOrigin` = container→Worker (/_bridge, /tool-exec);\n// `resolvePreviewHost` = browser→container previews. See their docstrings.\nexport { resolveBridgeOrigin, resolvePreviewHost } from './public-host'\n\n/** Cursor stashed on each hibernatable WebSocket so it survives eviction. */\ninterface SocketAttachment {\n runId: string\n lastSeq: number\n}\n\nfunction isSocketAttachment(value: unknown): value is SocketAttachment {\n return (\n value !== null &&\n typeof value === 'object' &&\n 'runId' in value &&\n typeof value.runId === 'string' &&\n 'lastSeq' in value &&\n typeof value.lastSeq === 'number'\n )\n}\n\nexport abstract class SandboxCoordinator<\n TEnv = unknown,\n> extends DurableObject<TEnv> {\n protected readonly log: DurableObjectRunEventLog\n protected readonly controller: RunController\n\n /**\n * Sockets with a live {@link pump} loop. Guards against a second concurrent\n * pump on the same socket: `acceptStream` starts one, and `webSocketMessage`\n * would start another on any inbound client message while the first is still\n * running — double-delivering events and racing the persisted cursor.\n */\n private readonly pumping = new WeakSet<WebSocket>()\n\n constructor(ctx: DurableObjectState, env: TEnv) {\n super(ctx, env)\n this.log = new DurableObjectRunEventLog(ctx.storage)\n this.controller = new RunController({\n runs: runLogStore(this.log),\n durability: (runId) => runLogStream(this.log, { runId }),\n })\n }\n\n // ===========================================================================\n // Subclass seam\n // ===========================================================================\n\n /**\n * Produce the run's `StreamChunk` stream. The ONE model-specific method:\n * `ChatSandboxCoordinator` runs `chat()` here; `ContainerSandboxCoordinator`\n * drives the in-container runner. Lazily consumed by the run driver, so any\n * setup (mint a token, start a container) can happen at the top.\n */\n protected abstract buildRunStream(\n input: StartRunInput,\n ): AsyncIterable<StreamChunk> | Promise<AsyncIterable<StreamChunk>>\n\n /** Extra fetch routes a subclass serves (e.g. `/_bridge`, `/tool-exec`). */\n protected handleRoute(\n _request: Request,\n _parts: Array<string>,\n ): Promise<Response> | Response {\n return new Response('not found', { status: 404 })\n }\n\n /** Called once a run reaches a terminal status (override to clean up state). */\n protected onRunSettled(_runId: string): void {}\n\n protected jsonResponse(body: unknown, status = 200): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { 'content-type': 'application/json' },\n })\n }\n\n // ===========================================================================\n // Trigger (called by the Worker; returns immediately)\n // ===========================================================================\n\n async startRun(input: StartRunInput): Promise<{ runId: string }> {\n const existing = await this.log.get(input.runId)\n if (existing) return { runId: input.runId } // idempotent re-trigger\n\n // Open the run BEFORE building the stream. `pipeToRunLog`'s never-rejects\n // guarantee only covers failures AFTER the stream is handed to it — a throw\n // while BUILDING the stream (config(), chat() validation, mint a token)\n // would otherwise leave no record and no terminal event, so a tailing client\n // would never see the failure. Opening here (idempotent with pipeToRunLog's\n // own open) lets us record it.\n await this.log.open({ runId: input.runId, threadId: input.threadId })\n let stream: AsyncIterable<StreamChunk>\n try {\n stream = await this.buildRunStream(input)\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n await this.log.append(input.runId, {\n type: EventType.RUN_ERROR,\n message,\n })\n await this.log.finish(input.runId, 'failed', { message })\n this.onRunSettled(input.runId)\n return { runId: input.runId }\n }\n\n const { done } = this.controller.start({\n runId: input.runId,\n threadId: input.threadId,\n stream,\n })\n // Keep the instance alive until the run is terminal. `pipeToRunLog` never\n // rejects (failures land in the log), but this must not DEPEND on that:\n // `.finally` adopts a rejection, which would hand `waitUntil` a rejected\n // promise. Two-argument `then` settles fulfilled either way while still\n // running the settle hook.\n const settle = (): void => this.onRunSettled(input.runId)\n this.ctx.waitUntil(done.then(settle, settle))\n await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS)\n return { runId: input.runId }\n }\n\n async status(runId: string): Promise<RunLogRecord | null> {\n // Straight off the log (not `controller.status`) so the answer keeps the\n // log-level fields (`lastSeq`) a reconnecting client resumes from.\n return this.log.get(runId)\n }\n\n // ===========================================================================\n // HTTP surface\n // ===========================================================================\n\n override async fetch(request: Request): Promise<Response> {\n const url = new URL(request.url)\n const parts = url.pathname.split('/').filter(Boolean)\n\n if (parts[0] === 'runs' && typeof parts[1] === 'string') {\n if (parts[2] === 'stream') return this.acceptStream(parts[1], request)\n if (parts.length === 2 && request.method === 'GET') {\n const record = await this.status(parts[1])\n return record\n ? this.jsonResponse(record)\n : this.jsonResponse({ error: 'unknown run' }, 404)\n }\n }\n return this.handleRoute(request, parts)\n }\n\n // ===========================================================================\n // WebSocket streaming with hibernation + resumable cursor\n // ===========================================================================\n\n private async acceptStream(\n runId: string,\n request: Request,\n ): Promise<Response> {\n if (request.headers.get('upgrade') !== 'websocket') {\n return new Response('expected websocket upgrade', { status: 426 })\n }\n const record = await this.log.get(runId)\n if (!record) return new Response('unknown run', { status: 404 })\n\n const url = new URL(request.url)\n const lastSeqParam = url.searchParams.get('lastSeq')\n const lastSeq =\n lastSeqParam !== null ? Number.parseInt(lastSeqParam, 10) : -1\n if (Number.isNaN(lastSeq)) {\n return new Response('lastSeq must be an integer', { status: 400 })\n }\n\n const pair = new WebSocketPair()\n const [client, server] = [pair[0], pair[1]]\n server.serializeAttachment({ runId, lastSeq } satisfies SocketAttachment)\n this.ctx.acceptWebSocket(server)\n this.pump(server, runId, lastSeq)\n\n return new Response(null, { status: 101, webSocket: client })\n }\n\n /**\n * Replay-then-tail loop for one socket. Each delivered event advances the\n * socket's persisted cursor so a mid-stream reconnect resumes exactly once.\n * No-ops if a pump is already running for this socket (see {@link pumping}).\n */\n private pump(socket: WebSocket, runId: string, fromSeq: number): void {\n if (this.pumping.has(socket)) return\n this.pumping.add(socket)\n const done = (async () => {\n try {\n // The tail reads the log directly by seq — the client wire protocol\n // (`?lastSeq`, `{seq, chunk}` frames) is seq-based, and `log.read` is\n // the seq-cursor surface. Core's `controller.attach` serves consumers\n // that speak opaque `StreamDurability` offsets instead.\n for await (const event of this.log.read(runId, { fromSeq })) {\n socket.send(JSON.stringify(event))\n socket.serializeAttachment({\n runId,\n lastSeq: event.seq,\n } satisfies SocketAttachment)\n }\n const record = await this.log.get(runId)\n if (socket.readyState === WebSocket.OPEN) {\n socket.send(JSON.stringify({ type: 'status', record }))\n socket.close(1000, 'run complete')\n }\n } catch (error) {\n // A tail loop throwing means a run-log read failed — an operator needs\n // the full error, but the client only gets a truncated close reason.\n const message = error instanceof Error ? error.message : String(error)\n console.error(\n `[sandbox-coordinator] tail failed for run ${runId}:`,\n error,\n )\n if (socket.readyState === WebSocket.OPEN) {\n socket.close(1011, message.slice(0, 120))\n }\n } finally {\n this.pumping.delete(socket)\n }\n })()\n this.ctx.waitUntil(done)\n }\n\n override webSocketMessage(\n ws: WebSocket,\n _message: string | ArrayBuffer,\n ): void {\n // Only meaningful as a post-hibernation resume nudge: restart the tail from\n // the persisted cursor IF no pump is live (the guard in `pump` enforces the\n // \"resume exactly once\" invariant when the original pump is still running).\n const attachment: unknown = ws.deserializeAttachment()\n if (isSocketAttachment(attachment)) {\n this.pump(ws, attachment.runId, attachment.lastSeq)\n }\n }\n\n override webSocketClose(\n _ws: WebSocket,\n _code: number,\n _reason: string,\n ): void {\n // Nothing to clean up: the run-log is durable and independent of any socket.\n }\n\n // ===========================================================================\n // Watchdog alarm — keeps a run observable across hibernation\n // ===========================================================================\n\n override async alarm(): Promise<void> {\n try {\n // Through the log (not a raw `rec:` list) so legacy records are migrated\n // on the way out — the storage layout is the log's private concern.\n const runs = await this.log.list()\n const now = Date.now()\n let active = false\n for (const record of runs) {\n if (isTerminalRunStatus(record.status)) continue\n if (now - record.updatedAt > WATCHDOG_STALL_MS) {\n // No progress for too long — the driver is presumed dead. Fail the run\n // so tailing clients stop waiting forever (the whole point of the\n // watchdog; without this a stuck run sits at `running` indefinitely).\n await this.failStalledRun(record.runId)\n } else {\n active = true\n }\n }\n if (active) await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS)\n } catch (error) {\n // Never let the watchdog die silently: a transient storage error must not\n // permanently disable liveness detection. Re-arm and try again next tick.\n console.error('[sandbox-coordinator] watchdog alarm failed:', error)\n await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS)\n }\n }\n\n /** Mark a stalled (orchestrator-presumed-dead) run as a terminal error. */\n private async failStalledRun(runId: string): Promise<void> {\n const message = 'run watchdog: no progress; orchestrator presumed dead'\n try {\n await this.log.append(runId, { type: EventType.RUN_ERROR, message })\n } catch {\n // The run may have just reached terminal concurrently; finish is idempotent.\n }\n await this.log.finish(runId, 'failed', { message })\n this.onRunSettled(runId)\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"coordinator.js","names":[],"sources":["../../src/coordinator.ts"],"sourcesContent":["/**\n * `SandboxCoordinator` — the abstract Durable Object base for the serverless/\n * edge agent run model. It owns everything the two concrete models share:\n *\n * - a durable, resumable run-log ({@link DurableObjectRunEventLog});\n * - `startRun`: open the run, kick off the model's chunk stream WITHOUT blocking\n * the trigger, start piping it into the log via {@link RunController}, register\n * the resulting `done` promise with `ctx.waitUntil` (keeping the instance alive\n * until the run is terminal rather than letting it hibernate mid-run), and arm\n * a watchdog alarm;\n * - `status` (poll fallback) + a hibernatable WebSocket tail with a resumable\n * cursor (replay after `lastSeq`, then live-tail, reconnect-safe);\n * - routing for `GET /runs/:id` and `GET /runs/:id/stream`, delegating any other\n * path to {@link handleRoute} (which a subclass overrides for e.g. `/_bridge`\n * or `/tool-exec`).\n *\n * Subclasses implement {@link buildRunStream} — the ONE difference between the\n * models: run `chat()` in the DO ({@link ChatSandboxCoordinator}) or drive an\n * in-container runner ({@link ContainerSandboxCoordinator}).\n *\n * NOTE: Workers-runtime code — compiles against `@cloudflare/workers-types`; not\n * runtime-verified in this repo.\n */\nimport { DurableObject } from 'cloudflare:workers'\nimport { EventType, isTerminalRunStatus } from '@tanstack/ai'\n// The PORTABLE run driver: this coordinator is a platform binding of core's\n// `RunController`, not a driver of its own. The DO run log backs both of the\n// driver's seams through the adapters in './durability' — `runLogStore` for\n// the lifecycle record, `runLogStream` for the per-run event log — and the\n// vocabulary is core's throughout (historical `done`/`error` records are\n// migrated on read; see './run-log').\nimport { RunController } from '@tanstack/ai-sandbox'\nimport { runLogStore, runLogStream } from './durability'\nimport { hasInFlightCallback } from './coordinator-callbacks'\nimport { DurableObjectRunEventLog } from './run-log-do'\nimport type { ModelMessage, StreamChunk } from '@tanstack/ai'\nimport type { RunLogRecord } from './run-log'\n\n/** Upper bound on the watchdog check interval while a run is in flight (ms). */\nconst WATCHDOG_MS = 30_000\n\n/** Default permitted period without persisted run activity (ms). */\nconst DEFAULT_STALL_TIMEOUT_MS = 5 * 60_000\n\n/** @internal Shared validation for direct subclasses and the eager factory path. */\nexport function normalizeStallTimeoutMs(\n stallTimeoutMs: number | false | undefined,\n): number | false {\n if (stallTimeoutMs === undefined) return DEFAULT_STALL_TIMEOUT_MS\n if (stallTimeoutMs === false) return false\n if (!Number.isSafeInteger(stallTimeoutMs) || stallTimeoutMs <= 0) {\n throw new TypeError(\n 'stallTimeoutMs must be a positive safe integer or false',\n )\n }\n return stallTimeoutMs\n}\n\n/** What the Worker hands the coordinator to start a run. */\nexport interface StartRunInput {\n runId: string\n threadId: string\n messages: Array<ModelMessage>\n /**\n * The host the `POST /runs` trigger request arrived on, captured by the Worker\n * (`new URL(request.url).host`). Used to derive the container's callback hosts\n * when `PUBLIC_HOSTNAME` / `PREVIEW_HOSTNAME` are not set — see\n * {@link resolveBridgeOrigin} / {@link resolvePreviewHost} for the rules (and the\n * Cloudflare-specific reason request-derivation is safe to trust).\n */\n publicHost?: string\n /**\n * Free-form per-run input forwarded verbatim from the trigger to the app's\n * `adapter` / `sandbox` / `tools` resolvers (it reaches them through `config`\n * unchanged; it is NOT persisted to the run-log). Use it to carry browser-chosen\n * run options the base trigger has no field for — e.g. which harness to run, or a\n * model id. The package never inspects it; the app validates whatever it reads.\n */\n metadata?: Record<string, unknown>\n}\n\n// Host resolvers live in their own (Workers-free) module so they stay pure and\n// unit-testable; re-exported here because the coordinators build their callback\n// URLs with them. `resolveBridgeOrigin` = container→Worker (/_bridge, /tool-exec);\n// `resolvePreviewHost` = browser→container previews. See their docstrings.\nexport { resolveBridgeOrigin, resolvePreviewHost } from './public-host'\n\n/** Cursor stashed on each hibernatable WebSocket so it survives eviction. */\ninterface SocketAttachment {\n runId: string\n lastSeq: number\n}\n\nfunction isSocketAttachment(value: unknown): value is SocketAttachment {\n return (\n value !== null &&\n typeof value === 'object' &&\n 'runId' in value &&\n typeof value.runId === 'string' &&\n 'lastSeq' in value &&\n typeof value.lastSeq === 'number'\n )\n}\n\nexport abstract class SandboxCoordinator<\n TEnv = unknown,\n> extends DurableObject<TEnv> {\n protected readonly log: DurableObjectRunEventLog\n protected readonly controller: RunController\n\n /**\n * Sockets with a live {@link pump} loop. Guards against a second concurrent\n * pump on the same socket: `acceptStream` starts one, and `webSocketMessage`\n * would start another on any inbound client message while the first is still\n * running — double-delivering events and racing the persisted cursor.\n */\n private readonly pumping = new WeakSet<WebSocket>()\n private readonly stallTimeoutMs: number | false\n\n constructor(\n ctx: DurableObjectState,\n env: TEnv,\n stallTimeoutMs?: number | false,\n ) {\n super(ctx, env)\n this.stallTimeoutMs = normalizeStallTimeoutMs(stallTimeoutMs)\n this.log = new DurableObjectRunEventLog(ctx.storage)\n this.controller = new RunController({\n runs: runLogStore(this.log),\n durability: (runId) => runLogStream(this.log, { runId }),\n })\n }\n\n // ===========================================================================\n // Subclass seam\n // ===========================================================================\n\n /**\n * Produce the run's `StreamChunk` stream. The ONE model-specific method:\n * `ChatSandboxCoordinator` runs `chat()` here; `ContainerSandboxCoordinator`\n * drives the in-container runner. Lazily consumed by the run driver, so any\n * setup (mint a token, start a container) can happen at the top.\n */\n protected abstract buildRunStream(\n input: StartRunInput,\n ): AsyncIterable<StreamChunk> | Promise<AsyncIterable<StreamChunk>>\n\n /** Extra fetch routes a subclass serves (e.g. `/_bridge`, `/tool-exec`). */\n protected handleRoute(\n _request: Request,\n _parts: Array<string>,\n ): Promise<Response> | Response {\n return new Response('not found', { status: 404 })\n }\n\n /** Called once a run reaches a terminal status (override to clean up state). */\n protected onRunSettled(_runId: string): void {}\n\n protected jsonResponse(body: unknown, status = 200): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { 'content-type': 'application/json' },\n })\n }\n\n // ===========================================================================\n // Trigger (called by the Worker; returns immediately)\n // ===========================================================================\n\n async startRun(input: StartRunInput): Promise<{ runId: string }> {\n const existing = await this.log.get(input.runId)\n if (existing) {\n if (!isTerminalRunStatus(existing.status)) await this.armWatchdog()\n return { runId: input.runId }\n }\n\n await this.armWatchdog()\n\n // Open the run BEFORE building the stream. `pipeToRunLog`'s never-rejects\n // guarantee only covers failures AFTER the stream is handed to it — a throw\n // while BUILDING the stream (config(), chat() validation, mint a token)\n // would otherwise leave no record and no terminal event, so a tailing client\n // would never see the failure. Opening here (idempotent with pipeToRunLog's\n // own open) lets us record it.\n await this.log.open({ runId: input.runId, threadId: input.threadId })\n let stream: AsyncIterable<StreamChunk>\n try {\n stream = await this.buildRunStream(input)\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n await this.log.append(input.runId, {\n type: EventType.RUN_ERROR,\n message,\n })\n await this.log.finish(input.runId, 'failed', { message })\n this.onRunSettled(input.runId)\n return { runId: input.runId }\n }\n\n const { done } = this.controller.start({\n runId: input.runId,\n threadId: input.threadId,\n stream,\n })\n // Keep the instance alive until the run is terminal. `pipeToRunLog` never\n // rejects (failures land in the log), but this must not DEPEND on that:\n // `.finally` adopts a rejection, which would hand `waitUntil` a rejected\n // promise. Two-argument `then` settles fulfilled either way while still\n // running the settle hook.\n const settle = (): void => this.onRunSettled(input.runId)\n this.ctx.waitUntil(done.then(settle, settle))\n return { runId: input.runId }\n }\n\n async status(runId: string): Promise<RunLogRecord | null> {\n // Straight off the log (not `controller.status`) so the answer keeps the\n // log-level fields (`lastSeq`) a reconnecting client resumes from.\n return this.log.get(runId)\n }\n\n // ===========================================================================\n // HTTP surface\n // ===========================================================================\n\n override async fetch(request: Request): Promise<Response> {\n const url = new URL(request.url)\n const parts = url.pathname.split('/').filter(Boolean)\n\n if (parts[0] === 'runs' && typeof parts[1] === 'string') {\n if (parts[2] === 'stream') return this.acceptStream(parts[1], request)\n if (parts.length === 2 && request.method === 'GET') {\n const record = await this.status(parts[1])\n return record\n ? this.jsonResponse(record)\n : this.jsonResponse({ error: 'unknown run' }, 404)\n }\n }\n return this.handleRoute(request, parts)\n }\n\n // ===========================================================================\n // WebSocket streaming with hibernation + resumable cursor\n // ===========================================================================\n\n private async acceptStream(\n runId: string,\n request: Request,\n ): Promise<Response> {\n if (request.headers.get('upgrade') !== 'websocket') {\n return new Response('expected websocket upgrade', { status: 426 })\n }\n const record = await this.log.get(runId)\n if (!record) return new Response('unknown run', { status: 404 })\n\n const url = new URL(request.url)\n const lastSeqParam = url.searchParams.get('lastSeq')\n const lastSeq =\n lastSeqParam !== null ? Number.parseInt(lastSeqParam, 10) : -1\n if (Number.isNaN(lastSeq)) {\n return new Response('lastSeq must be an integer', { status: 400 })\n }\n\n const pair = new WebSocketPair()\n const [client, server] = [pair[0], pair[1]]\n server.serializeAttachment({ runId, lastSeq } satisfies SocketAttachment)\n this.ctx.acceptWebSocket(server)\n this.pump(server, runId, lastSeq)\n\n return new Response(null, { status: 101, webSocket: client })\n }\n\n /**\n * Replay-then-tail loop for one socket. Each delivered event advances the\n * socket's persisted cursor so a mid-stream reconnect resumes exactly once.\n * No-ops if a pump is already running for this socket (see {@link pumping}).\n */\n private pump(socket: WebSocket, runId: string, fromSeq: number): void {\n if (this.pumping.has(socket)) return\n this.pumping.add(socket)\n const done = (async () => {\n try {\n // The tail reads the log directly by seq — the client wire protocol\n // (`?lastSeq`, `{seq, chunk}` frames) is seq-based, and `log.read` is\n // the seq-cursor surface. Core's `controller.attach` serves consumers\n // that speak opaque `StreamDurability` offsets instead.\n for await (const event of this.log.read(runId, { fromSeq })) {\n socket.send(JSON.stringify(event))\n socket.serializeAttachment({\n runId,\n lastSeq: event.seq,\n } satisfies SocketAttachment)\n }\n const record = await this.log.get(runId)\n if (socket.readyState === WebSocket.OPEN) {\n socket.send(JSON.stringify({ type: 'status', record }))\n socket.close(1000, 'run complete')\n }\n } catch (error) {\n // A tail loop throwing means a run-log read failed — an operator needs\n // the full error, but the client only gets a truncated close reason.\n const message = error instanceof Error ? error.message : String(error)\n console.error(\n `[sandbox-coordinator] tail failed for run ${runId}:`,\n error,\n )\n if (socket.readyState === WebSocket.OPEN) {\n socket.close(1011, message.slice(0, 120))\n }\n } finally {\n this.pumping.delete(socket)\n }\n })()\n this.ctx.waitUntil(done)\n }\n\n override webSocketMessage(\n ws: WebSocket,\n _message: string | ArrayBuffer,\n ): void {\n // Only meaningful as a post-hibernation resume nudge: restart the tail from\n // the persisted cursor IF no pump is live (the guard in `pump` enforces the\n // \"resume exactly once\" invariant when the original pump is still running).\n const attachment: unknown = ws.deserializeAttachment()\n if (isSocketAttachment(attachment)) {\n this.pump(ws, attachment.runId, attachment.lastSeq)\n }\n }\n\n override webSocketClose(\n _ws: WebSocket,\n _code: number,\n _reason: string,\n ): void {\n // Nothing to clean up: the run-log is durable and independent of any socket.\n }\n\n // ===========================================================================\n // Watchdog alarm — keeps a run observable across hibernation\n // ===========================================================================\n\n override async alarm(): Promise<void> {\n // A previously configured alarm may still be delivered once after watchdogs\n // are disabled. It must self-extinguish before reading storage or entering a\n // catch path that could re-arm it. Deliberately do not call deleteAlarm().\n if (this.stallTimeoutMs === false) return\n\n try {\n // Through the log (not a raw `rec:` list) so legacy records are migrated\n // on the way out — the storage layout is the log's private concern.\n const runs = await this.log.list()\n const cutoff = Date.now() - this.stallTimeoutMs\n let active = false\n for (const record of runs) {\n if (isTerminalRunStatus(record.status)) continue\n if (\n hasInFlightCallback(this, record.runId) ||\n record.updatedAt >= cutoff\n ) {\n active = true\n continue\n }\n\n // Re-check and terminalize atomically against the same strict cutoff.\n // A callback touch or normal completion racing this alarm wins cleanly.\n const finished = await this.failStalledRun(record.runId, cutoff)\n if (finished) this.onRunSettled(record.runId)\n else active = true\n }\n if (active) await this.armWatchdog()\n } catch (error) {\n // Never let the watchdog die silently: a transient storage error must not\n // permanently disable liveness detection. Re-arm and try again next tick.\n console.error('[sandbox-coordinator] watchdog alarm failed:', error)\n await this.armWatchdog()\n }\n }\n\n /** Arm without allowing a new run to postpone an earlier pending check. */\n private async armWatchdog(): Promise<void> {\n if (this.stallTimeoutMs === false) return\n const next = Date.now() + Math.min(WATCHDOG_MS, this.stallTimeoutMs)\n const pending = await this.ctx.storage.getAlarm()\n if (pending === null || pending > next) {\n await this.ctx.storage.setAlarm(next)\n }\n }\n\n /** Mark a strictly stale run as a terminal error if it still qualifies. */\n private failStalledRun(runId: string, cutoff: number): Promise<boolean> {\n const message = 'run watchdog: no progress; orchestrator presumed dead'\n return this.log.finishIfStale(runId, cutoff, {\n type: EventType.RUN_ERROR,\n message,\n })\n }\n}\n"],"mappings":"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"}
|
package/dist/esm/factory.d.ts
CHANGED
|
@@ -27,6 +27,11 @@ export interface SandboxAgentEnv extends ChatCoordinatorEnv, ContainerCoordinato
|
|
|
27
27
|
interface BaseAgentConfig<TEnv extends SandboxAgentEnv> {
|
|
28
28
|
/** chat()-provided server tools, resolved per run (DO-drives: bridged over MCP). */
|
|
29
29
|
tools?: (input: StartRunInput, env: TEnv) => Array<AnyTool>;
|
|
30
|
+
/**
|
|
31
|
+
* Fail a run after this many milliseconds without persisted activity. Omitted
|
|
32
|
+
* defaults to five minutes; `false` disables the watchdog.
|
|
33
|
+
*/
|
|
34
|
+
stallTimeoutMs?: number | false;
|
|
30
35
|
}
|
|
31
36
|
/** DO-drives config: the DO runs `chat()` with the given adapter. */
|
|
32
37
|
export interface DoDrivesAgentConfig<TEnv extends SandboxAgentEnv> extends BaseAgentConfig<TEnv> {
|