diff --git a/e2e/scenarios/codemode-output-helpers.test.ts b/e2e/scenarios/codemode-output-helpers.test.ts new file mode 100644 index 000000000..c0c0537a8 --- /dev/null +++ b/e2e/scenarios/codemode-output-helpers.test.ts @@ -0,0 +1,80 @@ +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Mcp, Target } from "../src/services"; + +const PNG_DATA = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; +const PNG_DATA_URL = `data:image/png;base64,${PNG_DATA}`; + +scenario( + "Codemode · output helpers emit images, detail metadata, and notifications", + {}, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const identity = yield* target.newIdentity(); + const session = mcp.session(identity); + + const result = yield* session.call("execute", { + code: [ + 'text("helper caption");', + `image(${JSON.stringify(PNG_DATA_URL)}, "original");`, + `image({ image_url: ${JSON.stringify(PNG_DATA_URL)}, detail: "low" });`, + 'notify({ message: "rendered previews", data: { count: 2 } });', + "return {", + ' value: "structured return",', + " toolKeys: {", + " root: Object.keys(tools),", + " describe: Object.keys(tools.describe),", + " executor: Object.keys(tools.executor),", + " sources: Object.keys(tools.executor.sources),", + " },", + "};", + ].join("\n"), + }); + + expect(result.ok, `execute completed (got: ${result.text.slice(0, 300)})`).toBe(true); + + const raw = result.raw as { + content?: ReadonlyArray>; + structuredContent?: Record; + }; + const content = raw.content ?? []; + expect(result.text, "text() reaches the user-visible MCP text stream").toContain( + "helper caption", + ); + expect(result.text, "notify() renders as a distinct visible notification").toContain( + "Notification: rendered previews", + ); + + const images = content.filter((block) => block.type === "image"); + expect(images, "image() emits two MCP image blocks").toHaveLength(2); + expect(images[0], "data URI image is converted to MCP image content").toMatchObject({ + type: "image", + data: PNG_DATA, + mimeType: "image/png", + _meta: { "codex/imageDetail": "original" }, + }); + expect(images[1], "image_url object detail is preserved").toMatchObject({ + type: "image", + data: PNG_DATA, + mimeType: "image/png", + _meta: { "codex/imageDetail": "low" }, + }); + + expect(raw.structuredContent?.result, "return value stays model-visible").toEqual({ + value: "structured return", + toolKeys: { + root: ["search", "describe", "executor"], + describe: ["tool"], + executor: ["sources"], + sources: ["list"], + }, + }); + expect(raw.structuredContent?.notifications, "notifications are structured too").toEqual([ + { message: "rendered previews", data: { count: 2 } }, + ]); + }), +); diff --git a/packages/core/execution/src/description.ts b/packages/core/execution/src/description.ts index f8785a91e..37232a446 100644 --- a/packages/core/execution/src/description.ts +++ b/packages/core/execution/src/description.ts @@ -116,6 +116,7 @@ const formatDescription = (connectionEntries: readonly ConnectionInventoryEntry[ "- Tool calls return a value union: `{ ok: true, data }` for success or `{ ok: false, error: { code, message, status?, details?, retryable? } }` for expected tool/domain failures. Branch on `result.ok`.", "- `data` is the upstream payload itself. HTTP-backed tools (OpenAPI) also set `http: { status, headers }` beside `data` — read `result.http?.headers` for pagination (Link) or rate-limit headers.", "- Use `emit(value)` to append user-visible output and return `undefined`. Plain values become MCP text content. MCP content blocks are forwarded as-is. `ToolFile` values are rendered by MIME. Emitted output goes to the user, not back to you; the result envelope reports an `emitted` count so you can confirm it landed, but to read a value yourself, `return` it.", + "- Prefer explicit output helpers when the content kind is known: `text(value)`, `image(value, detail?)`, `audio(block)`, `file(toolFile)`, `resource(block)`, and `notify(value)`. `image` accepts a base64 data URI string, `{ image_url, detail }`, or a raw MCP image block; `detail` may be `auto`, `low`, `high`, or `original`.", '- File-returning tools may return `ToolFile` values: `{ _tag: "ToolFile", name?, mimeType, encoding: "base64", data, byteLength }`. Emit any attachment with `emit(result.data)`.', '- To emit MCP-native content directly, pass an MCP content block to `emit(...)`, such as `{ type: "image", data, mimeType }`, `{ type: "audio", data, mimeType }`, `{ type: "text", text }`, `{ type: "resource", resource }`, or `{ type: "resource_link", uri, name, ... }`.', "- `emit(ToolFile)` is MIME-based: `image/*` becomes MCP image content, `audio/*` becomes MCP audio content, text-like files become decoded text, and other binary files become embedded MCP resources.", @@ -123,7 +124,7 @@ const formatDescription = (connectionEntries: readonly ConnectionInventoryEntry[ "- Some providers, including Gmail, return attachment bytes without a public URL. To send that attachment to another API from code, decode `ToolFile.data` from base64 and pass the bytes to that API's upload/file input.", "- If `tools.search()` returns `hasMore: true` and you didn't find what you need, fetch the next page: `tools.search({ query, offset: nextOffset, limit })`.", "- Always use the full address when calling tools: `tools....(args)`. The `path` returned by `tools.search()` / `tools.describe.tool()` is already the exact path under `tools` — call `tools[path]` rather than guessing segments.", - "- The `tools` object is a lazy proxy — `Object.keys(tools)` won't work. Use `tools.search()` or `tools.executor.coreTools.connections.list({})` instead.", + "- The `tools` object is a lazy proxy. `Object.keys(tools)` returns only bounded built-in discovery keys (`search`, `describe`, `executor`), not the full API catalog. Use `tools.search()` or `tools.executor.coreTools.connections.list({})` for configured tools.", '- Pass an object to system tools, e.g. `tools.search({ query: "..." })`, `tools.executor.coreTools.connections.list({})`, and `tools.describe.tool({ path })`.', '- `tools.describe.tool()` returns compact TypeScript shapes. Use `inputTypeScript`, `outputTypeScript`, and `typeScriptDefinitions`. If the path doesn\'t resolve, the result carries `error: { code: "tool_not_found", suggestions }` — use a suggestion instead of retrying the same path.', "- For tools that return large collections (e.g. `getStates`, `getAll`), filter results in code rather than calling per-item tools.", diff --git a/packages/core/execution/src/engine.ts b/packages/core/execution/src/engine.ts index 665548852..24a5a0440 100644 --- a/packages/core/execution/src/engine.ts +++ b/packages/core/execution/src/engine.ts @@ -10,7 +10,12 @@ import type { ElicitationContext, } from "@executor-js/sdk/core"; import { CodeExecutionError } from "@executor-js/codemode-core"; -import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor-js/codemode-core"; +import type { + CodeExecutor, + ExecuteNotification, + ExecuteResult, + SandboxToolInvoker, +} from "@executor-js/codemode-core"; import { defaultToolDiscoveryProvider, @@ -79,6 +84,16 @@ export const formatExecuteResult = ( : null; const logText = result.logs && result.logs.length > 0 ? result.logs.join("\n") : null; + const notifications = + result.output + ?.filter( + ( + item, + ): item is { readonly type: "notification"; readonly notification: ExecuteNotification } => + item.type === "notification", + ) + .map((item) => item.notification) ?? []; + const notificationField = notifications.length > 0 ? { notifications } : {}; // `emit()` output is shown to the user, not returned to the model, so a // script that only emits comes back with a null result. Acknowledge the @@ -97,6 +112,7 @@ export const formatExecuteResult = ( status: "error", error: result.error, ...emittedField, + ...notificationField, logs: result.logs ?? [], }, isError: true, @@ -115,6 +131,7 @@ export const formatExecuteResult = ( status: "completed", result: result.result ?? null, ...emittedField, + ...notificationField, logs: result.logs ?? [], }, isError: false, diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index ff7989b55..fc17fc3fc 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -373,6 +373,26 @@ const isContentOutputItem = ( ): item is { readonly type: "content"; readonly content: ContentBlock } => isRecord(item) && item.type === "content" && isMcpContentBlock(item.content); +const isNotificationOutputItem = ( + item: ExecuteOutputItem, +): item is { + readonly type: "notification"; + readonly notification: { readonly message: string; readonly data?: unknown }; +} => + isRecord(item) && + item.type === "notification" && + isRecord(item.notification) && + typeof item.notification.message === "string"; + +const notificationContent = (notification: { + readonly message: string; + readonly data?: unknown; +}): ContentBlock[] => { + const dataText = + notification.data === undefined ? "" : `\n${JSON.stringify(notification.data, null, 2)}`; + return [{ type: "text", text: `Notification: ${notification.message}${dataText}` }]; +}; + const outputItemContent = (item: ExecuteOutputItem): ContentBlock[] => { if (isFileOutputItem(item)) { return outputFileContent(item.file); @@ -380,6 +400,9 @@ const outputItemContent = (item: ExecuteOutputItem): ContentBlock[] => { if (isContentOutputItem(item)) { return [item.content]; } + if (isNotificationOutputItem(item)) { + return notificationContent(item.notification); + } return [{ type: "text", text: "Invalid execution output item omitted." }]; }; diff --git a/packages/kernel/core/src/types.ts b/packages/kernel/core/src/types.ts index c9e8e3ffe..3e03bd0aa 100644 --- a/packages/kernel/core/src/types.ts +++ b/packages/kernel/core/src/types.ts @@ -27,6 +27,16 @@ export interface SandboxToolInvoker { } /** User-visible output accumulated by sandbox helpers. */ +export type ImageDetail = "auto" | "low" | "high" | "original"; + +export const IMAGE_DETAIL_META_KEY = "codex/imageDetail"; +export const DEFAULT_IMAGE_DETAIL: ImageDetail = "high"; + +export type ExecuteNotification = { + readonly message: string; + readonly data?: unknown; +}; + export type ExecuteOutputItem = | { readonly type: "file"; @@ -35,8 +45,17 @@ export type ExecuteOutputItem = | { readonly type: "content"; readonly content: unknown; + } + | { + readonly type: "notification"; + readonly notification: ExecuteNotification; }; +export type CodeExecutionOptions = { + readonly onOutput?: (item: ExecuteOutputItem) => void | Promise; + readonly onYield?: () => void | Promise; +}; + /** Result of executing code in a sandbox */ export type ExecuteResult = { result: unknown; @@ -55,7 +74,11 @@ export type ExecuteResult = { * `Data.TaggedError` subclass — e.g. `CodeExecutor`. */ export interface CodeExecutor { - execute(code: string, toolInvoker: SandboxToolInvoker): Effect.Effect; + execute( + code: string, + toolInvoker: SandboxToolInvoker, + options?: CodeExecutionOptions, + ): Effect.Effect; } /** Accept-anything schema for tools with no input validation */ diff --git a/packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs b/packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs index fa70abbac..7fc197042 100644 --- a/packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs +++ b/packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs @@ -6,6 +6,7 @@ const encoder = new TextEncoder(); const IPC_PREFIX = "@@executor-ipc@@"; const pendingToolCalls = new Map(); +const pendingYieldCalls = new Map(); let started = false; let ipcNonce = ""; @@ -19,6 +20,13 @@ const writeIpcMessage = (message) => { Deno.stdout.writeSync(encoder.encode(payload)); }; +const recordOutput = (item) => { + outputs.push(item); + if (ipcNonce) { + writeIpcMessage({ type: "output", nonce: ipcNonce, item }); + } +}; + const toErrorMessage = (error) => { if (error instanceof Error) { return error.stack ?? error.message; @@ -41,6 +49,15 @@ const createToolCaller = (toolPath) => (args) => }); }); +const builtinToolKeys = { + "": ["search", "describe", "executor"], + describe: ["tool"], + executor: ["sources"], + "executor.sources": ["list"], +}; + +const toolKeysForPath = (path) => builtinToolKeys[path.join(".")] ?? []; + const createToolsProxy = (path = []) => { const callable = () => undefined; @@ -50,6 +67,14 @@ const createToolsProxy = (path = []) => { if (typeof prop !== "string") return undefined; return createToolsProxy([...path, prop]); }, + ownKeys() { + return toolKeysForPath(path); + }, + getOwnPropertyDescriptor(_target, prop) { + return typeof prop === "string" && toolKeysForPath(path).includes(prop) + ? { enumerable: true, configurable: true } + : undefined; + }, apply(_target, _thisArg, args) { const toolPath = path.join("."); if (!toolPath) { @@ -93,6 +118,24 @@ const formatOutputText = (value) => { } }; +const IMAGE_DETAIL_META_KEY = "codex/imageDetail"; +const DEFAULT_IMAGE_DETAIL = "high"; +const validImageDetails = new Set(["auto", "low", "high", "original"]); + +const normalizeImageDetail = (detail) => { + if (detail === null || typeof detail === "undefined") { + return undefined; + } + if (typeof detail !== "string") { + throw new TypeError("image detail must be a string when provided"); + } + const normalized = detail.toLowerCase(); + if (!validImageDetails.has(normalized)) { + throw new TypeError("image detail must be one of: auto, low, high, original"); + } + return normalized; +}; + const isToolFile = (value) => value && typeof value === "object" && @@ -111,7 +154,9 @@ const isMcpImageContentBlock = (value) => typeof value === "object" && value.type === "image" && typeof value.data === "string" && - typeof value.mimeType === "string"; + (typeof value.mimeType === "string" || + typeof value.mime_type === "string" || + value.data.toLowerCase().startsWith("data:")); const isMcpAudioContentBlock = (value) => value && @@ -143,16 +188,136 @@ const isMcpContentBlock = (value) => isMcpResourceContentBlock(value) || isMcpResourceLinkContentBlock(value); +const parseDataImageUrl = (imageUrl) => { + if (typeof imageUrl !== "string" || imageUrl.length === 0) { + throw new TypeError( + "image expects a non-empty data URI, an object with image_url and optional detail, or a raw MCP image block", + ); + } + const lower = imageUrl.toLowerCase(); + if (lower.startsWith("http://") || lower.startsWith("https://")) { + throw new TypeError( + "remote image URLs are not supported in code output; pass a base64 data URI or MCP image block", + ); + } + const match = /^data:([^;,]+);base64,(.*)$/i.exec(imageUrl); + if (!match) { + throw new TypeError("image expects a base64 data URI or MCP image block"); + } + return { mimeType: match[1], data: match[2] }; +}; + +const imageDetailFromMeta = (value) => { + const meta = value && typeof value === "object" ? value._meta : undefined; + const detail = meta && typeof meta === "object" ? meta[IMAGE_DETAIL_META_KEY] : undefined; + return typeof detail === "string" && validImageDetails.has(detail) ? detail : undefined; +}; + +const imageWithDetail = (block, detail) => ({ + ...block, + _meta: { + ...(block._meta && typeof block._meta === "object" ? block._meta : {}), + [IMAGE_DETAIL_META_KEY]: detail ?? DEFAULT_IMAGE_DETAIL, + }, +}); + +const normalizeImageBlock = (value, detailOverride) => { + const override = normalizeImageDetail(detailOverride); + if (typeof value === "string") { + return imageWithDetail({ type: "image", ...parseDataImageUrl(value) }, override); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError( + "image expects a non-empty data URI, an object with image_url and optional detail, or a raw MCP image block", + ); + } + if (typeof value.image_url === "string") { + return imageWithDetail( + { type: "image", ...parseDataImageUrl(value.image_url) }, + override ?? normalizeImageDetail(value.detail), + ); + } + if (value.type === "image" && typeof value.data === "string") { + const parsed = value.data.toLowerCase().startsWith("data:") + ? parseDataImageUrl(value.data) + : { + data: value.data, + mimeType: + typeof value.mimeType === "string" + ? value.mimeType + : typeof value.mime_type === "string" + ? value.mime_type + : "application/octet-stream", + }; + return imageWithDetail( + { ...value, type: "image", data: parsed.data, mimeType: parsed.mimeType }, + override ?? imageDetailFromMeta(value), + ); + } + throw new TypeError( + "image expects a non-empty data URI, an object with image_url and optional detail, or a raw MCP image block", + ); +}; + +const text = (value) => { + recordOutput({ type: "content", content: { type: "text", text: formatOutputText(value) } }); +}; + +const image = (value, detail) => { + recordOutput({ type: "content", content: normalizeImageBlock(value, detail) }); +}; + +const audio = (value) => { + if (!isMcpAudioContentBlock(value)) { + throw new TypeError("audio expects an MCP audio content block"); + } + recordOutput({ type: "content", content: value }); +}; + +const file = (value) => { + if (!isToolFile(value)) { + throw new TypeError("file expects a ToolFile value"); + } + recordOutput({ type: "file", file: value }); +}; + +const resource = (value) => { + if (!isMcpResourceContentBlock(value) && !isMcpResourceLinkContentBlock(value)) { + throw new TypeError("resource expects an MCP resource or resource_link content block"); + } + recordOutput({ type: "content", content: value }); +}; + +const notify = (value) => { + const notification = + value && typeof value === "object" && typeof value.message === "string" + ? { + message: value.message, + ...(Object.prototype.hasOwnProperty.call(value, "data") ? { data: value.data } : {}), + } + : { message: formatOutputText(value) }; + recordOutput({ type: "notification", notification }); +}; + +const yield_control = () => + new Promise((resolve, reject) => { + const requestId = crypto.randomUUID(); + pendingYieldCalls.set(requestId, { resolve, reject }); + writeIpcMessage({ type: "yield", nonce: ipcNonce, requestId }); + }); + +const yieldControl = yield_control; + const emit = (value) => { if (isToolFile(value)) { - outputs.push({ type: "file", file: value }); + file(value); return; } if (isMcpContentBlock(value)) { - outputs.push({ type: "content", content: value }); + recordOutput({ type: "content", content: value }); return; } - outputs.push({ type: "content", content: { type: "text", text: formatOutputText(value) } }); + text(value); }; const sandboxConsole = { @@ -181,10 +346,30 @@ const runUserCode = async (code) => { "tools", "console", "emit", + "text", + "image", + "audio", + "file", + "resource", + "notify", + "yield_control", + "yieldControl", `"use strict"; return (async () => {\n${code}\n})();`, ); - const result = await execute(tools, sandboxConsole, emit); + const result = await execute( + tools, + sandboxConsole, + emit, + text, + image, + audio, + file, + resource, + notify, + yield_control, + yieldControl, + ); return { result, output: outputs.length > 0 ? outputs : undefined }; }; @@ -243,6 +428,26 @@ const handleToolResult = (message) => { pending.reject(new Error(message.error)); }; +const handleYieldResult = (message) => { + if (message.nonce !== ipcNonce) { + return; + } + + const pending = pendingYieldCalls.get(message.requestId); + if (!pending) { + return; + } + + pendingYieldCalls.delete(message.requestId); + + if (message.ok) { + pending.resolve(); + return; + } + + pending.reject(new Error(message.error)); +}; + const handleHostMessage = (message) => { if (!message || typeof message !== "object") { return; @@ -255,6 +460,11 @@ const handleHostMessage = (message) => { if (message.type === "tool_result") { handleToolResult(message); + return; + } + + if (message.type === "yield_result") { + handleYieldResult(message); } }; diff --git a/packages/kernel/runtime-deno-subprocess/src/index.ts b/packages/kernel/runtime-deno-subprocess/src/index.ts index caf20ccaa..618812a56 100644 --- a/packages/kernel/runtime-deno-subprocess/src/index.ts +++ b/packages/kernel/runtime-deno-subprocess/src/index.ts @@ -4,6 +4,8 @@ import { fileURLToPath } from "node:url"; import { recoverExecutionBody, type CodeExecutor, + type CodeExecutionOptions, + type ExecuteOutputItem, type ExecuteResult, type SandboxToolInvoker, } from "@executor-js/codemode-core"; @@ -13,6 +15,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; @@ -49,6 +52,21 @@ class DenoSpawnError extends Data.TaggedError("DenoSpawnError")<{ } } +class DenoCallbackError extends Data.TaggedError("DenoCallbackError")<{ + readonly publicMessage: string; + readonly reason: unknown; +}> {} + +const isDenoCallbackError = Predicate.isTagged("DenoCallbackError") as ( + cause: unknown, +) => cause is DenoCallbackError; + +const callbackPublicMessage = (reason: unknown): string => + typeof reason === "string" && reason.length > 0 ? reason : "Deno callback failed"; + +const callbackErrorMessage = (cause: unknown): string => + isDenoCallbackError(cause) ? cause.publicMessage : "Deno callback failed"; + // --------------------------------------------------------------------------- // IPC schemas // --------------------------------------------------------------------------- @@ -69,6 +87,18 @@ const WorkerCompletedMessage = Schema.Struct({ logs: Schema.optional(Schema.Array(Schema.String)), }); +const WorkerOutputMessage = Schema.Struct({ + type: Schema.Literal("output"), + nonce: Schema.String, + item: Schema.Unknown, +}); + +const WorkerYieldMessage = Schema.Struct({ + type: Schema.Literal("yield"), + nonce: Schema.String, + requestId: Schema.String, +}); + const WorkerFailedMessage = Schema.Struct({ type: Schema.Literal("failed"), nonce: Schema.String, @@ -79,6 +109,8 @@ const WorkerFailedMessage = Schema.Struct({ const WorkerMessage = Schema.Union([ WorkerToolCallMessage, + WorkerOutputMessage, + WorkerYieldMessage, WorkerCompletedMessage, WorkerFailedMessage, ] as const); @@ -139,6 +171,13 @@ type HostToWorkerMessage = ok: boolean; value?: unknown; error?: string; + } + | { + type: "yield_result"; + nonce: string; + requestId: string; + ok: boolean; + error?: string; }; const causeMessage = (cause: Cause.Cause): string => { @@ -161,6 +200,7 @@ const executeInDeno = ( code: string, toolInvoker: SandboxToolInvoker, options: DenoSubprocessExecutorOptions, + executionOptions?: CodeExecutionOptions, ): Effect.Effect => { const recoveredBody = recoverExecutionBody(code); const denoExecutable = options.denoExecutable ?? defaultDenoExecutable(); @@ -254,6 +294,51 @@ const executeInDeno = ( const msg = yield* Queue.take(messages); switch (msg.type) { + case "output": { + yield* Effect.tryPromise({ + try: () => + Promise.resolve(executionOptions?.onOutput?.(msg.item as ExecuteOutputItem)), + catch: (reason) => + new DenoCallbackError({ + publicMessage: callbackPublicMessage(reason), + reason, + }), + }).pipe(Effect.catch(() => Effect.void)); + break; + } + + case "yield": { + const yieldResult = yield* Effect.tryPromise({ + try: () => Promise.resolve(executionOptions?.onYield?.()), + catch: (reason) => + new DenoCallbackError({ + publicMessage: callbackPublicMessage(reason), + reason, + }), + }).pipe( + Effect.map( + (): HostToWorkerMessage => ({ + type: "yield_result", + nonce, + requestId: msg.requestId, + ok: true, + }), + ), + Effect.catch((cause) => + Effect.succeed({ + type: "yield_result", + nonce, + requestId: msg.requestId, + ok: false, + error: callbackErrorMessage(cause), + }), + ), + ); + + writeMessage(worker.stdin, yieldResult); + break; + } + case "tool_call": { const toolResult = yield* toolInvoker .invoke({ path: msg.toolPath, args: msg.args }) @@ -348,6 +433,9 @@ export const isDenoAvailable = (executable: string = defaultDenoExecutable()): b export const makeDenoSubprocessExecutor = ( options: DenoSubprocessExecutorOptions = {}, ): CodeExecutor => ({ - execute: (code: string, toolInvoker: SandboxToolInvoker) => - executeInDeno(code, toolInvoker, options), + execute: ( + code: string, + toolInvoker: SandboxToolInvoker, + executionOptions?: CodeExecutionOptions, + ) => executeInDeno(code, toolInvoker, options, executionOptions), }); diff --git a/packages/kernel/runtime-dynamic-worker/src/executor.ts b/packages/kernel/runtime-dynamic-worker/src/executor.ts index b42727e24..730ed9f7d 100644 --- a/packages/kernel/runtime-dynamic-worker/src/executor.ts +++ b/packages/kernel/runtime-dynamic-worker/src/executor.ts @@ -17,6 +17,7 @@ import { recoverExecutionBody, stripTypeScript, type CodeExecutor, + type CodeExecutionOptions, type ExecuteOutputItem, type ExecuteResult, type SandboxToolInvoker, @@ -368,11 +369,29 @@ export type RunPromise = (effect: Effect.Effect) => Promise; export class ToolDispatcher extends RpcTarget { readonly #invoker: SandboxToolInvoker; readonly #runPromise: RunPromise; + readonly #executionOptions: CodeExecutionOptions | undefined; - constructor(invoker: SandboxToolInvoker, runPromise: RunPromise) { + constructor( + invoker: SandboxToolInvoker, + runPromise: RunPromise, + executionOptions?: CodeExecutionOptions, + ) { super(); this.#invoker = invoker; this.#runPromise = runPromise; + this.#executionOptions = executionOptions; + } + + async output(item: ExecuteOutputItem): Promise { + const onOutput = this.#executionOptions?.onOutput; + if (!onOutput) return; + await onOutput(item); + } + + async yieldControl(): Promise { + const onYield = this.#executionOptions?.onYield; + if (!onYield) return; + await onYield(); } async call(path: string, args: unknown): Promise { @@ -478,12 +497,17 @@ const evaluate = ( options: DynamicWorkerExecutorOptions, code: string, toolInvoker: SandboxToolInvoker, + executionOptions?: CodeExecutionOptions, ): Effect.Effect => { const timeoutMs = Math.max(100, options.timeoutMs ?? DEFAULT_TIMEOUT_MS); return Effect.gen(function* () { const context = yield* Effect.context(); - const dispatcher = new ToolDispatcher(toolInvoker, Effect.runPromiseWith(context)); + const dispatcher = new ToolDispatcher( + toolInvoker, + Effect.runPromiseWith(context), + executionOptions, + ); const entrypoint = yield* startDynamicWorker(options, code, timeoutMs); const response = yield* Effect.tryPromise({ try: () => entrypoint.evaluate(dispatcher), @@ -515,8 +539,9 @@ const runInDynamicWorker = ( options: DynamicWorkerExecutorOptions, code: string, toolInvoker: SandboxToolInvoker, + executionOptions?: CodeExecutionOptions, ): Effect.Effect => - evaluate(options, code, toolInvoker).pipe( + evaluate(options, code, toolInvoker, executionOptions).pipe( Effect.withSpan("executor.code.exec.dynamic_worker", { attributes: { "executor.runtime": "dynamic-worker" }, }), @@ -529,6 +554,9 @@ const runInDynamicWorker = ( export const makeDynamicWorkerExecutor = ( options: DynamicWorkerExecutorOptions, ): CodeExecutor => ({ - execute: (code: string, toolInvoker: SandboxToolInvoker) => - runInDynamicWorker(options, code, toolInvoker), + execute: ( + code: string, + toolInvoker: SandboxToolInvoker, + executionOptions?: CodeExecutionOptions, + ) => runInDynamicWorker(options, code, toolInvoker, executionOptions), }); diff --git a/packages/kernel/runtime-dynamic-worker/src/module-template.ts b/packages/kernel/runtime-dynamic-worker/src/module-template.ts index 32daeeba2..f0f98aaa1 100644 --- a/packages/kernel/runtime-dynamic-worker/src/module-template.ts +++ b/packages/kernel/runtime-dynamic-worker/src/module-template.ts @@ -4,7 +4,8 @@ * The module exports a `WorkerEntrypoint` subclass with an `evaluate` * method that: * 1. Captures console output into `__logs`. - * 2. Accumulates user-visible output from `text`, `file`, and `image`. + * 2. Accumulates user-visible output from `emit`, `text`, `file`, `image`, + * `audio`, `resource`, and `notify`. * 3. Creates a recursive `tools` Proxy that dispatches calls via RPC. * 4. Executes the normalised user code with a `Promise.race` timeout. * 5. Returns `{ result, output?, error?, logs }`. @@ -24,6 +25,18 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => " async evaluate(__dispatcher) {", " const __logs = [];", " const __outputs = [];", + " let __pendingOutput = Promise.resolve();", + " const __recordOutput = (item) => {", + " __outputs.push(item);", + " if (__dispatcher && typeof __dispatcher.output === 'function') {", + " __pendingOutput = __pendingOutput.then(() => __dispatcher.output(item)).catch(() => undefined);", + " }", + " };", + " const yield_control = async () => {", + " await __pendingOutput;", + " if (__dispatcher && typeof __dispatcher.yieldControl === 'function') await __dispatcher.yieldControl();", + " };", + " const yieldControl = yield_control;", ' console.log = (...a) => { __logs.push(a.map(String).join(" ")); };', ' console.warn = (...a) => { __logs.push("[warn] " + a.map(String).join(" ")); };', ' console.error = (...a) => { __logs.push("[error] " + a.map(String).join(" ")); };', @@ -71,23 +84,60 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => " return String(value);", " }", " };", + " const __IMAGE_DETAIL_META_KEY = 'codex/imageDetail';", + " const __DEFAULT_IMAGE_DETAIL = 'high';", + " const __validImageDetails = new Set(['auto', 'low', 'high', 'original']);", + " const __normalizeImageDetail = (detail) => {", + " if (detail === null || typeof detail === 'undefined') return undefined;", + " if (typeof detail !== 'string') throw new TypeError('image detail must be a string when provided');", + " const normalized = detail.toLowerCase();", + " if (!__validImageDetails.has(normalized)) throw new TypeError('image detail must be one of: auto, low, high, original');", + " return normalized;", + " };", " const __isToolFile = (value) => value && typeof value === 'object' && value._tag === 'ToolFile' && typeof value.mimeType === 'string' && value.encoding === 'base64' && typeof value.data === 'string' && typeof value.byteLength === 'number';", " const __isMcpTextContentBlock = (value) => value && typeof value === 'object' && value.type === 'text' && typeof value.text === 'string';", - " const __isMcpImageContentBlock = (value) => value && typeof value === 'object' && value.type === 'image' && typeof value.data === 'string' && typeof value.mimeType === 'string';", + " const __isMcpImageContentBlock = (value) => value && typeof value === 'object' && value.type === 'image' && typeof value.data === 'string' && (typeof value.mimeType === 'string' || typeof value.mime_type === 'string' || value.data.toLowerCase().startsWith('data:'));", " const __isMcpAudioContentBlock = (value) => value && typeof value === 'object' && value.type === 'audio' && typeof value.data === 'string' && typeof value.mimeType === 'string';", " const __isMcpResourceContentBlock = (value) => value && typeof value === 'object' && value.type === 'resource' && value.resource && typeof value.resource === 'object' && typeof value.resource.uri === 'string' && (typeof value.resource.text === 'string' || typeof value.resource.blob === 'string');", " const __isMcpResourceLinkContentBlock = (value) => value && typeof value === 'object' && value.type === 'resource_link' && typeof value.uri === 'string' && typeof value.name === 'string';", " const __isMcpContentBlock = (value) => __isMcpTextContentBlock(value) || __isMcpImageContentBlock(value) || __isMcpAudioContentBlock(value) || __isMcpResourceContentBlock(value) || __isMcpResourceLinkContentBlock(value);", - " const emit = (value) => {", - " if (__isToolFile(value)) {", - " __outputs.push({ type: 'file', file: value });", - " return;", + " const __parseDataImageUrl = (imageUrl) => {", + " if (typeof imageUrl !== 'string' || imageUrl.length === 0) throw new TypeError('image expects a non-empty data URI, an object with image_url and optional detail, or a raw MCP image block');", + " const lower = imageUrl.toLowerCase();", + " if (lower.startsWith('http://') || lower.startsWith('https://')) throw new TypeError('remote image URLs are not supported in code output; pass a base64 data URI or MCP image block');", + " const match = /^data:([^;,]+);base64,(.*)$/i.exec(imageUrl);", + " if (!match) throw new TypeError('image expects a base64 data URI or MCP image block');", + " return { mimeType: match[1], data: match[2] };", + " };", + " const __imageDetailFromMeta = (value) => {", + " const meta = value && typeof value === 'object' ? value._meta : undefined;", + " const detail = meta && typeof meta === 'object' ? meta[__IMAGE_DETAIL_META_KEY] : undefined;", + " return typeof detail === 'string' && __validImageDetails.has(detail) ? detail : undefined;", + " };", + " const __imageWithDetail = (block, detail) => ({ ...block, _meta: { ...(block._meta && typeof block._meta === 'object' ? block._meta : {}), [__IMAGE_DETAIL_META_KEY]: detail ?? __DEFAULT_IMAGE_DETAIL } });", + " const __normalizeImageBlock = (value, detailOverride) => {", + " const override = __normalizeImageDetail(detailOverride);", + " if (typeof value === 'string') return __imageWithDetail({ type: 'image', ...__parseDataImageUrl(value) }, override);", + " if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('image expects a non-empty data URI, an object with image_url and optional detail, or a raw MCP image block');", + " if (typeof value.image_url === 'string') {", + " return __imageWithDetail({ type: 'image', ...__parseDataImageUrl(value.image_url) }, override ?? __normalizeImageDetail(value.detail));", " }", - " if (__isMcpContentBlock(value)) {", - " __outputs.push({ type: 'content', content: value });", - " return;", + " if (value.type === 'image' && typeof value.data === 'string') {", + " const parsed = value.data.toLowerCase().startsWith('data:') ? __parseDataImageUrl(value.data) : { data: value.data, mimeType: typeof value.mimeType === 'string' ? value.mimeType : typeof value.mime_type === 'string' ? value.mime_type : 'application/octet-stream' };", + " return __imageWithDetail({ ...value, type: 'image', data: parsed.data, mimeType: parsed.mimeType }, override ?? __imageDetailFromMeta(value));", " }", - " __outputs.push({ type: 'content', content: { type: 'text', text: __formatOutputText(value) } });", + " throw new TypeError('image expects a non-empty data URI, an object with image_url and optional detail, or a raw MCP image block');", + " };", + " const text = (value) => { __recordOutput({ type: 'content', content: { type: 'text', text: __formatOutputText(value) } }); };", + " const image = (value, detail) => { __recordOutput({ type: 'content', content: __normalizeImageBlock(value, detail) }); };", + " const audio = (value) => { if (!__isMcpAudioContentBlock(value)) throw new TypeError('audio expects an MCP audio content block'); __recordOutput({ type: 'content', content: value }); };", + " const file = (value) => { if (!__isToolFile(value)) throw new TypeError('file expects a ToolFile value'); __recordOutput({ type: 'file', file: value }); };", + " const resource = (value) => { if (!__isMcpResourceContentBlock(value) && !__isMcpResourceLinkContentBlock(value)) throw new TypeError('resource expects an MCP resource or resource_link content block'); __recordOutput({ type: 'content', content: value }); };", + " const notify = (value) => { const notification = value && typeof value === 'object' && typeof value.message === 'string' ? { message: value.message, ...(Object.prototype.hasOwnProperty.call(value, 'data') ? { data: value.data } : {}) } : { message: __formatOutputText(value) }; __recordOutput({ type: 'notification', notification }); };", + " const emit = (value) => {", + " if (__isToolFile(value)) { file(value); return; }", + " if (__isMcpContentBlock(value)) { __recordOutput({ type: 'content', content: value }); return; }", + " text(value);", " };", " const __serializeThrownError = (err) => {", " const primary = __serializeErrorValue(err);", @@ -176,11 +226,19 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => " if (error && typeof error.message === 'string' && error.message.startsWith('Internal tool error')) return error.message;", " return null;", " };", + " const __builtinToolKeys = { '': ['search', 'describe', 'executor'], 'describe': ['tool'], 'executor': ['sources'], 'executor.sources': ['list'] };", + " const __toolKeysForPath = (path) => __builtinToolKeys[path.join('.')] ?? [];", " const __makeToolsProxy = (path = []) => new Proxy(() => undefined, {", " get(_target, prop) {", " if (prop === 'then' || typeof prop === 'symbol') return undefined;", " return __makeToolsProxy([...path, String(prop)]);", " },", + " ownKeys() {", + " return __toolKeysForPath(path);", + " },", + " getOwnPropertyDescriptor(_target, prop) {", + " return typeof prop === 'string' && __toolKeysForPath(path).includes(prop) ? { enumerable: true, configurable: true } : undefined;", + " },", " apply(_target, _thisArg, args) {", " const toolPath = path.join('.');", " if (!toolPath) throw new Error('Tool path missing in invocation');", @@ -203,8 +261,10 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => ` setTimeout(() => reject(new Error("Execution timed out after ${timeoutMs}ms")), ${timeoutMs})`, " ),", " ]);", + " await __pendingOutput;", " return { result, output: __outputs.length > 0 ? __outputs : undefined, logs: __logs };", " } catch (err) {", + " await __pendingOutput;", " return { result: undefined, output: __outputs.length > 0 ? __outputs : undefined, error: __serializeThrownError(err), logs: __logs };", " }", " }", diff --git a/packages/kernel/runtime-quickjs/src/index.ts b/packages/kernel/runtime-quickjs/src/index.ts index 27eed850e..6c93c25b2 100644 --- a/packages/kernel/runtime-quickjs/src/index.ts +++ b/packages/kernel/runtime-quickjs/src/index.ts @@ -2,6 +2,7 @@ import { recoverExecutionBody, stripTypeScript, type CodeExecutor, + type CodeExecutionOptions, type ExecuteOutputItem, type ExecuteResult, type SandboxToolInvoker, @@ -127,8 +128,16 @@ const buildExecutionSource = (code: string): string => { '"use strict";', "const __invokeTool = __executor_invokeTool;", "const __log = __executor_log;", + "const __output = __executor_output;", + "const __yield = __executor_yield;", + "const __sleep = __executor_sleep;", + "const __cancelSleep = __executor_cancelSleep;", "try { delete globalThis.__executor_invokeTool; } catch {}", "try { delete globalThis.__executor_log; } catch {}", + "try { delete globalThis.__executor_output; } catch {}", + "try { delete globalThis.__executor_yield; } catch {}", + "try { delete globalThis.__executor_sleep; } catch {}", + "try { delete globalThis.__executor_cancelSleep; } catch {}", "const __formatLogArg = (value) => {", " if (typeof value === 'string') return value;", " try {", @@ -140,6 +149,34 @@ const buildExecutionSource = (code: string): string => { "const __formatLogLine = (args) => args.map(__formatLogArg).join(' ');", "const __outputs = [];", "globalThis.__executor_outputs = __outputs;", + "const __recordOutput = (item) => { __outputs.push(item); __output(item); };", + "const yield_control = () => __yield();", + "const yieldControl = yield_control;", + "const __timers = new Map();", + "let __nextTimerId = 1;", + "const setTimeout = (callback, delay = 0, ...args) => {", + " if (typeof callback !== 'function') throw new TypeError('setTimeout callback must be a function');", + " const id = __nextTimerId++;", + " const timer = { cancelled: false };", + " __timers.set(id, timer);", + " const ms = Math.max(0, Number.isFinite(Number(delay)) ? Math.floor(Number(delay)) : 0);", + " __sleep(ms, id).then((active) => {", + " if (active !== 1 || timer.cancelled) return;", + " __timers.delete(id);", + " callback(...args);", + " }, () => {", + " __timers.delete(id);", + " });", + " return id;", + "};", + "const clearTimeout = (id) => {", + " const key = Number(id);", + " const timer = __timers.get(key);", + " if (!timer) return;", + " timer.cancelled = true;", + " __timers.delete(key);", + " __cancelSleep(key);", + "};", "const __formatOutputText = (value) => {", " if (typeof value === 'undefined') return 'undefined';", " if (value === null) return 'null';", @@ -150,24 +187,63 @@ const buildExecutionSource = (code: string): string => { " return String(value);", " }", "};", + "const __IMAGE_DETAIL_META_KEY = 'codex/imageDetail';", + "const __DEFAULT_IMAGE_DETAIL = 'high';", + "const __validImageDetails = new Set(['auto', 'low', 'high', 'original']);", + "const __normalizeImageDetail = (detail) => {", + " if (detail === null || typeof detail === 'undefined') return undefined;", + " if (typeof detail !== 'string') throw new TypeError('image detail must be a string when provided');", + " const normalized = detail.toLowerCase();", + " if (!__validImageDetails.has(normalized)) throw new TypeError('image detail must be one of: auto, low, high, original');", + " return normalized;", + "};", "const __isToolFile = (value) => value && typeof value === 'object' && value._tag === 'ToolFile' && typeof value.mimeType === 'string' && value.encoding === 'base64' && typeof value.data === 'string' && typeof value.byteLength === 'number';", "const __isMcpTextContentBlock = (value) => value && typeof value === 'object' && value.type === 'text' && typeof value.text === 'string';", - "const __isMcpImageContentBlock = (value) => value && typeof value === 'object' && value.type === 'image' && typeof value.data === 'string' && typeof value.mimeType === 'string';", + "const __isMcpImageContentBlock = (value) => value && typeof value === 'object' && value.type === 'image' && typeof value.data === 'string' && (typeof value.mimeType === 'string' || typeof value.mime_type === 'string' || value.data.toLowerCase().startsWith('data:'));", "const __isMcpAudioContentBlock = (value) => value && typeof value === 'object' && value.type === 'audio' && typeof value.data === 'string' && typeof value.mimeType === 'string';", "const __isMcpResourceContentBlock = (value) => value && typeof value === 'object' && value.type === 'resource' && value.resource && typeof value.resource === 'object' && typeof value.resource.uri === 'string' && (typeof value.resource.text === 'string' || typeof value.resource.blob === 'string');", "const __isMcpResourceLinkContentBlock = (value) => value && typeof value === 'object' && value.type === 'resource_link' && typeof value.uri === 'string' && typeof value.name === 'string';", "const __isMcpContentBlock = (value) => __isMcpTextContentBlock(value) || __isMcpImageContentBlock(value) || __isMcpAudioContentBlock(value) || __isMcpResourceContentBlock(value) || __isMcpResourceLinkContentBlock(value);", - "const emit = (value) => {", - " if (__isToolFile(value)) {", - " __outputs.push({ type: 'file', file: value });", - " return;", + "const __parseDataImageUrl = (imageUrl) => {", + " if (typeof imageUrl !== 'string' || imageUrl.length === 0) throw new TypeError('image expects a non-empty data URI, an object with image_url and optional detail, or a raw MCP image block');", + " const lower = imageUrl.toLowerCase();", + " if (lower.startsWith('http://') || lower.startsWith('https://')) throw new TypeError('remote image URLs are not supported in code output; pass a base64 data URI or MCP image block');", + " const match = /^data:([^;,]+);base64,(.*)$/i.exec(imageUrl);", + " if (!match) throw new TypeError('image expects a base64 data URI or MCP image block');", + " return { mimeType: match[1], data: match[2] };", + "};", + "const __imageDetailFromMeta = (value) => {", + " const meta = value && typeof value === 'object' ? value._meta : undefined;", + " const detail = meta && typeof meta === 'object' ? meta[__IMAGE_DETAIL_META_KEY] : undefined;", + " return typeof detail === 'string' && __validImageDetails.has(detail) ? detail : undefined;", + "};", + "const __imageWithDetail = (block, detail) => ({ ...block, _meta: { ...(block._meta && typeof block._meta === 'object' ? block._meta : {}), [__IMAGE_DETAIL_META_KEY]: detail ?? __DEFAULT_IMAGE_DETAIL } });", + "const __normalizeImageBlock = (value, detailOverride) => {", + " const override = __normalizeImageDetail(detailOverride);", + " if (typeof value === 'string') return __imageWithDetail({ type: 'image', ...__parseDataImageUrl(value) }, override);", + " if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('image expects a non-empty data URI, an object with image_url and optional detail, or a raw MCP image block');", + " if (typeof value.image_url === 'string') {", + " return __imageWithDetail({ type: 'image', ...__parseDataImageUrl(value.image_url) }, override ?? __normalizeImageDetail(value.detail));", " }", - " if (__isMcpContentBlock(value)) {", - " __outputs.push({ type: 'content', content: value });", - " return;", + " if (value.type === 'image' && typeof value.data === 'string') {", + " const parsed = value.data.toLowerCase().startsWith('data:') ? __parseDataImageUrl(value.data) : { data: value.data, mimeType: typeof value.mimeType === 'string' ? value.mimeType : typeof value.mime_type === 'string' ? value.mime_type : 'application/octet-stream' };", + " return __imageWithDetail({ ...value, type: 'image', data: parsed.data, mimeType: parsed.mimeType }, override ?? __imageDetailFromMeta(value));", " }", - " __outputs.push({ type: 'content', content: { type: 'text', text: __formatOutputText(value) } });", + " throw new TypeError('image expects a non-empty data URI, an object with image_url and optional detail, or a raw MCP image block');", + "};", + "const text = (value) => { __recordOutput({ type: 'content', content: { type: 'text', text: __formatOutputText(value) } }); };", + "const image = (value, detail) => { __recordOutput({ type: 'content', content: __normalizeImageBlock(value, detail) }); };", + "const audio = (value) => { if (!__isMcpAudioContentBlock(value)) throw new TypeError('audio expects an MCP audio content block'); __recordOutput({ type: 'content', content: value }); };", + "const file = (value) => { if (!__isToolFile(value)) throw new TypeError('file expects a ToolFile value'); __recordOutput({ type: 'file', file: value }); };", + "const resource = (value) => { if (!__isMcpResourceContentBlock(value) && !__isMcpResourceLinkContentBlock(value)) throw new TypeError('resource expects an MCP resource or resource_link content block'); __recordOutput({ type: 'content', content: value }); };", + "const notify = (value) => { const notification = value && typeof value === 'object' && typeof value.message === 'string' ? { message: value.message, ...(Object.prototype.hasOwnProperty.call(value, 'data') ? { data: value.data } : {}) } : { message: __formatOutputText(value) }; __recordOutput({ type: 'notification', notification }); };", + "const emit = (value) => {", + " if (__isToolFile(value)) { file(value); return; }", + " if (__isMcpContentBlock(value)) { __recordOutput({ type: 'content', content: value }); return; }", + " text(value);", "};", + "const __builtinToolKeys = { '': ['search', 'describe', 'executor'], 'describe': ['tool'], 'executor': ['sources'], 'executor.sources': ['list'] };", + "const __toolKeysForPath = (path) => __builtinToolKeys[path.join('.')] ?? [];", "const __makeToolsProxy = (path = []) => new Proxy(() => undefined, {", " get(_target, prop) {", " if (prop === 'then' || typeof prop === 'symbol') {", @@ -175,6 +251,12 @@ const buildExecutionSource = (code: string): string => { " }", " return __makeToolsProxy([...path, String(prop)]);", " },", + " ownKeys() {", + " return __toolKeysForPath(path);", + " },", + " getOwnPropertyDescriptor(_target, prop) {", + " return typeof prop === 'string' && __toolKeysForPath(path).includes(prop) ? { enumerable: true, configurable: true } : undefined;", + " },", " apply(_target, _thisArg, args) {", " const toolPath = path.join('.');", " if (!toolPath) {", @@ -235,8 +317,151 @@ const createLogBridge = (context: QuickJSContext, logs: string[]): QuickJSHandle return context.undefined; }); +const createOutputBridge = ( + context: QuickJSContext, + executionOptions: CodeExecutionOptions | undefined, +): QuickJSHandle => + context.newFunction("__executor_output", (itemHandle) => { + const item = context.dump(itemHandle) as ExecuteOutputItem; + void executionOptions?.onOutput?.(item); + return context.undefined; + }); + type RunPromise = (effect: Effect.Effect) => Promise; +const createYieldBridge = ( + context: QuickJSContext, + pendingDeferreds: Set, + executionOptions: CodeExecutionOptions | undefined, +): QuickJSHandle => + context.newFunction("__executor_yield", () => { + const deferred = context.newPromise(); + pendingDeferreds.add(deferred); + deferred.settled.finally(() => { + pendingDeferreds.delete(deferred); + }); + + let yielded: void | Promise; + try { + yielded = executionOptions?.onYield?.(); + } catch (cause) { + if (deferred.alive) { + const errorHandle = context.newError( + cause instanceof Error ? cause.message : String(cause), + ); + deferred.reject(errorHandle); + errorHandle.dispose(); + } + return deferred.handle; + } + + void Promise.resolve(yielded).then( + () => { + if (!deferred.alive) return; + deferred.resolve(); + }, + (cause) => { + if (!deferred.alive) return; + const errorHandle = context.newError( + cause instanceof Error ? cause.message : String(cause), + ); + deferred.reject(errorHandle); + errorHandle.dispose(); + }, + ); + + return deferred.handle; + }); + +const createSleepBridge = ( + context: QuickJSContext, + pendingDeferreds: Set, + pendingTimers: Map< + number, + { + readonly timeout: ReturnType; + readonly deferred: QuickJSDeferredPromise; + } + >, +): QuickJSHandle => + context.newFunction("__executor_sleep", (delayHandle, idHandle) => { + const rawDelay = + delayHandle === undefined || context.typeof(delayHandle) === "undefined" + ? 0 + : context.dump(delayHandle); + const rawId = + idHandle === undefined || context.typeof(idHandle) === "undefined" + ? undefined + : context.dump(idHandle); + const timerId = + typeof rawId === "number" && Number.isFinite(rawId) ? Math.floor(rawId) : undefined; + const delayMs = + typeof rawDelay === "number" && Number.isFinite(rawDelay) + ? Math.max(0, Math.floor(rawDelay)) + : 0; + const deferred = context.newPromise(); + pendingDeferreds.add(deferred); + + const timer = setTimeout(() => { + if (timerId !== undefined) { + const current = pendingTimers.get(timerId); + if (!current || current.deferred !== deferred) return; + pendingTimers.delete(timerId); + } + if (!deferred.alive) return; + const activeHandle = context.newNumber(1); + deferred.resolve(activeHandle); + activeHandle.dispose(); + }, delayMs); + if (timerId !== undefined) { + pendingTimers.set(timerId, { timeout: timer, deferred }); + } + + deferred.settled.finally(() => { + clearTimeout(timer); + if (timerId !== undefined) { + const current = pendingTimers.get(timerId); + if (current?.deferred === deferred) { + pendingTimers.delete(timerId); + } + } + pendingDeferreds.delete(deferred); + }); + + return deferred.handle; + }); + +const createCancelSleepBridge = ( + context: QuickJSContext, + pendingTimers: Map< + number, + { + readonly timeout: ReturnType; + readonly deferred: QuickJSDeferredPromise; + } + >, +): QuickJSHandle => + context.newFunction("__executor_cancelSleep", (idHandle) => { + const rawId = + idHandle === undefined || context.typeof(idHandle) === "undefined" + ? undefined + : context.dump(idHandle); + const timerId = + typeof rawId === "number" && Number.isFinite(rawId) ? Math.floor(rawId) : undefined; + if (timerId === undefined) return context.undefined; + + const current = pendingTimers.get(timerId); + if (!current) return context.undefined; + clearTimeout(current.timeout); + pendingTimers.delete(timerId); + if (current.deferred.alive) { + const cancelledHandle = context.newNumber(0); + current.deferred.resolve(cancelledHandle); + cancelledHandle.dispose(); + } + return context.undefined; + }); + const createToolBridge = ( context: QuickJSContext, toolInvoker: SandboxToolInvoker, @@ -362,11 +587,19 @@ const evaluateInQuickJs = async ( code: string, toolInvoker: SandboxToolInvoker, runPromise: RunPromise, + executionOptions?: CodeExecutionOptions, ): Promise => { const timeoutMs = Math.max(100, options.timeoutMs ?? DEFAULT_TIMEOUT_MS); const deadlineMs = Date.now() + timeoutMs; const logs: string[] = []; const pendingDeferreds = new Set(); + const pendingTimers = new Map< + number, + { + readonly timeout: ReturnType; + readonly deferred: QuickJSDeferredPromise; + } + >(); const QuickJS = await resolveQuickJS(); const runtime = QuickJS.newRuntime(); @@ -382,6 +615,22 @@ const evaluateInQuickJs = async ( context.setProp(context.global, "__executor_log", logBridge); logBridge.dispose(); + const outputBridge = createOutputBridge(context, executionOptions); + context.setProp(context.global, "__executor_output", outputBridge); + outputBridge.dispose(); + + const yieldBridge = createYieldBridge(context, pendingDeferreds, executionOptions); + context.setProp(context.global, "__executor_yield", yieldBridge); + yieldBridge.dispose(); + + const sleepBridge = createSleepBridge(context, pendingDeferreds, pendingTimers); + context.setProp(context.global, "__executor_sleep", sleepBridge); + sleepBridge.dispose(); + + const cancelSleepBridge = createCancelSleepBridge(context, pendingTimers); + context.setProp(context.global, "__executor_cancelSleep", cancelSleepBridge); + cancelSleepBridge.dispose(); + const toolBridge = createToolBridge(context, toolInvoker, pendingDeferreds, runPromise); context.setProp(context.global, "__executor_invokeTool", toolBridge); toolBridge.dispose(); @@ -444,6 +693,11 @@ const evaluateInQuickJs = async ( stateHandle.dispose(); } } finally { + for (const { timeout } of pendingTimers.values()) { + clearTimeout(timeout); + } + pendingTimers.clear(); + for (const deferred of pendingDeferreds) { if (deferred.alive) { deferred.dispose(); @@ -468,12 +722,13 @@ const runInQuickJs = ( options: QuickJsExecutorOptions, code: string, toolInvoker: SandboxToolInvoker, + executionOptions?: CodeExecutionOptions, ): Effect.Effect => Effect.gen(function* () { const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); return yield* Effect.tryPromise({ - try: () => evaluateInQuickJs(options, code, toolInvoker, runPromise), + try: () => evaluateInQuickJs(options, code, toolInvoker, runPromise, executionOptions), catch: (cause) => new QuickJsExecutionError({ message: String(cause) }), }); }).pipe( @@ -485,6 +740,9 @@ const runInQuickJs = ( export const makeQuickJsExecutor = ( options: QuickJsExecutorOptions = {}, ): CodeExecutor => ({ - execute: (code: string, toolInvoker: SandboxToolInvoker) => - runInQuickJs(options, code, toolInvoker), + execute: ( + code: string, + toolInvoker: SandboxToolInvoker, + executionOptions?: CodeExecutionOptions, + ) => runInQuickJs(options, code, toolInvoker, executionOptions), });