@tanstack/ai-sandbox-cloudflare 0.1.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.
Files changed (61) hide show
  1. package/dist/esm/agent.d.ts +30 -0
  2. package/dist/esm/agent.js +25 -0
  3. package/dist/esm/agent.js.map +1 -0
  4. package/dist/esm/chat-coordinator.d.ts +75 -0
  5. package/dist/esm/chat-coordinator.js +135 -0
  6. package/dist/esm/chat-coordinator.js.map +1 -0
  7. package/dist/esm/container-coordinator.d.ts +114 -0
  8. package/dist/esm/container-coordinator.js +256 -0
  9. package/dist/esm/container-coordinator.js.map +1 -0
  10. package/dist/esm/coordinator.d.ts +68 -0
  11. package/dist/esm/coordinator.js +188 -0
  12. package/dist/esm/coordinator.js.map +1 -0
  13. package/dist/esm/factory.d.ts +80 -0
  14. package/dist/esm/factory.js +69 -0
  15. package/dist/esm/factory.js.map +1 -0
  16. package/dist/esm/handle.d.ts +23 -0
  17. package/dist/esm/handle.js +208 -0
  18. package/dist/esm/handle.js.map +1 -0
  19. package/dist/esm/index.d.ts +4 -0
  20. package/dist/esm/index.js +10 -0
  21. package/dist/esm/index.js.map +1 -0
  22. package/dist/esm/preview-tool.d.ts +31 -0
  23. package/dist/esm/preview-tool.js +37 -0
  24. package/dist/esm/preview-tool.js.map +1 -0
  25. package/dist/esm/protocol.d.ts +42 -0
  26. package/dist/esm/protocol.js +64 -0
  27. package/dist/esm/protocol.js.map +1 -0
  28. package/dist/esm/provider.d.ts +31 -0
  29. package/dist/esm/provider.js +65 -0
  30. package/dist/esm/provider.js.map +1 -0
  31. package/dist/esm/public-host.d.ts +67 -0
  32. package/dist/esm/public-host.js +49 -0
  33. package/dist/esm/public-host.js.map +1 -0
  34. package/dist/esm/run-log-do.d.ts +25 -0
  35. package/dist/esm/run-log-do.js +122 -0
  36. package/dist/esm/run-log-do.js.map +1 -0
  37. package/dist/esm/runner.d.ts +32 -0
  38. package/dist/esm/runner.js +107 -0
  39. package/dist/esm/runner.js.map +1 -0
  40. package/dist/esm/web-crypto.d.ts +11 -0
  41. package/dist/esm/web-crypto.js +18 -0
  42. package/dist/esm/web-crypto.js.map +1 -0
  43. package/dist/esm/worker.d.ts +8 -0
  44. package/dist/esm/worker.js +83 -0
  45. package/dist/esm/worker.js.map +1 -0
  46. package/package.json +74 -0
  47. package/src/agent.ts +66 -0
  48. package/src/chat-coordinator.ts +253 -0
  49. package/src/container-coordinator.ts +437 -0
  50. package/src/coordinator.ts +338 -0
  51. package/src/factory.ts +225 -0
  52. package/src/handle.ts +292 -0
  53. package/src/index.ts +5 -0
  54. package/src/preview-tool.ts +110 -0
  55. package/src/protocol.ts +171 -0
  56. package/src/provider.ts +111 -0
  57. package/src/public-host.ts +121 -0
  58. package/src/run-log-do.ts +171 -0
  59. package/src/runner.ts +226 -0
  60. package/src/web-crypto.ts +31 -0
  61. package/src/worker.ts +173 -0
