From d1490408282415d47ddc900775f5d91c9e96a6b3 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Tue, 10 Mar 2026 20:55:45 -0700 Subject: [PATCH 1/3] feat(deepagents): PTC via Worker repl --- examples/sandbox/sandbox-ptc-node.ts | 2 +- examples/sandbox/sandbox-ptc-python.ts | 2 +- examples/sandbox/sandbox-ptc-repl.ts | 182 +++++++++++++ examples/sandbox/sandbox-ptc.ts | 3 +- libs/deepagents/src/index.ts | 2 + libs/deepagents/src/sandbox-ptc/index.ts | 4 +- libs/deepagents/src/sandbox-ptc/middleware.ts | 198 ++++++++++---- libs/deepagents/src/sandbox-ptc/prompt.ts | 99 ++++++- libs/deepagents/src/sandbox-ptc/types.ts | 7 +- .../src/sandbox-ptc/worker-repl.test.ts | 157 +++++++++++ .../deepagents/src/sandbox-ptc/worker-repl.ts | 244 ++++++++++++++++++ .../src/sandbox-ptc/worker-runtime.ts | 147 +++++++++++ 12 files changed, 987 insertions(+), 60 deletions(-) create mode 100644 examples/sandbox/sandbox-ptc-repl.ts create mode 100644 libs/deepagents/src/sandbox-ptc/worker-repl.test.ts create mode 100644 libs/deepagents/src/sandbox-ptc/worker-repl.ts create mode 100644 libs/deepagents/src/sandbox-ptc/worker-runtime.ts diff --git a/examples/sandbox/sandbox-ptc-node.ts b/examples/sandbox/sandbox-ptc-node.ts index 6975067ab..2c82a8a80 100644 --- a/examples/sandbox/sandbox-ptc-node.ts +++ b/examples/sandbox/sandbox-ptc-node.ts @@ -66,7 +66,7 @@ console.log(` 100 employee records loaded\n`); try { const agent = createDeepAgent({ model: new ChatAnthropic({ - model: "claude-sonnet-4-20250514", + model: "claude-haiku-4-5", temperature: 0, }), systemPrompt, diff --git a/examples/sandbox/sandbox-ptc-python.ts b/examples/sandbox/sandbox-ptc-python.ts index c7bbbe04f..d586bbd7a 100644 --- a/examples/sandbox/sandbox-ptc-python.ts +++ b/examples/sandbox/sandbox-ptc-python.ts @@ -66,7 +66,7 @@ console.log(` 100 employee records loaded\n`); try { const agent = createDeepAgent({ model: new ChatAnthropic({ - model: "claude-sonnet-4-20250514", + model: "claude-haiku-4-5", temperature: 0, }), systemPrompt, diff --git a/examples/sandbox/sandbox-ptc-repl.ts b/examples/sandbox/sandbox-ptc-repl.ts new file mode 100644 index 000000000..22a4283b6 --- /dev/null +++ b/examples/sandbox/sandbox-ptc-repl.ts @@ -0,0 +1,182 @@ +/* eslint-disable no-console */ +/** + * Sandbox PTC (Programmatic Tool Calling) Example — Worker REPL + StateBackend + * + * Same scenario as sandbox-ptc.ts but without any sandbox infrastructure. + * A StateBackend stores the CSV file in agent state, giving the agent + * filesystem tools (read_file, write_file, etc.) for data access. + * The PTC middleware adds a `js_eval` tool backed by a Worker REPL + * with `toolCall()` and `spawnAgent()` as async globals. + * + * Phase 1 — 100 parallel `toolCall("classify_record", ...)` via Promise.all + * Phase 2 — 100 parallel `spawnAgent(...)` "analyst" (real LLM subagents) + * + * No VfsSandbox, no Deno, no Modal, no Docker — just a StateBackend + Worker thread. + * + * ## Running + * + * ```bash + * ANTHROPIC_API_KEY=sk-... npx tsx examples/sandbox/sandbox-ptc-repl.ts + * ``` + */ + +import "dotenv/config"; + +import { z } from "zod/v4"; + +import { ChatAnthropic } from "@langchain/anthropic"; +import { createMiddleware, tool, HumanMessage, AIMessage } from "langchain"; +import { + createDeepAgent, + createSandboxPtcMiddleware, + StateBackend, +} from "deepagents"; + +import { generateCsv } from "./utils/sandbox-ptc.js"; + +/** + * =============================== + * Define the classification tool. + * =============================== + */ + +const classifyTool = tool( + async (input: { + name: string; + age: number; + department: string; + years_at_company: number; + }) => { + const seniority = + input.years_at_company >= 15 + ? "senior" + : input.years_at_company >= 5 + ? "mid-level" + : "junior"; + const ageGroup = + input.age >= 55 + ? "55+" + : input.age >= 40 + ? "40-54" + : input.age >= 30 + ? "30-39" + : "under-30"; + const eligible = input.years_at_company >= 3 && input.age >= 25; + return JSON.stringify({ + name: input.name, + seniority, + age_group: ageGroup, + department: input.department, + promotion_eligible: eligible, + }); + }, + { + name: "classify_record", + description: + "Classify an employee record — returns seniority, age group, promotion eligibility", + schema: z.object({ + name: z.string(), + age: z.number(), + department: z.string(), + years_at_company: z.number(), + }), + }, +); + +const ptcOnlyToolsMiddleware = createMiddleware({ + name: "PtcOnlyTools", + tools: [classifyTool], + wrapModelCall: async (request, handler) => { + const visibleTools = (request.tools as { name: string }[]).filter( + (t) => t.name !== "classify_record", + ); + return handler({ ...request, tools: visibleTools }); + }, +}); + +/** + * ============================= + * Define the system prompt. + * ============================= + */ +const systemPrompt = `You are a data-processing agent. + +The file \`data/employees.csv\` contains 100 employee records with columns: id, name, age, department, years_at_company. +Use read_file to load it, then use js_eval with toolCall/spawnAgent for processing.`; + +const csv = generateCsv(100); +const now = new Date().toISOString(); + +console.log("Starting Worker REPL with StateBackend...\n"); +console.log(` 100 employee records loaded into state\n`); + +/** + * ============================= + * Create the agent. + * ============================= + */ +const agent = createDeepAgent({ + model: new ChatAnthropic({ + model: "claude-haiku-4-5", + temperature: 0, + }), + systemPrompt, + backend: (config) => new StateBackend(config), + subagents: [ + { + name: "analyst", + description: "Provides career recommendations for individual employees", + systemPrompt: `You are an HR analyst. Given an employee's classification data, +provide exactly ONE sentence of career recommendation. Be specific and actionable. +Do not use markdown. Keep it under 30 words.`, + model: new ChatAnthropic({ + model: "claude-haiku-4-5", + temperature: 0, + }), + }, + ], + middleware: [ + // No backend passed to PTC → Worker REPL with js_eval tool. + // The agent's StateBackend still provides filesystem tools (read_file, etc.) + createSandboxPtcMiddleware({ ptc: true }), + // @ts-expect-error type issue with branded AgentMiddleware type + ptcOnlyToolsMiddleware, + ], +}); + +console.log( + "Running: 100 parallel classify + 100 parallel analyst subagents (Worker REPL)...\n", +); +const t0 = performance.now(); + +/** + * ============================= + * Invoke the agent. + * ============================= + */ +const result = await agent.invoke( + { + messages: [ + new HumanMessage( + `Classify all 100 employees and spawn 100 analyst subagents in parallel.`, + ), + ], + files: { + "/data/employees.csv": { + content: csv.split("\n"), + created_at: now, + modified_at: now, + }, + }, + }, + { recursionLimit: 100 }, +); + +const elapsed = ((performance.now() - t0) / 1000).toFixed(1); +const last = result.messages.findLast(AIMessage.isInstance); +if (last) { + console.log(`\nAgent Response (${elapsed}s total):\n`); + console.log(last.content); +} + +console.log("\nDone."); diff --git a/examples/sandbox/sandbox-ptc.ts b/examples/sandbox/sandbox-ptc.ts index 810512939..1b47bc326 100644 --- a/examples/sandbox/sandbox-ptc.ts +++ b/examples/sandbox/sandbox-ptc.ts @@ -116,7 +116,7 @@ try { */ const agent = createDeepAgent({ model: new ChatAnthropic({ - model: "claude-sonnet-4-20250514", + model: "claude-haiku-4-5", temperature: 0, }), systemPrompt, @@ -136,6 +136,7 @@ Do not use markdown. Keep it under 30 words.`, ], middleware: [ createSandboxPtcMiddleware({ backend: sandbox, ptc: true }), + // @ts-expect-error type issue with branded AgentMiddleware type ptcOnlyToolsMiddleware, ], }); diff --git a/libs/deepagents/src/index.ts b/libs/deepagents/src/index.ts index e82ca597b..c1f1b89fd 100644 --- a/libs/deepagents/src/index.ts +++ b/libs/deepagents/src/index.ts @@ -125,7 +125,9 @@ export { createSandboxPtcMiddleware, PtcExecutionEngine, StdoutScanner, + WorkerRepl, generateSandboxPtcPrompt, + generateWorkerReplPrompt, type SandboxPtcMiddlewareOptions, type PtcEngineOptions, } from "./sandbox-ptc/index.js"; diff --git a/libs/deepagents/src/sandbox-ptc/index.ts b/libs/deepagents/src/sandbox-ptc/index.ts index cb8e83cb9..aaedaa070 100644 --- a/libs/deepagents/src/sandbox-ptc/index.ts +++ b/libs/deepagents/src/sandbox-ptc/index.ts @@ -18,7 +18,9 @@ export { type PtcEngineOptions, } from "./engine.js"; -export { generateSandboxPtcPrompt } from "./prompt.js"; +export { generateSandboxPtcPrompt, generateWorkerReplPrompt } from "./prompt.js"; + +export { WorkerRepl } from "./worker-repl.js"; export { BASH_RUNTIME, diff --git a/libs/deepagents/src/sandbox-ptc/middleware.ts b/libs/deepagents/src/sandbox-ptc/middleware.ts index a215a8329..05b786561 100644 --- a/libs/deepagents/src/sandbox-ptc/middleware.ts +++ b/libs/deepagents/src/sandbox-ptc/middleware.ts @@ -1,23 +1,32 @@ /** - * Sandbox PTC Middleware — enables programmatic tool calling from - * within any sandbox that implements `spawnInteractive()`. + * Sandbox PTC Middleware — enables programmatic tool calling from scripts. * - * Intercepts `execute` tool calls, instruments the command with the - * PTC runtime library, and runs it through the PtcExecutionEngine - * which handles IPC between the sandbox script and host tools. + * Three modes based on the backend: + * + * 1. **Sandbox mode** — backend with `spawnInteractive()` (Deno, Modal, etc.): + * intercepts `execute` tool calls, instruments bash/python/node scripts + * with PTC runtime, routes IPC through the PtcExecutionEngine. + * + * 2. **Backend + Worker REPL** — backend without `spawnInteractive()` + * (FilesystemBackend, StateBackend, etc.): the backend handles file + * storage via the standard filesystem tools, and a `js_eval` tool is + * added for running JS code with `toolCall()` / `spawnAgent()` in an + * isolated Worker. + * + * 3. **Standalone Worker REPL** — no backend at all: same as (2) but + * without filesystem tools. */ import { createMiddleware, + tool, type AgentMiddleware as _AgentMiddleware, + type ToolRuntime, } from "langchain"; +import { z } from "zod/v4"; import { ToolMessage } from "@langchain/core/messages"; import type { StructuredToolInterface } from "@langchain/core/tools"; -/** - * These type-only imports are required for TypeScript's type inference to work - * correctly with the langchain/langgraph middleware system. - */ import type * as _zodTypes from "@langchain/core/utils/types"; import type * as _zodMeta from "@langchain/langgraph/zod"; import type * as _messages from "@langchain/core/messages"; @@ -30,8 +39,12 @@ import { } from "../backends/protocol.js"; import { PtcExecutionEngine } from "./engine.js"; -import { generateSandboxPtcPrompt } from "./prompt.js"; -import type { SandboxPtcMiddlewareOptions } from "./types.js"; +import { + generateSandboxPtcPrompt, + generateWorkerReplPrompt, +} from "./prompt.js"; +import { WorkerRepl } from "./worker-repl.js"; +import type { SandboxPtcMiddlewareOptions, PtcExecuteResult } from "./types.js"; import { DEFAULT_PTC_EXCLUDED_TOOLS } from "./types.js"; function getBackend( @@ -50,7 +63,9 @@ function filterToolsForPtc( ): StructuredToolInterface[] { if (ptc === false) return []; - const candidates = allTools.filter((t) => t.name !== "execute"); + const candidates = allTools.filter( + (t) => t.name !== "execute" && t.name !== "js_eval", + ); if (ptc === true || ptc === undefined) { const excluded = new Set(DEFAULT_PTC_EXCLUDED_TOOLS); @@ -75,47 +90,146 @@ function filterToolsForPtc( return []; } +function formatPtcTrace(result: PtcExecuteResult): string { + if (result.toolCalls.length === 0) return ""; + + const succeeded = result.toolCalls.filter((tc) => !tc.error).length; + const failed = result.toolCalls.length - succeeded; + const totalMs = result.toolCalls.reduce((s, tc) => s + tc.durationMs, 0); + + const counts = new Map(); + for (const tc of result.toolCalls) { + counts.set(tc.name, (counts.get(tc.name) ?? 0) + 1); + } + const breakdown = [...counts.entries()] + .map(([name, count]) => `${name}=${count}`) + .join(", "); + + return `\n[PTC: ${result.toolCalls.length} tool calls (${breakdown}), ${succeeded} succeeded, ${failed} failed, ${totalMs.toFixed(0)}ms total]`; +} + /** - * Create a middleware that enables Programmatic Tool Calling (PTC) - * from within sandbox `execute` commands. - * - * When the agent runs a shell command via `execute`, this middleware: - * 1. Instruments the command with PTC runtime functions - * 2. Monitors stdout for IPC request markers - * 3. Dispatches tool calls / subagent spawns on the host - * 4. Writes responses back into the sandbox filesystem + * Determine whether the backend supports sandbox-style execution + * (i.e. has `spawnInteractive()` for bash/python/node scripts). + */ +function isSandboxWithInteractive( + backend: BackendProtocol | BackendFactory | undefined, + stateAndStore: StateAndStore, +): boolean { + if (!backend) return false; + const resolved = + typeof backend === "function" ? backend(stateAndStore) : backend; + return ( + isSandboxBackend(resolved) && + typeof resolved.spawnInteractive === "function" + ); +} + +/** + * Create a middleware that enables Programmatic Tool Calling (PTC). * - * Requires the backend to implement `spawnInteractive()`. - * Falls back to normal execution if not supported. + * - **Sandbox backend** (has `spawnInteractive`): intercepts `execute` and + * instruments bash/python/node scripts with PTC runtime. + * - **Non-sandbox backend** (FilesystemBackend, etc.) or **no backend**: + * adds a `js_eval` tool backed by a Worker REPL with `toolCall()` and + * `spawnAgent()` as async globals. The backend is still used for + * filesystem operations if provided. */ export function createSandboxPtcMiddleware( - options: SandboxPtcMiddlewareOptions, + options: SandboxPtcMiddlewareOptions = {}, ) { const { backend, ptc = true, timeoutMs = 300_000 } = options; let ptcTools: StructuredToolInterface[] = []; - let cachedPrompt: string | null = null; + let cachedSandboxPrompt: string | null = null; + let cachedReplPrompt: string | null = null; + let repl: WorkerRepl | null = null; + let detectedSandbox: boolean | null = null; + + const jsEvalTool = tool( + async (input: { code: string }, runnableConfig: ToolRuntime) => { + if (!repl) { + repl = new WorkerRepl(ptcTools, { timeoutMs }); + } + repl.tools = ptcTools; + + const result = await repl.eval(input.code, runnableConfig); + + const parts = [result.output]; + const trace = formatPtcTrace(result); + if (trace) parts.push(trace); + + return parts.join("").trim() || "(no output)"; + }, + { + name: "js_eval", + description: + "Evaluate JavaScript code in a sandboxed REPL. " + + "Use toolCall(name, input) and spawnAgent(description, type) for tool calls and subagents. " + + "Use console.log() for output. Returns the result of the last expression.", + schema: z.object({ + code: z + .string() + .describe("JavaScript code to evaluate in the sandboxed REPL"), + }), + }, + ); return createMiddleware({ name: "SandboxPtcMiddleware", + tools: [jsEvalTool], wrapModelCall: async (request, handler) => { const agentTools = (request.tools || []) as StructuredToolInterface[]; ptcTools = filterToolsForPtc(agentTools, ptc); - if (ptcTools.length > 0 && !cachedPrompt) { - cachedPrompt = generateSandboxPtcPrompt(ptcTools); + // Detect sandbox support lazily (once) + if (detectedSandbox === null) { + const stateAndStore: StateAndStore = { + state: request.state || {}, + // @ts-expect-error - request.config may have store + store: request.config?.store, + }; + detectedSandbox = isSandboxWithInteractive(backend, stateAndStore); } - const systemMessage = cachedPrompt - ? request.systemMessage.concat(cachedPrompt) - : request.systemMessage; + if (detectedSandbox) { + // Sandbox mode: inject bash/python/node PTC prompt, hide js_eval + if (ptcTools.length > 0 && !cachedSandboxPrompt) { + cachedSandboxPrompt = generateSandboxPtcPrompt(ptcTools); + } + const tools = (request.tools as { name: string }[]).filter( + (t) => t.name !== "js_eval", + ); + const systemMessage = cachedSandboxPrompt + ? request.systemMessage.concat(cachedSandboxPrompt) + : request.systemMessage; + return handler({ ...request, tools, systemMessage }); + } - return handler({ ...request, systemMessage }); + // Worker REPL mode: inject JS REPL prompt, hide PTC tools from the + // model so it must use toolCall()/spawnAgent() inside js_eval + if (ptcTools.length > 0 && !cachedReplPrompt) { + cachedReplPrompt = generateWorkerReplPrompt(ptcTools); + } + const ptcToolNames = new Set(ptcTools.map((t) => t.name)); + const visibleTools = (request.tools as { name: string }[]).filter( + (t) => !ptcToolNames.has(t.name), + ); + const systemMessage = cachedReplPrompt + ? request.systemMessage.concat(cachedReplPrompt) + : request.systemMessage; + return handler({ ...request, tools: visibleTools, systemMessage }); }, wrapToolCall: async (request, handler) => { - if (request.toolCall?.name !== "execute" || ptcTools.length === 0) { + // Only intercept `execute` in sandbox mode + if ( + request.toolCall?.name !== "execute" || + ptcTools.length === 0 || + !detectedSandbox || + !backend + ) { return handler(request); } @@ -157,26 +271,8 @@ export function createSandboxPtcMiddleware( parts.push("\n[Output was truncated due to size limits]"); } - if (result.toolCalls.length > 0) { - const succeeded = result.toolCalls.filter((tc) => !tc.error).length; - const failed = result.toolCalls.length - succeeded; - const totalMs = result.toolCalls.reduce( - (s, tc) => s + tc.durationMs, - 0, - ); - - const counts = new Map(); - for (const tc of result.toolCalls) { - counts.set(tc.name, (counts.get(tc.name) ?? 0) + 1); - } - const breakdown = [...counts.entries()] - .map(([name, count]) => `${name}=${count}`) - .join(", "); - - parts.push( - `\n[PTC: ${result.toolCalls.length} tool calls (${breakdown}), ${succeeded} succeeded, ${failed} failed, ${totalMs.toFixed(0)}ms total]`, - ); - } + const trace = formatPtcTrace(result); + if (trace) parts.push(trace); return new ToolMessage({ content: parts.join(""), diff --git a/libs/deepagents/src/sandbox-ptc/prompt.ts b/libs/deepagents/src/sandbox-ptc/prompt.ts index f0baf7d96..cc08a3823 100644 --- a/libs/deepagents/src/sandbox-ptc/prompt.ts +++ b/libs/deepagents/src/sandbox-ptc/prompt.ts @@ -1,9 +1,11 @@ /** - * System prompt generation for Sandbox PTC. + * System prompt generation for PTC. * - * Auto-generates complete API documentation from the actual tool schemas - * so the LLM knows exactly how to call tools and spawn subagents from - * within bash scripts. Injected by the PTC middleware into wrapModelCall. + * Two prompt generators: + * - `generateSandboxPtcPrompt` — for sandbox mode (bash/python/node scripts via execute) + * - `generateWorkerReplPrompt` — for Worker REPL mode (JS code via js_eval) + * + * Both auto-generate API documentation from actual tool schemas. */ import type { StructuredToolInterface } from "@langchain/core/tools"; @@ -190,3 +192,92 @@ ${toolEntries} ${subagentSection} `; } + +/** + * Build the Worker REPL system prompt section from actual tool definitions. + * Used when no sandbox backend is provided (Worker REPL mode). + */ +export function generateWorkerReplPrompt( + tools: StructuredToolInterface[], +): string { + if (tools.length === 0) return ""; + + const isTaskTool = (t: StructuredToolInterface) => t.name === "task"; + const regularTools = tools.filter((t) => !isTaskTool(t)); + const taskTool = tools.find(isTaskTool); + + const toolEntries = regularTools + .map((t) => { + const schema = t.schema ? safeToJsonSchema(t.schema) : undefined; + const example = schemaToExample(schema); + return `- **\`${t.name}\`** — ${t.description} + \`\`\`javascript + const result = await toolCall("${t.name}", ${example}); + \`\`\``; + }) + .join("\n\n"); + + let subagentSection = ""; + if (taskTool) { + const agentTypesMatch = taskTool.description.match( + /Available(?:\\s+agent\\s+types)?:?\\s*((?:- .+\\n?)+)/i, + ); + const agentTypes = agentTypesMatch + ? agentTypesMatch[1].trim() + : "- general-purpose: General-purpose agent"; + + subagentSection = ` +### Spawning subagents + +\`spawnAgent()\` launches a subagent. Returns a Promise with the agent's response. + +\`\`\`javascript +const analysis = await spawnAgent("Analyse this data and provide recommendations", "general-purpose"); +console.log(analysis); + +// Parallel subagent spawning +const results = await Promise.all( + items.map(item => spawnAgent(\`Analyse: \${JSON.stringify(item)}\`, "general-purpose")) +); +\`\`\` + +Available agent types: +${agentTypes} +`; + } + + return ` +## JavaScript REPL (\`js_eval\`) + +You have access to a sandboxed JavaScript REPL running in an isolated Worker. +Variables and closures do NOT persist across calls. +Use \`console.log()\` for output — it is captured and returned. + +### Key behavior + +- \`toolCall(name, input)\` and \`spawnAgent(description, type)\` are async globals. + Always use \`await\` when calling them. +- Maximise parallelism with \`Promise.all()\` whenever possible. +- Top-level \`await\` is supported — no need for async IIFE wrappers. +- No \`require\`, \`import\`, \`fetch\`, or filesystem access. + +### Calling tools + +\`\`\`javascript +// Single tool call +const result = await toolCall("tool_name", { key: "value" }); +console.log(result); + +// Parallel tool calls +const results = await Promise.all( + items.map(item => toolCall("tool_name", { id: item.id })) +); +console.log(results); +\`\`\` + +### Available tools + +${toolEntries} +${subagentSection} +`; +} diff --git a/libs/deepagents/src/sandbox-ptc/types.ts b/libs/deepagents/src/sandbox-ptc/types.ts index 794cfc662..c6c304c69 100644 --- a/libs/deepagents/src/sandbox-ptc/types.ts +++ b/libs/deepagents/src/sandbox-ptc/types.ts @@ -24,8 +24,13 @@ export interface SandboxPtcMiddlewareOptions { /** * Backend instance or factory that implements SandboxBackendProtocol. * Must support `spawnInteractive()` for PTC to be active. + * + * If not provided, an in-process Worker-based JavaScript REPL is used + * instead (Web Worker in browsers, Node.js Worker Threads in Node). + * In this mode, a `js_eval` tool is added so the agent can run JS code + * with `toolCall()` and `spawnAgent()` available as globals. */ - backend: BackendProtocol | BackendFactory; + backend?: BackendProtocol | BackendFactory; /** * Which tools to expose inside the sandbox via PTC. diff --git a/libs/deepagents/src/sandbox-ptc/worker-repl.test.ts b/libs/deepagents/src/sandbox-ptc/worker-repl.test.ts new file mode 100644 index 000000000..6d6a28190 --- /dev/null +++ b/libs/deepagents/src/sandbox-ptc/worker-repl.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from "vitest"; +import { tool } from "langchain"; +import { z } from "zod/v4"; + +import { WorkerRepl } from "./worker-repl.js"; + +describe("WorkerRepl", { timeout: 15_000 }, () => { + it("should evaluate simple JS and return output", async () => { + const repl = new WorkerRepl([]); + const result = await repl.eval('console.log("hello from worker")'); + + expect(result.output).toContain("hello from worker"); + expect(result.exitCode).toBe(0); + expect(result.toolCalls).toEqual([]); + }); + + it("should return the last expression value via console.log", async () => { + const repl = new WorkerRepl([]); + const result = await repl.eval("const x = 2 + 3;\nconsole.log(x)"); + + expect(result.output).toContain("5"); + expect(result.exitCode).toBe(0); + }); + + it("should handle errors gracefully", async () => { + const repl = new WorkerRepl([]); + const result = await repl.eval("throw new Error('boom')"); + + expect(result.output).toContain("boom"); + expect(result.exitCode).toBe(1); + }); + + it("should perform a single toolCall round-trip", async () => { + const greetTool = tool( + async (input: { name: string }) => `Hello, ${input.name}!`, + { + name: "greet", + description: "Greet someone", + schema: z.object({ name: z.string() }), + }, + ); + + const repl = new WorkerRepl([greetTool]); + const result = await repl.eval(` + const msg = await toolCall("greet", { name: "World" }); + console.log("GOT: " + msg); + `); + + expect(result.output).toContain("GOT: Hello, World!"); + expect(result.exitCode).toBe(0); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0].name).toBe("greet"); + expect(result.toolCalls[0].result).toBe("Hello, World!"); + expect(result.toolCalls[0].error).toBeUndefined(); + expect(result.toolCalls[0].durationMs).toBeGreaterThanOrEqual(0); + }); + + it("should handle parallel toolCalls via Promise.all", async () => { + const echoTool = tool( + async (input: { id: number }) => `echo-${input.id}`, + { + name: "echo", + description: "Echo an id", + schema: z.object({ id: z.number() }), + }, + ); + + const repl = new WorkerRepl([echoTool]); + const result = await repl.eval(` + const ids = [1, 2, 3, 4, 5]; + const results = await Promise.all( + ids.map(id => toolCall("echo", { id })) + ); + console.log("results: " + results.join(",")); + `); + + expect(result.output).toContain("results: echo-1,echo-2,echo-3,echo-4,echo-5"); + expect(result.exitCode).toBe(0); + expect(result.toolCalls).toHaveLength(5); + }); + + it("should handle tool call errors", async () => { + const failTool = tool( + async () => { throw new Error("intentional failure"); }, + { + name: "fail", + description: "Always fails", + schema: z.object({}), + }, + ); + + const repl = new WorkerRepl([failTool]); + const result = await repl.eval(` + try { + await toolCall("fail", {}); + } catch (e) { + console.log("caught: " + e.message); + } + `); + + expect(result.output).toContain("caught: intentional failure"); + expect(result.exitCode).toBe(0); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0].error).toContain("intentional failure"); + }); + + it("should handle unknown tool names", async () => { + const repl = new WorkerRepl([]); + const result = await repl.eval(` + try { + await toolCall("nonexistent", {}); + } catch (e) { + console.log("caught: " + e.message); + } + `); + + expect(result.output).toContain("caught: Unknown tool: nonexistent"); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0].error).toContain("Unknown tool: nonexistent"); + }); + + it("should handle spawnAgent", async () => { + const taskTool = tool( + async (input: { description: string; subagent_type: string }) => + `Analysed: ${input.description.slice(0, 30)} (agent=${input.subagent_type})`, + { + name: "task", + description: "Spawn a subagent", + schema: z.object({ + description: z.string(), + subagent_type: z.string(), + }), + }, + ); + + const repl = new WorkerRepl([taskTool]); + const result = await repl.eval(` + const analysis = await spawnAgent("Review quarterly data", "analyst"); + console.log("AGENT: " + analysis); + `); + + expect(result.output).toContain("AGENT: Analysed: Review quarterly data"); + expect(result.output).toContain("agent=analyst"); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0].name).toBe("task"); + }); + + it("should timeout long-running code", async () => { + const repl = new WorkerRepl([], { timeoutMs: 500 }); + const result = await repl.eval(` + await new Promise(r => setTimeout(r, 10000)); + `); + + expect(result.output).toContain("timed out"); + expect(result.exitCode).toBe(1); + }); +}); diff --git a/libs/deepagents/src/sandbox-ptc/worker-repl.ts b/libs/deepagents/src/sandbox-ptc/worker-repl.ts new file mode 100644 index 000000000..d573fdc80 --- /dev/null +++ b/libs/deepagents/src/sandbox-ptc/worker-repl.ts @@ -0,0 +1,244 @@ +/** + * Worker-based JavaScript REPL for PTC. + * + * Evaluates JS code in an isolated Worker (Web Worker or Node.js Worker + * Thread) with `toolCall()` and `spawnAgent()` available as globals. + * Tool invocations are routed to the host via postMessage IPC. + * + * This enables PTC without any sandbox infrastructure — the agent writes + * JS code via a `js_eval` tool and tools/subagents are called in-process. + */ + +import type { ToolRuntime } from "langchain"; +import type { StructuredToolInterface } from "@langchain/core/tools"; +import type { PtcToolCallTrace, PtcExecuteResult } from "./types.js"; +import { wrapUserCode } from "./worker-runtime.js"; + +interface NodeProcess { + getBuiltinModule?: (id: string) => Record | undefined; +} + +interface WorkerThreadsModule { + Worker: new (code: string, opts: { eval: true }) => NodeWorkerThread; +} + +interface NodeWorkerThread { + on(event: "message", cb: (msg: unknown) => void): void; + on(event: "error", cb: (err: Error) => void): void; + on(event: "exit", cb: (code: number) => void): void; + postMessage(msg: unknown): void; + terminate(): Promise; +} + +type WorkerImpl = "web" | "node"; + +function detectWorkerImpl(): WorkerImpl | null { + if ( + typeof globalThis !== "undefined" && + typeof (globalThis as Record).Worker === "function" + ) { + return "web"; + } + + try { + const mod = ( + globalThis as unknown as { process?: NodeProcess } + ).process?.getBuiltinModule?.("node:worker_threads"); + if (mod && "Worker" in mod) return "node"; + } catch { + // not available + } + + return null; +} + +function getNodeWorkerThreads(): WorkerThreadsModule { + const mod = ( + globalThis as unknown as { process?: NodeProcess } + ).process?.getBuiltinModule?.("node:worker_threads") as + | WorkerThreadsModule + | undefined; + if (!mod) throw new Error("node:worker_threads is not available"); + return mod; +} + +const DEFAULT_TIMEOUT_MS = 30_000; + +/** + * Evaluate JavaScript code in an isolated Worker with PTC globals. + */ +export class WorkerRepl { + private impl: WorkerImpl; + tools: StructuredToolInterface[]; + + constructor( + tools: StructuredToolInterface[], + private options: { timeoutMs?: number } = {}, + ) { + this.tools = tools; + const detected = detectWorkerImpl(); + if (!detected) { + throw new Error( + "No Worker implementation available. " + + "Requires Web Workers (browser) or Node.js >= 20.16.0 with worker_threads.", + ); + } + this.impl = detected; + } + + async eval(code: string, config?: ToolRuntime): Promise { + const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const wrappedCode = wrapUserCode(code); + const toolCallTraces: PtcToolCallTrace[] = []; + const logs: string[] = []; + + return new Promise((resolve) => { + let settled = false; + let timer: ReturnType | undefined; + let terminateFn: () => void = () => {}; + + const finish = (ok: boolean, value?: string, error?: string) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + + const output = logs.length > 0 ? logs.join("\n") + "\n" : ""; + const resultLine = + ok && value !== undefined + ? `→ ${value}\n` + : !ok && error + ? `Error: ${error}\n` + : ""; + + resolve({ + output: output + resultLine, + exitCode: ok ? 0 : 1, + truncated: false, + toolCalls: toolCallTraces, + }); + }; + + const handleMessage = async (msg: Record) => { + if (msg.type === "tool_call") { + const t0 = performance.now(); + const uuid = msg.uuid as string; + const name = msg.name as string; + const input = (msg.input as Record) || {}; + + const tool = this.tools.find((t) => t.name === name); + if (!tool) { + toolCallTraces.push({ + name, + input, + error: `Unknown tool: ${name}`, + durationMs: performance.now() - t0, + }); + postToWorker({ + type: "tool_result", + uuid, + ok: false, + error: `Unknown tool: ${name}`, + }); + return; + } + + try { + const result = await tool.invoke(input, config); + const resultStr = + typeof result === "string" ? result : JSON.stringify(result); + toolCallTraces.push({ + name, + input, + result: resultStr, + durationMs: performance.now() - t0, + }); + postToWorker({ + type: "tool_result", + uuid, + ok: true, + result: resultStr, + }); + } catch (e: unknown) { + const errMsg = + // eslint-disable-next-line no-instanceof/no-instanceof + e instanceof Error ? e.message : String(e); + toolCallTraces.push({ + name, + input, + error: errMsg, + durationMs: performance.now() - t0, + }); + postToWorker({ + type: "tool_result", + uuid, + ok: false, + error: errMsg, + }); + } + } else if (msg.type === "log") { + logs.push(msg.text as string); + } else if (msg.type === "result") { + finish( + msg.ok as boolean, + msg.value as string | undefined, + msg.error as string | undefined, + ); + } + }; + + let postToWorker: (msg: unknown) => void; + + if (this.impl === "node") { + const { Worker } = getNodeWorkerThreads(); + const worker = new Worker(wrappedCode, { eval: true }); + + postToWorker = (msg) => worker.postMessage(msg); + terminateFn = () => { + worker.terminate().catch(() => {}); + }; + + worker.on("message", (msg: unknown) => { + handleMessage(msg as Record); + }); + worker.on("error", (err: Error) => { + finish(false, undefined, err.message); + }); + worker.on("exit", () => { + finish(true); + }); + } else { + const blob = new Blob([wrappedCode], { + type: "application/javascript", + }); + const url = URL.createObjectURL(blob); + const worker = new ( + globalThis as unknown as { Worker: typeof Worker } + ).Worker(url); + + postToWorker = (msg) => worker.postMessage(msg); + terminateFn = () => { + worker.terminate(); + URL.revokeObjectURL(url); + }; + + worker.onmessage = (e: MessageEvent) => { + handleMessage(e.data as Record); + }; + worker.onerror = (e: ErrorEvent) => { + finish(false, undefined, e.message || "Worker error"); + }; + } + + if (timeoutMs > 0) { + timer = setTimeout(() => { + terminateFn(); + finish(false, undefined, "Execution timed out"); + }, timeoutMs); + } + }); + } + + dispose(): void { + // No persistent state to clean up — each eval() creates a fresh Worker + } +} diff --git a/libs/deepagents/src/sandbox-ptc/worker-runtime.ts b/libs/deepagents/src/sandbox-ptc/worker-runtime.ts new file mode 100644 index 000000000..c28c824a5 --- /dev/null +++ b/libs/deepagents/src/sandbox-ptc/worker-runtime.ts @@ -0,0 +1,147 @@ +/** + * Worker-side JavaScript runtime for PTC. + * + * This code is injected into Web Workers or Node.js Worker Threads before + * the user's script. It provides `toolCall()`, `spawnAgent()`, and + * `console.log` via `postMessage` IPC — no filesystem or process.stderr needed. + * + * Communication protocol (Worker <-> Main thread): + * + * Worker -> Main: { type: "tool_call", uuid, name, input } + * Main -> Worker: { type: "tool_result", uuid, ok, result?, error? } + * Worker -> Main: { type: "log", args } + * Worker -> Main: { type: "result", ok, value?, error? } + */ + +/** + * Runtime source code evaluated inside the Worker. + * + * Uses an async message-based IPC pattern: + * - `toolCall(name, input)` returns a Promise that resolves when the + * main thread replies with the matching uuid. + * - `spawnAgent(description, type)` is sugar for `toolCall("task", {...})`. + * - `console.log/warn/error` are overridden to forward output to the main thread. + */ +export const WORKER_JS_RUNTIME = ` +// ── PTC Worker Runtime ────────────────────────────────────────────── +"use strict"; + +const __da_pending = new Map(); + +// Detect environment: Node.js worker_threads vs Web Worker +const __da_isNode = typeof require === "function" && typeof self === "undefined"; +let __da_port; +let __da_postMessage; + +if (__da_isNode) { + const { parentPort } = require("worker_threads"); + __da_port = parentPort; + __da_postMessage = (msg) => parentPort.postMessage(msg); + parentPort.on("message", __da_onMessage); +} else { + __da_port = self; + __da_postMessage = (msg) => self.postMessage(msg); + self.onmessage = (e) => __da_onMessage(e.data); +} + +function __da_onMessage(msg) { + if (msg.type === "tool_result" && __da_pending.has(msg.uuid)) { + const { resolve, reject } = __da_pending.get(msg.uuid); + __da_pending.delete(msg.uuid); + if (msg.ok) resolve(msg.result); + else reject(new Error(msg.error || "Tool call failed")); + } +} + +function __da_uuid() { + if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID(); + // Fallback + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + return (c === "x" ? r : (r & 0x3) | 0x8).toString(16); + }); +} + +// ── Public API ────────────────────────────────────────────────────── + +/** + * Call a host-side tool. Returns a Promise. + * Use with await or Promise.all() for parallelism. + */ +function toolCall(name, input) { + input = input || {}; + const uuid = __da_uuid(); + return new Promise((resolve, reject) => { + __da_pending.set(uuid, { resolve, reject }); + __da_postMessage({ type: "tool_call", uuid, name, input }); + }); +} + +/** + * Spawn a subagent. Returns a Promise with the agent's text response. + */ +function spawnAgent(description, agentType) { + return toolCall("task", { + description: description, + subagent_type: agentType || "general-purpose", + }); +} + +// Override console to forward output to main thread +const __da_origConsole = { + log: typeof console !== "undefined" ? console.log : () => {}, + warn: typeof console !== "undefined" ? console.warn : () => {}, + error: typeof console !== "undefined" ? console.error : () => {}, +}; + +const __da_logs = []; + +function __da_formatArgs(args) { + return args + .map((a) => (typeof a === "object" && a !== null ? JSON.stringify(a) : String(a))) + .join(" "); +} + +console.log = (...args) => { + const line = __da_formatArgs(args); + __da_logs.push(line); + __da_postMessage({ type: "log", text: line }); +}; +console.warn = (...args) => { + const line = "[warn] " + __da_formatArgs(args); + __da_logs.push(line); + __da_postMessage({ type: "log", text: line }); +}; +console.error = (...args) => { + const line = "[error] " + __da_formatArgs(args); + __da_logs.push(line); + __da_postMessage({ type: "log", text: line }); +}; + +// Make available as globals +if (typeof globalThis !== "undefined") { + globalThis.toolCall = toolCall; + globalThis.spawnAgent = spawnAgent; +} +`; + +/** + * Wraps user code in an async IIFE so top-level await works, + * and sends the result (or error) back to the main thread. + */ +export function wrapUserCode(code: string): string { + return `${WORKER_JS_RUNTIME} + +// ── User code (async IIFE) ────────────────────────────────────────── +(async () => { + try { + const __da_userResult = await (async () => { +${code} + })(); + __da_postMessage({ type: "result", ok: true, value: __da_userResult !== undefined ? String(__da_userResult) : undefined, logs: __da_logs }); + } catch (__da_err) { + __da_postMessage({ type: "result", ok: false, error: __da_err?.message || String(__da_err), logs: __da_logs }); + } +})(); +`; +} From ececd4ae4e534ee9a75836102feb9018814d347f Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Tue, 10 Mar 2026 21:02:02 -0700 Subject: [PATCH 2/3] format --- libs/deepagents/src/sandbox-ptc/index.ts | 5 ++++- .../src/sandbox-ptc/worker-repl.test.ts | 21 ++++++++++--------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/libs/deepagents/src/sandbox-ptc/index.ts b/libs/deepagents/src/sandbox-ptc/index.ts index aaedaa070..c89c70329 100644 --- a/libs/deepagents/src/sandbox-ptc/index.ts +++ b/libs/deepagents/src/sandbox-ptc/index.ts @@ -18,7 +18,10 @@ export { type PtcEngineOptions, } from "./engine.js"; -export { generateSandboxPtcPrompt, generateWorkerReplPrompt } from "./prompt.js"; +export { + generateSandboxPtcPrompt, + generateWorkerReplPrompt, +} from "./prompt.js"; export { WorkerRepl } from "./worker-repl.js"; diff --git a/libs/deepagents/src/sandbox-ptc/worker-repl.test.ts b/libs/deepagents/src/sandbox-ptc/worker-repl.test.ts index 6d6a28190..5747261d2 100644 --- a/libs/deepagents/src/sandbox-ptc/worker-repl.test.ts +++ b/libs/deepagents/src/sandbox-ptc/worker-repl.test.ts @@ -56,14 +56,11 @@ describe("WorkerRepl", { timeout: 15_000 }, () => { }); it("should handle parallel toolCalls via Promise.all", async () => { - const echoTool = tool( - async (input: { id: number }) => `echo-${input.id}`, - { - name: "echo", - description: "Echo an id", - schema: z.object({ id: z.number() }), - }, - ); + const echoTool = tool(async (input: { id: number }) => `echo-${input.id}`, { + name: "echo", + description: "Echo an id", + schema: z.object({ id: z.number() }), + }); const repl = new WorkerRepl([echoTool]); const result = await repl.eval(` @@ -74,14 +71,18 @@ describe("WorkerRepl", { timeout: 15_000 }, () => { console.log("results: " + results.join(",")); `); - expect(result.output).toContain("results: echo-1,echo-2,echo-3,echo-4,echo-5"); + expect(result.output).toContain( + "results: echo-1,echo-2,echo-3,echo-4,echo-5", + ); expect(result.exitCode).toBe(0); expect(result.toolCalls).toHaveLength(5); }); it("should handle tool call errors", async () => { const failTool = tool( - async () => { throw new Error("intentional failure"); }, + async () => { + throw new Error("intentional failure"); + }, { name: "fail", description: "Always fails", From 8e9ceaf8ca99eafb34587e37090c7b8a893ecf5d Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Tue, 10 Mar 2026 21:41:04 -0700 Subject: [PATCH 3/3] make worker thread secure --- .../src/sandbox-ptc/worker-repl.test.ts | 15 ++ .../deepagents/src/sandbox-ptc/worker-repl.ts | 2 +- .../src/sandbox-ptc/worker-runtime.ts | 229 +++++++++++++----- 3 files changed, 185 insertions(+), 61 deletions(-) diff --git a/libs/deepagents/src/sandbox-ptc/worker-repl.test.ts b/libs/deepagents/src/sandbox-ptc/worker-repl.test.ts index 5747261d2..eb8c587c2 100644 --- a/libs/deepagents/src/sandbox-ptc/worker-repl.test.ts +++ b/libs/deepagents/src/sandbox-ptc/worker-repl.test.ts @@ -146,6 +146,21 @@ describe("WorkerRepl", { timeout: 15_000 }, () => { expect(result.toolCalls[0].name).toBe("task"); }); + it("should block access to require, process, and fs (vm sandbox)", async () => { + const repl = new WorkerRepl([]); + + const r1 = await repl.eval('try { require("fs"); console.log("FAIL: require accessible"); } catch(e) { console.log("OK: " + e.message); }'); + expect(r1.output).toContain("OK:"); + expect(r1.output).not.toContain("FAIL"); + + const r2 = await repl.eval('try { console.log("env=" + process.env.HOME); console.log("FAIL: process accessible"); } catch(e) { console.log("OK: " + e.message); }'); + expect(r2.output).toContain("OK:"); + expect(r2.output).not.toContain("FAIL"); + + const r3 = await repl.eval('try { const f = globalThis; console.log("globalThis=" + typeof f); } catch(e) { console.log("OK: " + e.message); }'); + expect(r3.output).not.toContain("object"); + }); + it("should timeout long-running code", async () => { const repl = new WorkerRepl([], { timeoutMs: 500 }); const result = await repl.eval(` diff --git a/libs/deepagents/src/sandbox-ptc/worker-repl.ts b/libs/deepagents/src/sandbox-ptc/worker-repl.ts index d573fdc80..50fd00cf7 100644 --- a/libs/deepagents/src/sandbox-ptc/worker-repl.ts +++ b/libs/deepagents/src/sandbox-ptc/worker-repl.ts @@ -88,7 +88,7 @@ export class WorkerRepl { async eval(code: string, config?: ToolRuntime): Promise { const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const wrappedCode = wrapUserCode(code); + const wrappedCode = wrapUserCode(code, this.impl); const toolCallTraces: PtcToolCallTrace[] = []; const logs: string[] = []; diff --git a/libs/deepagents/src/sandbox-ptc/worker-runtime.ts b/libs/deepagents/src/sandbox-ptc/worker-runtime.ts index c28c824a5..78a90f130 100644 --- a/libs/deepagents/src/sandbox-ptc/worker-runtime.ts +++ b/libs/deepagents/src/sandbox-ptc/worker-runtime.ts @@ -1,73 +1,60 @@ /** * Worker-side JavaScript runtime for PTC. * - * This code is injected into Web Workers or Node.js Worker Threads before - * the user's script. It provides `toolCall()`, `spawnAgent()`, and - * `console.log` via `postMessage` IPC — no filesystem or process.stderr needed. + * Security model: + * - **Node.js Worker Threads**: User code runs inside a `vm.createContext()` + * with only whitelisted globals. No `require`, `process`, `fs`, `Buffer`, + * `fetch`, or `import` — only `toolCall`, `spawnAgent`, `console`, `JSON`, + * `Math`, `Promise`, `Array`, `Object`, etc. + * - **Web Workers**: Already restricted by the browser — no filesystem, + * no `require`, no `process`. Code runs directly in the Worker scope. * * Communication protocol (Worker <-> Main thread): - * * Worker -> Main: { type: "tool_call", uuid, name, input } * Main -> Worker: { type: "tool_result", uuid, ok, result?, error? } - * Worker -> Main: { type: "log", args } + * Worker -> Main: { type: "log", text } * Worker -> Main: { type: "result", ok, value?, error? } */ /** - * Runtime source code evaluated inside the Worker. + * Node.js Worker Thread bootstrap. * - * Uses an async message-based IPC pattern: - * - `toolCall(name, input)` returns a Promise that resolves when the - * main thread replies with the matching uuid. - * - `spawnAgent(description, type)` is sugar for `toolCall("task", {...})`. - * - `console.log/warn/error` are overridden to forward output to the main thread. + * Sets up the IPC bridge (parentPort), then runs the user's code inside + * a restricted `vm.createContext()` that only exposes safe globals + + * PTC functions. This prevents the agent's code from accessing `require`, + * `process`, `fs`, network APIs, or any Node.js built-ins. */ -export const WORKER_JS_RUNTIME = ` -// ── PTC Worker Runtime ────────────────────────────────────────────── +export const NODE_WORKER_BOOTSTRAP = ` "use strict"; +const { parentPort } = require("worker_threads"); +const vm = require("vm"); const __da_pending = new Map(); +const __da_logs = []; -// Detect environment: Node.js worker_threads vs Web Worker -const __da_isNode = typeof require === "function" && typeof self === "undefined"; -let __da_port; -let __da_postMessage; - -if (__da_isNode) { - const { parentPort } = require("worker_threads"); - __da_port = parentPort; - __da_postMessage = (msg) => parentPort.postMessage(msg); - parentPort.on("message", __da_onMessage); -} else { - __da_port = self; - __da_postMessage = (msg) => self.postMessage(msg); - self.onmessage = (e) => __da_onMessage(e.data); -} +function __da_postMessage(msg) { parentPort.postMessage(msg); } -function __da_onMessage(msg) { +parentPort.on("message", (msg) => { if (msg.type === "tool_result" && __da_pending.has(msg.uuid)) { const { resolve, reject } = __da_pending.get(msg.uuid); __da_pending.delete(msg.uuid); if (msg.ok) resolve(msg.result); else reject(new Error(msg.error || "Tool call failed")); } -} +}); function __da_uuid() { - if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID(); - // Fallback - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { - const r = (Math.random() * 16) | 0; - return (c === "x" ? r : (r & 0x3) | 0x8).toString(16); - }); + return require("crypto").randomUUID(); } -// ── Public API ────────────────────────────────────────────────────── +function __da_formatArgs(args) { + return args + .map((a) => (typeof a === "object" && a !== null ? JSON.stringify(a) : String(a))) + .join(" "); +} + +// ── PTC functions exposed to the sandbox ──────────────────────────── -/** - * Call a host-side tool. Returns a Promise. - * Use with await or Promise.all() for parallelism. - */ function toolCall(name, input) { input = input || {}; const uuid = __da_uuid(); @@ -77,9 +64,6 @@ function toolCall(name, input) { }); } -/** - * Spawn a subagent. Returns a Promise with the agent's text response. - */ function spawnAgent(description, agentType) { return toolCall("task", { description: description, @@ -87,21 +71,146 @@ function spawnAgent(description, agentType) { }); } -// Override console to forward output to main thread -const __da_origConsole = { - log: typeof console !== "undefined" ? console.log : () => {}, - warn: typeof console !== "undefined" ? console.warn : () => {}, - error: typeof console !== "undefined" ? console.error : () => {}, +const __da_console = { + log: (...args) => { + const line = __da_formatArgs(args); + __da_logs.push(line); + __da_postMessage({ type: "log", text: line }); + }, + warn: (...args) => { + const line = "[warn] " + __da_formatArgs(args); + __da_logs.push(line); + __da_postMessage({ type: "log", text: line }); + }, + error: (...args) => { + const line = "[error] " + __da_formatArgs(args); + __da_logs.push(line); + __da_postMessage({ type: "log", text: line }); + }, }; +// ── Run user code in a restricted VM context ──────────────────────── + +const __da_sandbox = vm.createContext({ + // PTC globals + toolCall, + spawnAgent, + console: __da_console, + + // Safe JS built-ins + Promise, + JSON, + Math, + Array, + Object, + String, + Number, + Boolean, + Date, + RegExp, + Map, + Set, + WeakMap, + WeakSet, + Symbol, + Error, + TypeError, + RangeError, + SyntaxError, + URIError, + parseInt, + parseFloat, + isNaN, + isFinite, + encodeURI, + decodeURI, + encodeURIComponent, + decodeURIComponent, + undefined, + NaN, + Infinity, + globalThis: undefined, + // Timers (needed for Promise resolution polling in some edge cases) + setTimeout, + clearTimeout, + setInterval, + clearInterval, +}); + +const __da_userCode = "@@SPLIT@@"; + +const __da_script = new vm.Script( + "(async () => {\\n" + + " try {\\n" + + " const __result = await (async () => {\\n" + + __da_userCode + "\\n" + + " })();\\n" + + " return { ok: true, value: __result !== undefined ? String(__result) : undefined };\\n" + + " } catch (__err) {\\n" + + " return { ok: false, error: __err?.message || String(__err) };\\n" + + " }\\n" + + "})()", + { filename: "js_eval" } +); + +const __da_promise = __da_script.runInContext(__da_sandbox); +__da_promise.then((res) => { + __da_postMessage({ type: "result", ok: res.ok, value: res.value, error: res.error, logs: __da_logs }); +}).catch((err) => { + __da_postMessage({ type: "result", ok: false, error: err?.message || String(err), logs: __da_logs }); +}); +`; + +/** + * Web Worker bootstrap. + * + * Web Workers are already restricted (no `require`, `process`, `fs`). + * We just set up the IPC bridge and run the user's code directly. + */ +export const WEB_WORKER_BOOTSTRAP = ` +"use strict"; + +const __da_pending = new Map(); const __da_logs = []; +function __da_postMessage(msg) { self.postMessage(msg); } + +self.onmessage = (e) => { + const msg = e.data; + if (msg.type === "tool_result" && __da_pending.has(msg.uuid)) { + const { resolve, reject } = __da_pending.get(msg.uuid); + __da_pending.delete(msg.uuid); + if (msg.ok) resolve(msg.result); + else reject(new Error(msg.error || "Tool call failed")); + } +}; + +function __da_uuid() { + return crypto.randomUUID(); +} + function __da_formatArgs(args) { return args .map((a) => (typeof a === "object" && a !== null ? JSON.stringify(a) : String(a))) .join(" "); } +function toolCall(name, input) { + input = input || {}; + const uuid = __da_uuid(); + return new Promise((resolve, reject) => { + __da_pending.set(uuid, { resolve, reject }); + __da_postMessage({ type: "tool_call", uuid, name, input }); + }); +} + +function spawnAgent(description, agentType) { + return toolCall("task", { + description: description, + subagent_type: agentType || "general-purpose", + }); +} + console.log = (...args) => { const line = __da_formatArgs(args); __da_logs.push(line); @@ -117,22 +226,22 @@ console.error = (...args) => { __da_logs.push(line); __da_postMessage({ type: "log", text: line }); }; - -// Make available as globals -if (typeof globalThis !== "undefined") { - globalThis.toolCall = toolCall; - globalThis.spawnAgent = spawnAgent; -} `; /** - * Wraps user code in an async IIFE so top-level await works, - * and sends the result (or error) back to the main thread. + * Build the complete Worker code by injecting the user's code into the + * appropriate bootstrap (Node.js with vm sandbox, or Web Worker). */ -export function wrapUserCode(code: string): string { - return `${WORKER_JS_RUNTIME} +export function wrapUserCode(code: string, impl: "node" | "web"): string { + if (impl === "node") { + const escaped = JSON.stringify(code); + const [before, after] = NODE_WORKER_BOOTSTRAP.split('"@@SPLIT@@"'); + return before + escaped + after; + } + + // Web Worker: run code directly (already sandboxed by the browser) + return `${WEB_WORKER_BOOTSTRAP} -// ── User code (async IIFE) ────────────────────────────────────────── (async () => { try { const __da_userResult = await (async () => {