@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.
- package/dist/esm/agent.d.ts +30 -0
- package/dist/esm/agent.js +25 -0
- package/dist/esm/agent.js.map +1 -0
- package/dist/esm/chat-coordinator.d.ts +75 -0
- package/dist/esm/chat-coordinator.js +135 -0
- package/dist/esm/chat-coordinator.js.map +1 -0
- package/dist/esm/container-coordinator.d.ts +114 -0
- package/dist/esm/container-coordinator.js +256 -0
- package/dist/esm/container-coordinator.js.map +1 -0
- package/dist/esm/coordinator.d.ts +68 -0
- package/dist/esm/coordinator.js +188 -0
- package/dist/esm/coordinator.js.map +1 -0
- package/dist/esm/factory.d.ts +80 -0
- package/dist/esm/factory.js +69 -0
- package/dist/esm/factory.js.map +1 -0
- package/dist/esm/handle.d.ts +23 -0
- package/dist/esm/handle.js +208 -0
- package/dist/esm/handle.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +10 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/preview-tool.d.ts +31 -0
- package/dist/esm/preview-tool.js +37 -0
- package/dist/esm/preview-tool.js.map +1 -0
- package/dist/esm/protocol.d.ts +42 -0
- package/dist/esm/protocol.js +64 -0
- package/dist/esm/protocol.js.map +1 -0
- package/dist/esm/provider.d.ts +31 -0
- package/dist/esm/provider.js +65 -0
- package/dist/esm/provider.js.map +1 -0
- package/dist/esm/public-host.d.ts +67 -0
- package/dist/esm/public-host.js +49 -0
- package/dist/esm/public-host.js.map +1 -0
- package/dist/esm/run-log-do.d.ts +25 -0
- package/dist/esm/run-log-do.js +122 -0
- package/dist/esm/run-log-do.js.map +1 -0
- package/dist/esm/runner.d.ts +32 -0
- package/dist/esm/runner.js +107 -0
- package/dist/esm/runner.js.map +1 -0
- package/dist/esm/web-crypto.d.ts +11 -0
- package/dist/esm/web-crypto.js +18 -0
- package/dist/esm/web-crypto.js.map +1 -0
- package/dist/esm/worker.d.ts +8 -0
- package/dist/esm/worker.js +83 -0
- package/dist/esm/worker.js.map +1 -0
- package/package.json +74 -0
- package/src/agent.ts +66 -0
- package/src/chat-coordinator.ts +253 -0
- package/src/container-coordinator.ts +437 -0
- package/src/coordinator.ts +338 -0
- package/src/factory.ts +225 -0
- package/src/handle.ts +292 -0
- package/src/index.ts +5 -0
- package/src/preview-tool.ts +110 -0
- package/src/protocol.ts +171 -0
- package/src/provider.ts +111 -0
- package/src/public-host.ts +121 -0
- package/src/run-log-do.ts +171 -0
- package/src/runner.ts +226 -0
- package/src/web-crypto.ts +31 -0
- 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
|
+
}
|