@@ -0,0 +1,338 @@
1
+ /**
2
+ * `SandboxCoordinator` — the abstract Durable Object base for the serverless/
3
+ * edge agent run model. It owns everything the two concrete models share:
4
+ *
5
+ * - a durable, resumable run-log ({@link DurableObjectRunEventLog});
6
+ * - `startRun`: open the run, kick off the model's chunk stream WITHOUT blocking
7
+ * the trigger, start piping it into the log via {@link RunController}, register
8
+ * the resulting `done` promise with `ctx.waitUntil` (keeping the instance alive
9
+ * until the run is terminal rather than letting it hibernate mid-run), and arm
10
+ * a watchdog alarm;
11
+ * - `status` (poll fallback) + a hibernatable WebSocket tail with a resumable
12
+ * cursor (replay after `lastSeq`, then live-tail, reconnect-safe);
13
+ * - routing for `GET /runs/:id` and `GET /runs/:id/stream`, delegating any other
14
+ * path to {@link handleRoute} (which a subclass overrides for e.g. `/_bridge`
15
+ * or `/tool-exec`).
16
+ *
17
+ * Subclasses implement {@link buildRunStream} — the ONE difference between the
18
+ * models: run `chat()` in the DO ({@link ChatSandboxCoordinator}) or drive an
19
+ * in-container runner ({@link ContainerSandboxCoordinator}).
20
+ *
21
+ * NOTE: Workers-runtime code — compiles against `@cloudflare/workers-types`; not
22
+ * runtime-verified in this repo.
23
+ */
24
+ import { DurableObject } from 'cloudflare:workers'
25
+ import { EventType } from '@tanstack/ai'
26
+ import { RunController, isTerminalRunStatus } from '@tanstack/ai-sandbox'
27
+ import { DurableObjectRunEventLog } from './run-log-do'
28
+ import type { ModelMessage, StreamChunk } from '@tanstack/ai'
29
+ import type { RunRecord } from '@tanstack/ai-sandbox'
30
+
31
+ /** Re-arm window for the liveness watchdog while a run is in flight (ms). */
32
+ const WATCHDOG_MS = 30_000
33
+
34
+ /**
35
+ * How long a non-terminal run may go without ANY new event before the watchdog
36
+ * presumes the orchestrator driving it is dead (eviction that lost the
37
+ * `waitUntil` promise, an uncaught fault, a hung container) and fails the run so
38
+ * tailing clients stop waiting forever. Generous so a legitimately slow agent
39
+ * step (a long tool call that emits no chunks) is not killed prematurely.
40
+ */
41
+ const WATCHDOG_STALL_MS = 5 * 60_000
42
+
43
+ /** What the Worker hands the coordinator to start a run. */
44
+ export interface StartRunInput {
45
+ runId: string
46
+ threadId: string
47
+ messages: Array<ModelMessage>
48
+ /**
49
+ * The host the `POST /runs` trigger request arrived on, captured by the Worker
50
+ * (`new URL(request.url).host`). Used to derive the container's callback hosts
51
+ * when `PUBLIC_HOSTNAME` / `PREVIEW_HOSTNAME` are not set — see
52
+ * {@link resolveBridgeOrigin} / {@link resolvePreviewHost} for the rules (and the
53
+ * Cloudflare-specific reason request-derivation is safe to trust).
54
+ */
55
+ publicHost?: string
56
+ /**
57
+ * Free-form per-run input forwarded verbatim from the trigger to the app's
58
+ * `adapter` / `sandbox` / `tools` resolvers (it reaches them through `config`
59
+ * unchanged; it is NOT persisted to the run-log). Use it to carry browser-chosen
60
+ * run options the base trigger has no field for — e.g. which harness to run, or a
61
+ * model id. The package never inspects it; the app validates whatever it reads.
62
+ */
63
+ metadata?: Record<string, unknown>
64
+ }
65
+
66
+ // Host resolvers live in their own (Workers-free) module so they stay pure and
67
+ // unit-testable; re-exported here because the coordinators build their callback
68
+ // URLs with them. `resolveBridgeOrigin` = container→Worker (/_bridge, /tool-exec);
69
+ // `resolvePreviewHost` = browser→container previews. See their docstrings.
70
+ export { resolveBridgeOrigin, resolvePreviewHost } from './public-host'
71
+
72
+ /** Cursor stashed on each hibernatable WebSocket so it survives eviction. */
73
+ interface SocketAttachment {
74
+ runId: string
75
+ lastSeq: number
76
+ }
77
+
78
+ function isSocketAttachment(value: unknown): value is SocketAttachment {
79
+ return (
80
+ value !== null &&
81
+ typeof value === 'object' &&
82
+ 'runId' in value &&
83
+ typeof value.runId === 'string' &&
84
+ 'lastSeq' in value &&
85
+ typeof value.lastSeq === 'number'
86
+ )
87
+ }
88
+
89
+ export abstract class SandboxCoordinator<
90
+ TEnv = unknown,
91
+ > extends DurableObject<TEnv> {
92
+ protected readonly log: DurableObjectRunEventLog
93
+ protected readonly controller: RunController
94
+
95
+ /**
96
+ * Sockets with a live {@link pump} loop. Guards against a second concurrent
97
+ * pump on the same socket: `acceptStream` starts one, and `webSocketMessage`
98
+ * would start another on any inbound client message while the first is still
99
+ * running — double-delivering events and racing the persisted cursor.
100
+ */
101
+ private readonly pumping = new WeakSet<WebSocket>()
102
+
103
+ constructor(ctx: DurableObjectState, env: TEnv) {
104
+ super(ctx, env)
105
+ this.log = new DurableObjectRunEventLog(ctx.storage)
106
+ this.controller = new RunController(this.log)
107
+ }
108
+
109
+ // ===========================================================================
110
+ // Subclass seam
111
+ // ===========================================================================
112
+
113
+ /**
114
+ * Produce the run's `StreamChunk` stream. The ONE model-specific method:
115
+ * `ChatSandboxCoordinator` runs `chat()` here; `ContainerSandboxCoordinator`
116
+ * drives the in-container runner. Lazily consumed by the run driver, so any
117
+ * setup (mint a token, start a container) can happen at the top.
118
+ */
119
+ protected abstract buildRunStream(
120
+ input: StartRunInput,
121
+ ): AsyncIterable<StreamChunk> | Promise<AsyncIterable<StreamChunk>>
122
+
123
+ /** Extra fetch routes a subclass serves (e.g. `/_bridge`, `/tool-exec`). */
124
+ protected handleRoute(
125
+ _request: Request,
126
+ _parts: Array<string>,
127
+ ): Promise<Response> | Response {
128
+ return new Response('not found', { status: 404 })
129
+ }
130
+
131
+ /** Called once a run reaches a terminal status (override to clean up state). */
132
+ protected onRunSettled(_runId: string): void {}
133
+
134
+ protected jsonResponse(body: unknown, status = 200): Response {
135
+ return new Response(JSON.stringify(body), {
136
+ status,
137
+ headers: { 'content-type': 'application/json' },
138
+ })
139
+ }
140
+
141
+ // ===========================================================================
142
+ // Trigger (called by the Worker; returns immediately)
143
+ // ===========================================================================
144
+
145
+ async startRun(input: StartRunInput): Promise<{ runId: string }> {
146
+ const existing = await this.log.get(input.runId)
147
+ if (existing) return { runId: input.runId } // idempotent re-trigger
148
+
149
+ // Open the run BEFORE building the stream. `pipeToRunLog`'s never-rejects
150
+ // guarantee only covers failures AFTER the stream is handed to it — a throw
151
+ // while BUILDING the stream (config(), chat() validation, mint a token)
152
+ // would otherwise leave no record and no terminal event, so a tailing client
153
+ // would never see the failure. Opening here (idempotent with pipeToRunLog's
154
+ // own open) lets us record it.
155
+ await this.log.open({ runId: input.runId, threadId: input.threadId })
156
+ let stream: AsyncIterable<StreamChunk>
157
+ try {
158
+ stream = await this.buildRunStream(input)
159
+ } catch (error) {
160
+ const message = error instanceof Error ? error.message : String(error)
161
+ await this.log.append(input.runId, {
162
+ type: EventType.RUN_ERROR,
163
+ message,
164
+ })
165
+ await this.log.finish(input.runId, 'error', { message })
166
+ this.onRunSettled(input.runId)
167
+ return { runId: input.runId }
168
+ }
169
+
170
+ const { done } = this.controller.start({
171
+ runId: input.runId,
172
+ threadId: input.threadId,
173
+ stream,
174
+ })
175
+ // Keep the instance alive until the run is terminal; `pipeToRunLog` never
176
+ // rejects (failures land in the log), so no `.catch` is needed.
177
+ this.ctx.waitUntil(done.finally(() => this.onRunSettled(input.runId)))
178
+ await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS)
179
+ return { runId: input.runId }
180
+ }
181
+
182
+ async status(runId: string): Promise<RunRecord | null> {
183
+ return this.controller.status(runId)
184
+ }
185
+
186
+ // ===========================================================================
187
+ // HTTP surface
188
+ // ===========================================================================
189
+
190
+ override async fetch(request: Request): Promise<Response> {
191
+ const url = new URL(request.url)
192
+ const parts = url.pathname.split('/').filter(Boolean)
193
+
194
+ if (parts[0] === 'runs' && typeof parts[1] === 'string') {
195
+ if (parts[2] === 'stream') return this.acceptStream(parts[1], request)
196
+ if (parts.length === 2 && request.method === 'GET') {
197
+ const record = await this.status(parts[1])
198
+ return record
199
+ ? this.jsonResponse(record)
200
+ : this.jsonResponse({ error: 'unknown run' }, 404)
201
+ }
202
+ }
203
+ return this.handleRoute(request, parts)
204
+ }
205
+
206
+ // ===========================================================================
207
+ // WebSocket streaming with hibernation + resumable cursor
208
+ // ===========================================================================
209
+
210
+ private async acceptStream(
211
+ runId: string,
212
+ request: Request,
213
+ ): Promise<Response> {
214
+ if (request.headers.get('upgrade') !== 'websocket') {
215
+ return new Response('expected websocket upgrade', { status: 426 })
216
+ }
217
+ const record = await this.log.get(runId)
218
+ if (!record) return new Response('unknown run', { status: 404 })
219
+
220
+ const url = new URL(request.url)
221
+ const lastSeqParam = url.searchParams.get('lastSeq')
222
+ const lastSeq =
223
+ lastSeqParam !== null ? Number.parseInt(lastSeqParam, 10) : -1
224
+ if (Number.isNaN(lastSeq)) {
225
+ return new Response('lastSeq must be an integer', { status: 400 })
226
+ }
227
+
228
+ const pair = new WebSocketPair()
229
+ const [client, server] = [pair[0], pair[1]]
230
+ server.serializeAttachment({ runId, lastSeq } satisfies SocketAttachment)
231
+ this.ctx.acceptWebSocket(server)
232
+ this.pump(server, runId, lastSeq)
233
+
234
+ return new Response(null, { status: 101, webSocket: client })
235
+ }
236
+
237
+ /**
238
+ * Replay-then-tail loop for one socket. Each delivered event advances the
239
+ * socket's persisted cursor so a mid-stream reconnect resumes exactly once.
240
+ * No-ops if a pump is already running for this socket (see {@link pumping}).
241
+ */
242
+ private pump(socket: WebSocket, runId: string, fromSeq: number): void {
243
+ if (this.pumping.has(socket)) return
244
+ this.pumping.add(socket)
245
+ const done = (async () => {
246
+ try {
247
+ for await (const event of this.controller.attach(runId, { fromSeq })) {
248
+ socket.send(JSON.stringify(event))
249
+ socket.serializeAttachment({
250
+ runId,
251
+ lastSeq: event.seq,
252
+ } satisfies SocketAttachment)
253
+ }
254
+ const record = await this.log.get(runId)
255
+ if (socket.readyState === WebSocket.OPEN) {
256
+ socket.send(JSON.stringify({ type: 'status', record }))
257
+ socket.close(1000, 'run complete')
258
+ }
259
+ } catch (error) {
260
+ // A tail loop throwing means a run-log read failed — an operator needs
261
+ // the full error, but the client only gets a truncated close reason.
262
+ const message = error instanceof Error ? error.message : String(error)
263
+ console.error(
264
+ `[sandbox-coordinator] tail failed for run ${runId}:`,
265
+ error,
266
+ )
267
+ if (socket.readyState === WebSocket.OPEN) {
268
+ socket.close(1011, message.slice(0, 120))
269
+ }
270
+ } finally {
271
+ this.pumping.delete(socket)
272
+ }
273
+ })()
274
+ this.ctx.waitUntil(done)
275
+ }
276
+
277
+ override webSocketMessage(
278
+ ws: WebSocket,
279
+ _message: string | ArrayBuffer,
280
+ ): void {
281
+ // Only meaningful as a post-hibernation resume nudge: restart the tail from
282
+ // the persisted cursor IF no pump is live (the guard in `pump` enforces the
283
+ // "resume exactly once" invariant when the original pump is still running).
284
+ const attachment: unknown = ws.deserializeAttachment()
285
+ if (isSocketAttachment(attachment)) {
286
+ this.pump(ws, attachment.runId, attachment.lastSeq)
287
+ }
288
+ }
289
+
290
+ override webSocketClose(
291
+ _ws: WebSocket,
292
+ _code: number,
293
+ _reason: string,
294
+ ): void {
295
+ // Nothing to clean up: the run-log is durable and independent of any socket.
296
+ }
297
+
298
+ // ===========================================================================
299
+ // Watchdog alarm — keeps a run observable across hibernation
300
+ // ===========================================================================
301
+
302
+ override async alarm(): Promise<void> {
303
+ try {
304
+ const runs = await this.ctx.storage.list<RunRecord>({ prefix: 'rec:' })
305
+ const now = Date.now()
306
+ let active = false
307
+ for (const record of runs.values()) {
308
+ if (isTerminalRunStatus(record.status)) continue
309
+ if (now - record.updatedAt > WATCHDOG_STALL_MS) {
310
+ // No progress for too long — the driver is presumed dead. Fail the run
311
+ // so tailing clients stop waiting forever (the whole point of the
312
+ // watchdog; without this a stuck run sits at `running` indefinitely).
313
+ await this.failStalledRun(record.runId)
314
+ } else {
315
+ active = true
316
+ }
317
+ }
318
+ if (active) await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS)
319
+ } catch (error) {
320
+ // Never let the watchdog die silently: a transient storage error must not
321
+ // permanently disable liveness detection. Re-arm and try again next tick.
322
+ console.error('[sandbox-coordinator] watchdog alarm failed:', error)
323
+ await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS)
324
+ }
325
+ }
326
+
327
+ /** Mark a stalled (orchestrator-presumed-dead) run as a terminal error. */
328
+ private async failStalledRun(runId: string): Promise<void> {
329
+ const message = 'run watchdog: no progress; orchestrator presumed dead'
330
+ try {
331
+ await this.log.append(runId, { type: EventType.RUN_ERROR, message })
332
+ } catch {
333
+ // The run may have just reached terminal concurrently; finish is idempotent.
334
+ }
335
+ await this.log.finish(runId, 'error', { message })
336
+ this.onRunSettled(runId)
337
+ }
338
+ }
package/src/factory.ts ADDED
@@ -0,0 +1,225 @@
1
+ /**
2
+ * `createCloudflareSandboxAgent` — the headline DX: one configured function call
3
+ * returns the Durable Object coordinator, the Sandbox DO, and the Worker fetch
4
+ * handler, so a Cloudflare app's whole `worker.ts` is just export wiring:
5
+ *
6
+ * ```ts
7
+ * const agent = createCloudflareSandboxAgent({
8
+ * adapter: () => claudeCodeText('sonnet'),
9
+ * })
10
+ * export const RunCoordinator = agent.Coordinator
11
+ * export const Sandbox = agent.Sandbox
12
+ * export default agent.worker
13
+ * ```
14
+ *
15
+ * Two modes, switched by `config.mode`:
16
+ * - `'do-drives'` (default) → a {@link ChatSandboxCoordinator}: the DO runs
17
+ * `chat()` itself and hosts the MCP tool-bridge.
18
+ * - `'colocated'` → a {@link ContainerSandboxCoordinator}: an in-container
19
+ * runner runs `chat()`; the DO is a thin coordinator that executes host tools.
20
+ *
21
+ * Env bindings (set in `wrangler.jsonc`):
22
+ * - `RUN_COORDINATOR` — this coordinator DO's own namespace (so the Worker can
23
+ * address it by `threadId`). Class name: whatever you export `Coordinator` as.
24
+ * - `Sandbox` — the `@cloudflare/sandbox` Sandbox DO namespace (the container
25
+ * hosts). Bind the exported `Sandbox` class.
26
+ * - `PUBLIC_HOSTNAME` — OPTIONAL. Hostname the CONTAINER uses to reach the Worker's
27
+ * tool-bridge / tool-exec endpoint. Unset → request-derived (local dev →
28
+ * `host.docker.internal`). See `resolveBridgeOrigin`.
29
+ * - `PREVIEW_HOSTNAME` — OPTIONAL. Custom domain (with a `*.<domain>` route) for
30
+ * browser-facing `exposePort` preview URLs. Unset → request-derived (local dev →
31
+ * `localhost`); REQUIRED on a `*.workers.dev` deploy, which has no wildcard
32
+ * subdomains. See `resolvePreviewHost`.
33
+ * - The harness's API key (`ANTHROPIC_API_KEY` for Claude Code, `CODEX_API_KEY` for
34
+ * codex, …) — supplied by YOUR app, never by the package. Declare it as a secret
35
+ * on the run's workspace (via a `sandbox`/`workspace` resolver) and add the field
36
+ * to your own env type; the coordinator injects each declared secret into the
37
+ * sandbox env by name. The package itself is harness-agnostic and binds no key.
38
+ *
39
+ * NOTE: Workers-runtime code — compiles against the real Cloudflare + TanStack
40
+ * AI types; not runtime-verified in this repo (no Workers runtime here).
41
+ */
42
+ import { defineSandbox, defineWorkspace } from '@tanstack/ai-sandbox'
43
+ import { Sandbox } from '@cloudflare/sandbox'
44
+ import { cloudflareSandbox } from './provider'
45
+ import { ChatSandboxCoordinator } from './chat-coordinator'
46
+ import { ContainerSandboxCoordinator } from './container-coordinator'
47
+ import { createSandboxAgentWorker } from './worker'
48
+ import { resolvePreviewHost } from './coordinator'
49
+ import type { ChatCoordinatorEnv, ChatRunConfig } from './chat-coordinator'
50
+ import type {
51
+ ContainerCoordinatorEnv,
52
+ ContainerRunConfig,
53
+ } from './container-coordinator'
54
+ import type { HarnessId } from './protocol'
55
+ import type { SandboxCoordinator, StartRunInput } from './coordinator'
56
+ import type { AnyTextAdapter, AnyTool, SystemPrompt } from '@tanstack/ai'
57
+ import type {
58
+ SandboxDefinition,
59
+ WorkspaceDefinition,
60
+ } from '@tanstack/ai-sandbox'
61
+
62
+ /**
63
+ * The base Env every generated app binds: the coordinator's own namespace, the
64
+ * Sandbox namespace, the OPTIONAL bridge/preview hostnames (request-derived when
65
+ * unset), and the Anthropic key. The two modes extend this with exactly the
66
+ * coordinator base each one requires.
67
+ */
68
+ export interface SandboxAgentEnv
69
+ extends ChatCoordinatorEnv, ContainerCoordinatorEnv {
70
+ /** This coordinator DO's own namespace (so the Worker can address it). */
71
+ RUN_COORDINATOR: DurableObjectNamespace<SandboxCoordinator<SandboxAgentEnv>>
72
+ /**
73
+ * Custom domain (with a `*.<domain>` route) for browser-facing `exposePort`
74
+ * preview URLs. Optional: unset → request-derived (local dev → `localhost`).
75
+ * REQUIRED on a `*.workers.dev` deploy (no wildcard subdomains). Distinct from
76
+ * `PUBLIC_HOSTNAME`, which is the CONTAINER→Worker bridge host. See
77
+ * {@link resolvePreviewHost}.
78
+ */
79
+ PREVIEW_HOSTNAME?: string
80
+ }
81
+
82
+ /** Shared config across both modes. */
83
+ interface BaseAgentConfig<TEnv extends SandboxAgentEnv> {
84
+ /** chat()-provided server tools, resolved per run (DO-drives: bridged over MCP). */
85
+ tools?: (input: StartRunInput, env: TEnv) => Array<AnyTool>
86
+ }
87
+
88
+ /** DO-drives config: the DO runs `chat()` with the given adapter. */
89
+ export interface DoDrivesAgentConfig<
90
+ TEnv extends SandboxAgentEnv,
91
+ > extends BaseAgentConfig<TEnv> {
92
+ mode?: 'do-drives'
93
+ /** The harness/text adapter `chat()` runs, resolved per run. */
94
+ adapter: (input: StartRunInput, env: TEnv) => AnyTextAdapter
95
+ /**
96
+ * Base system prompts prepended to every run's `chat()` (DO-drives only — the DO
97
+ * runs `chat()` itself). The natural home for transport-level guidance the agent
98
+ * needs regardless of what it builds — e.g. `systemPrompts: [PREVIEW_GUIDANCE]`
99
+ * so previews don't reload-loop. See {@link PREVIEW_GUIDANCE}.
100
+ */
101
+ systemPrompts?: Array<SystemPrompt>
102
+ /**
103
+ * The sandbox the agent runs in, resolved per run. When omitted, a default
104
+ * Cloudflare sandbox (one per thread, no source clone, NO auth secrets) is built
105
+ * from the `Sandbox` binding and the resolved preview host, optionally
106
+ * bootstrapping `workspace`. Supply the harness's API key either here (a custom
107
+ * `sandbox` resolver whose workspace declares the secret) or via `workspace`
108
+ * below — the package binds no key of its own.
109
+ */
110
+ sandbox?: (input: StartRunInput, env: TEnv) => SandboxDefinition
111
+ /**
112
+ * Workspace for the default sandbox (ignored when `sandbox` is provided). This is
113
+ * where a default-sandbox app declares its harness auth, e.g.
114
+ * `defineWorkspace({ source: { type: 'none' }, secrets: createSecrets({ ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY }) })`.
115
+ */
116
+ workspace?: WorkspaceDefinition
117
+ }
118
+
119
+ /** Co-located config: an in-container runner runs `chat()`. */
120
+ export interface ColocatedAgentConfig<
121
+ TEnv extends SandboxAgentEnv,
122
+ > extends BaseAgentConfig<TEnv> {
123
+ mode: 'colocated'
124
+ /** Which in-sandbox harness the runner spawns. */
125
+ harness: HarnessId
126
+ /** Model id passed to that harness. */
127
+ model: string
128
+ /** Workspace the in-container runner bootstraps for the agent. */
129
+ workspace: WorkspaceDefinition
130
+ }
131
+
132
+ export type CloudflareSandboxAgentConfig<TEnv extends SandboxAgentEnv> =
133
+ | DoDrivesAgentConfig<TEnv>
134
+ | ColocatedAgentConfig<TEnv>
135
+
136
+ /** What {@link createCloudflareSandboxAgent} returns: the app's whole worker. */
137
+ export interface CloudflareSandboxAgent<TEnv extends SandboxAgentEnv> {
138
+ /** The coordinator Durable Object class — export as your `RUN_COORDINATOR` binding. */
139
+ Coordinator: new (
140
+ ctx: DurableObjectState,
141
+ env: TEnv,
142
+ ) => SandboxCoordinator<TEnv>
143
+ /** The `@cloudflare/sandbox` Sandbox DO class — export for the `Sandbox` binding. */
144
+ Sandbox: typeof Sandbox
145
+ /** The Worker fetch handler — `export default` it. */
146
+ worker: ExportedHandler<TEnv>
147
+ }
148
+
149
+ /** Build the default per-thread Cloudflare sandbox for the DO-drives mode. */
150
+ function defaultSandbox<TEnv extends SandboxAgentEnv>(
151
+ env: TEnv,
152
+ input: StartRunInput,
153
+ workspace: WorkspaceDefinition | undefined,
154
+ ): SandboxDefinition {
155
+ return defineSandbox({
156
+ id: 'cf-edge-agent',
157
+ provider: cloudflareSandbox({
158
+ binding: env.Sandbox,
159
+ // Browser-facing preview host: `PREVIEW_HOSTNAME` if set, else derived from
160
+ // the trigger request (local dev → `localhost`; deployed → a custom domain,
161
+ // since `*.workers.dev` has no wildcard). See `resolvePreviewHost`.
162
+ previewHostname: resolvePreviewHost(env, input),
163
+ }),
164
+ workspace:
165
+ workspace ??
166
+ // The container image ships the harness CLI; no source to clone, and NO auth
167
+ // secrets — the package is harness-agnostic, so it can't know which key the
168
+ // CLI needs. Supply the harness's API key via a `workspace` with `secrets`
169
+ // (or a custom `sandbox` resolver), e.g.:
170
+ // workspace: defineWorkspace({
171
+ // source: { type: 'none' },
172
+ // secrets: createSecrets({ ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY }),
173
+ // })
174
+ defineWorkspace({ source: { type: 'none' } }),
175
+ // One sandbox per thread, so a follow-up run resumes the same workspace.
176
+ lifecycle: { reuse: 'thread' },
177
+ })
178
+ }
179
+
180
+ /** Resolve the coordinator DO that owns a thread's runs (`RUN_COORDINATOR`). */
181
+ function resolveCoordinator<TEnv extends SandboxAgentEnv>(
182
+ env: TEnv,
183
+ threadId: string,
184
+ ): DurableObjectStub<SandboxCoordinator<TEnv>> {
185
+ return env.RUN_COORDINATOR.get(env.RUN_COORDINATOR.idFromName(threadId))
186
+ }
187
+
188
+ export function createCloudflareSandboxAgent<
189
+ TEnv extends SandboxAgentEnv = SandboxAgentEnv,
190
+ >(config: CloudflareSandboxAgentConfig<TEnv>): CloudflareSandboxAgent<TEnv> {
191
+ const worker = createSandboxAgentWorker<TEnv>(resolveCoordinator)
192
+
193
+ if (config.mode === 'colocated') {
194
+ const colocated = config
195
+ class ConfiguredContainerCoordinator extends ContainerSandboxCoordinator<TEnv> {
196
+ protected override config(input: StartRunInput): ContainerRunConfig {
197
+ return {
198
+ hostTools: colocated.tools?.(input, this.env) ?? [],
199
+ workspace: colocated.workspace,
200
+ harness: colocated.harness,
201
+ model: colocated.model,
202
+ }
203
+ }
204
+ }
205
+ return { Coordinator: ConfiguredContainerCoordinator, Sandbox, worker }
206
+ }
207
+
208
+ const doDrives = config
209
+ class ConfiguredChatCoordinator extends ChatSandboxCoordinator<TEnv> {
210
+ protected override config(input: StartRunInput): ChatRunConfig {
211
+ const tools = doDrives.tools?.(input, this.env)
212
+ return {
213
+ adapter: doDrives.adapter(input, this.env),
214
+ sandbox:
215
+ doDrives.sandbox?.(input, this.env) ??
216
+ defaultSandbox(this.env, input, doDrives.workspace),
217
+ ...(tools !== undefined ? { tools } : {}),
218
+ ...(doDrives.systemPrompts !== undefined
219
+ ? { systemPrompts: doDrives.systemPrompts }
220
+ : {}),
221
+ }
222
+ }
223
+ }
224
+ return { Coordinator: ConfiguredChatCoordinator, Sandbox, worker }
225
+ }