chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
19
third_party/pi/packages/codemode/src/cloudflare-modules.d.ts
vendored
Normal file
19
third_party/pi/packages/codemode/src/cloudflare-modules.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
// Modules that only exist when cloudflare.ts is built by Wrangler. Consumers get the real
|
||||
// declarations from @cloudflare/workers-types or `wrangler types`; these keep this repository's
|
||||
// type check free of a Workers type dependency and are not published.
|
||||
|
||||
declare module "cloudflare:workers" {
|
||||
export abstract class DurableObject<Env = unknown> {
|
||||
protected ctx: unknown;
|
||||
protected env: Env;
|
||||
constructor(ctx: unknown, env: Env);
|
||||
}
|
||||
}
|
||||
|
||||
// A pattern, not the exact specifier: coding-agent declares "quickjs-wasi/quickjs.wasm" as a path
|
||||
// for Bun, and an exact declaration takes precedence where both are visible. cloudflare.ts casts the
|
||||
// import to the compiled module Wrangler actually produces.
|
||||
declare module "*/quickjs.wasm" {
|
||||
const module: unknown;
|
||||
export default module;
|
||||
}
|
||||
75
third_party/pi/packages/codemode/src/cloudflare.ts
vendored
Normal file
75
third_party/pi/packages/codemode/src/cloudflare.ts
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Cloudflare Workers entry, for Workers built with Wrangler.
|
||||
*
|
||||
* Wrangler's default `CompiledWasm` rule turns the `.wasm` import below into a
|
||||
* `WebAssembly.Module`; Workers cannot compile WebAssembly from bytes at runtime. Other bundlers
|
||||
* do not support the import: use `@earendil-works/pi-codemode/portable` with your own module there.
|
||||
*
|
||||
* Two ways to run codemode on Cloudflare:
|
||||
* - `RemoteCodemodeSandbox` with a {@link CodemodeSandboxDurableObject} deployed as its own
|
||||
* Worker: scripts run in a separate isolate and never block the caller. Use this from a Durable
|
||||
* Object that serves a session.
|
||||
* - {@link createInlineCodemodeSandbox}: scripts run on the caller's thread. Simplest; a script
|
||||
* that computes blocks the caller until it awaits a tool or uses up its interrupt budget.
|
||||
*/
|
||||
import { DurableObject } from "cloudflare:workers";
|
||||
import quickjsWasmImport from "quickjs-wasi/quickjs.wasm";
|
||||
import {
|
||||
DEFAULT_INTERRUPT_BUDGET,
|
||||
InlineCodemodeSandbox,
|
||||
type InlineCodemodeSandboxOptions,
|
||||
} from "./runtime/inline.ts";
|
||||
import { type CodemodeRemoteExchange, type CodemodeRemoteRequest, serveCodemodeRemote } from "./runtime/remote.ts";
|
||||
import type { CodemodeWasmModule } from "./wasm.ts";
|
||||
|
||||
export * from "./portable.ts";
|
||||
|
||||
// Wrangler's `CompiledWasm` rule makes this a compiled module; the declaration cannot say so (see
|
||||
// cloudflare-modules.d.ts).
|
||||
const quickjsWasm = quickjsWasmImport as unknown as CodemodeWasmModule;
|
||||
|
||||
/** Default VM memory limit of {@link CodemodeSandboxDurableObject}. Isolates have 128 MB in total. */
|
||||
export const DEFAULT_REMOTE_MEMORY_LIMIT_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
/** `quickjs-wasi/quickjs.wasm`, compiled by Wrangler. */
|
||||
export const cloudflareQuickJSWasm: CodemodeWasmModule = quickjsWasm;
|
||||
|
||||
/** `InlineCodemodeSandbox` with the QuickJS module Wrangler bundled. */
|
||||
export function createInlineCodemodeSandbox(options: Omit<InlineCodemodeSandboxOptions, "wasm"> = {}) {
|
||||
return new InlineCodemodeSandbox({ ...options, wasm: quickjsWasm });
|
||||
}
|
||||
|
||||
/**
|
||||
* The remote end of `RemoteCodemodeSandbox`: a Durable Object whose one RPC method runs a script's
|
||||
* VM and calls back into the client for every tool call.
|
||||
*
|
||||
* Deploy it as its own Worker and bind it from the Worker that runs your agent with `script_name`.
|
||||
* A separate Worker is what keeps scripts off the agent's thread: a class in the same Worker can
|
||||
* share an isolate, and therefore a thread, with the objects that call it.
|
||||
*
|
||||
* The object keeps no state between executions and never touches storage. Executions that reach
|
||||
* the same instance share its thread: they interleave while they wait on tools, and a script that
|
||||
* computes delays the others. Name instances per session (`getByName(sessionId)`) to keep one
|
||||
* session's scripts together, or per execution for full independence.
|
||||
*
|
||||
* Limits apply to every request, whatever the client asks for. Override the fields in a subclass:
|
||||
*
|
||||
* export class CodemodeSandbox extends CodemodeSandboxDurableObject {
|
||||
* protected maxInterruptBudget = 200_000;
|
||||
* }
|
||||
*/
|
||||
export class CodemodeSandboxDurableObject<Env = unknown> extends DurableObject<Env> {
|
||||
/** Interrupt polls per execution; the client may only ask for less. See `InlineCodemodeSandboxOptions`. */
|
||||
protected maxInterruptBudget: number = DEFAULT_INTERRUPT_BUDGET;
|
||||
/** VM memory per execution; the client may only ask for less. */
|
||||
protected maxMemoryLimitBytes: number = DEFAULT_REMOTE_MEMORY_LIMIT_BYTES;
|
||||
|
||||
/** Called by `RemoteCodemodeSandbox` over RPC. Resolves when the script has settled or was stopped. */
|
||||
execute(request: CodemodeRemoteRequest, exchange: CodemodeRemoteExchange): Promise<void> {
|
||||
return serveCodemodeRemote(request, exchange, {
|
||||
wasm: quickjsWasm,
|
||||
maxInterruptBudget: this.maxInterruptBudget,
|
||||
maxMemoryLimitBytes: this.maxMemoryLimitBytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
340
third_party/pi/packages/codemode/src/declarations.ts
vendored
Normal file
340
third_party/pi/packages/codemode/src/declarations.ts
vendored
Normal file
@@ -0,0 +1,340 @@
|
||||
import { toCodemodeIdentifier } from "./identifier.ts";
|
||||
|
||||
export { toCodemodeIdentifier };
|
||||
|
||||
import type { CodemodeJsonSchema, CodemodeTool } from "./types.ts";
|
||||
|
||||
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
||||
const INDENT = " ";
|
||||
/** Largest rendered input type, in characters, before it becomes `unknown`. */
|
||||
export const DEFAULT_INPUT_SCHEMA_MAX_CHARS = 16_000;
|
||||
/** Local `$ref` expansions per rendered schema, so shared definitions cannot blow up the output. */
|
||||
const MAX_REF_EXPANSIONS = 32;
|
||||
/** JSON Schema keywords with a number or string value shown as `@keyword value` property tags. */
|
||||
const TAG_KEYWORDS = [
|
||||
"minimum",
|
||||
"exclusiveMinimum",
|
||||
"maximum",
|
||||
"exclusiveMaximum",
|
||||
"multipleOf",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"pattern",
|
||||
"format",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
];
|
||||
/** Tag keywords whose values all hold at once, so only the largest (lower) or smallest (upper) matters. */
|
||||
const LOWER_BOUNDS = new Set(["minimum", "exclusiveMinimum", "minLength", "minItems"]);
|
||||
const UPPER_BOUNDS = new Set(["maximum", "exclusiveMaximum", "maxLength", "maxItems"]);
|
||||
/** Longest property tag; longer ones (large defaults, long patterns) are left out. */
|
||||
const MAX_TAG_CHARS = 80;
|
||||
/** Characters that end a `//` comment. */
|
||||
const LINE_TERMINATORS = /[\r\n\u2028\u2029]/g;
|
||||
|
||||
export interface RenderDeclarationsOptions {
|
||||
tools?: readonly CodemodeTool[];
|
||||
globals?: readonly CodemodeTool[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render TypeScript declarations for the script-visible API. Tools become members of
|
||||
* `declare const tools`, globals become `declare function` statements, and `ns.member` globals
|
||||
* members of `declare const ns`. Descriptions become doc comments; schemas become types.
|
||||
*/
|
||||
export function renderDeclarations(options: RenderDeclarationsOptions): string {
|
||||
const sections: string[] = [];
|
||||
const tools = options.tools ?? [];
|
||||
if (tools.length > 0) {
|
||||
const members = tools.map(
|
||||
(tool) => `${docComment(tool.description, INDENT)}${INDENT}${renderToolSignature(tool)}`,
|
||||
);
|
||||
sections.push(`declare const tools: {\n${members.join("\n")}\n};`);
|
||||
}
|
||||
const namespaces = new Map<string, string[]>();
|
||||
for (const global of options.globals ?? []) {
|
||||
const dot = global.name.indexOf(".");
|
||||
if (dot === -1) {
|
||||
sections.push(renderGlobal(`declare function ${global.name}`, global, ""));
|
||||
continue;
|
||||
}
|
||||
const namespace = global.name.slice(0, dot);
|
||||
const members = namespaces.get(namespace) ?? [];
|
||||
if (members.length === 0) namespaces.set(namespace, members);
|
||||
members.push(renderGlobal(global.name.slice(dot + 1), global, INDENT));
|
||||
}
|
||||
for (const [namespace, members] of namespaces) {
|
||||
sections.push(`declare const ${namespace}: {\n${members.join("\n")}\n};`);
|
||||
}
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* One tool as a member of the `tools` object: `name(args: T): Promise<R>;` with the
|
||||
* name as the identifier scripts use. Input types longer than `inputMaxChars` render as `unknown`.
|
||||
*/
|
||||
export function renderToolSignature(
|
||||
tool: Pick<CodemodeTool, "name" | "inputSchema" | "outputSchema">,
|
||||
options: { inputMaxChars?: number } = {},
|
||||
): string {
|
||||
const input =
|
||||
tool.inputSchema === undefined
|
||||
? "unknown"
|
||||
: schemaToType(tool.inputSchema, { maxChars: options.inputMaxChars ?? DEFAULT_INPUT_SCHEMA_MAX_CHARS });
|
||||
return `${toCodemodeIdentifier(tool.name)}(args: ${input}): Promise<${renderToolOutputType(tool.outputSchema)}>;`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool's sample: the description followed by the tool's declaration. Used for tool
|
||||
* listings and `ALL_TOOLS` entries.
|
||||
*/
|
||||
export function renderToolSample(
|
||||
tool: Pick<CodemodeTool, "name" | "description" | "inputSchema" | "outputSchema">,
|
||||
options: { inputMaxChars?: number } = {},
|
||||
): string {
|
||||
const declaration = `declare const tools: { ${renderToolSignature(tool, options)} };`;
|
||||
return `${tool.description?.trim() ?? ""}\n\ncodemode tool declaration:\n\`\`\`ts\n${declaration}\n\`\`\``;
|
||||
}
|
||||
|
||||
/** The type a tool call resolves to: the output schema's type, and `unknown` without a schema. */
|
||||
export function renderToolOutputType(schema: CodemodeJsonSchema | undefined): string {
|
||||
return schema === undefined ? "unknown" : schemaToType(schema);
|
||||
}
|
||||
|
||||
function renderGlobal(head: string, global: CodemodeTool, indent: string): string {
|
||||
if (global.signature !== undefined) {
|
||||
return `${docComment(global.description, indent)}${indent}${head}${global.signature};`;
|
||||
}
|
||||
const input = global.inputSchema === undefined ? "unknown" : schemaToType(global.inputSchema);
|
||||
const output = global.outputSchema === undefined ? "unknown" : schemaToType(global.outputSchema);
|
||||
return `${docComment(global.description, indent)}${indent}${head}(args: ${input}): Promise<${output}>;`;
|
||||
}
|
||||
|
||||
function docComment(description: string | undefined, indent: string): string {
|
||||
const text = description?.trim();
|
||||
if (!text) return "";
|
||||
const lines = text.replaceAll("*/", "*\\/").split(/\r?\n/);
|
||||
if (lines.length === 1) return `${indent}/** ${lines[0]} */\n`;
|
||||
return `${indent}/**\n${lines.map((line) => `${indent} *${line ? ` ${line}` : ""}`).join("\n")}\n${indent} */\n`;
|
||||
}
|
||||
|
||||
function propertyKey(name: string): string {
|
||||
return IDENTIFIER.test(name) ? name : JSON.stringify(name);
|
||||
}
|
||||
|
||||
function union(types: string[]): string {
|
||||
const unique = [...new Set(types)];
|
||||
if (unique.includes("unknown")) return "unknown";
|
||||
return unique.length === 0 ? "never" : unique.join(" | ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a JSON Schema to a TypeScript type expression: objects on one line (`{ a: string; b?: number; }`) with properties sorted by name,
|
||||
* or one property per line with `//` comments when a property has a description or constraints the
|
||||
* type cannot express (`// @integer @minimum 1 @default 20`); `Array<T>` for arrays. Local references
|
||||
* (`#/$defs/...`, `#/definitions/...`) resolve against `schema`; recursive and remote references
|
||||
* render as `unknown`. A result longer than `maxChars` is rendered again without the constraint tags,
|
||||
* and renders as `unknown` if it is still too long.
|
||||
*/
|
||||
export function schemaToType(schema: CodemodeJsonSchema, options: { maxChars?: number } = {}): string {
|
||||
const render = (tags: boolean) => toType(schema, { root: schema, resolving: new Set(), expansions: 0, tags });
|
||||
const type = render(true);
|
||||
if (options.maxChars === undefined || type.length <= options.maxChars) return type;
|
||||
const untagged = render(false);
|
||||
return untagged.length <= options.maxChars ? untagged : "unknown";
|
||||
}
|
||||
|
||||
interface SchemaContext {
|
||||
root: CodemodeJsonSchema;
|
||||
/** References being expanded on the current path, to stop at recursive types. */
|
||||
resolving: Set<string>;
|
||||
expansions: number;
|
||||
/** Whether property comments include constraint tags. */
|
||||
tags: boolean;
|
||||
}
|
||||
|
||||
function resolveRef(ref: string, root: CodemodeJsonSchema): CodemodeJsonSchema | undefined {
|
||||
if (ref !== "#" && !ref.startsWith("#/")) return undefined;
|
||||
let current: unknown = root;
|
||||
for (const segment of ref.slice(2).split("/").filter(Boolean)) {
|
||||
const key = decodeURIComponent(segment).replaceAll("~1", "/").replaceAll("~0", "~");
|
||||
if (typeof current !== "object" || current === null || Array.isArray(current) || !(key in current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
if (typeof current === "boolean") return current;
|
||||
return typeof current === "object" && current !== null && !Array.isArray(current)
|
||||
? (current as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function toType(schema: CodemodeJsonSchema, context: SchemaContext): string {
|
||||
if (schema === true) return "unknown";
|
||||
if (schema === false) return "never";
|
||||
if (typeof schema !== "object" || schema === null || Array.isArray(schema)) return "unknown";
|
||||
if (typeof schema.$ref === "string") {
|
||||
const ref = schema.$ref;
|
||||
if (context.resolving.has(ref) || context.expansions >= MAX_REF_EXPANSIONS) return "unknown";
|
||||
const target = resolveRef(ref, context.root);
|
||||
if (target === undefined) return "unknown";
|
||||
context.expansions++;
|
||||
context.resolving.add(ref);
|
||||
try {
|
||||
return toType(target, context);
|
||||
} finally {
|
||||
context.resolving.delete(ref);
|
||||
}
|
||||
}
|
||||
|
||||
if ("const" in schema) return JSON.stringify(schema.const) ?? "unknown";
|
||||
if (Array.isArray(schema.enum)) return union(schema.enum.map((value) => JSON.stringify(value) ?? "unknown"));
|
||||
|
||||
const variants = Array.isArray(schema.anyOf) ? schema.anyOf : Array.isArray(schema.oneOf) ? schema.oneOf : undefined;
|
||||
if (variants) return union(variants.map((variant) => toType(variant as CodemodeJsonSchema, context)));
|
||||
if (Array.isArray(schema.allOf)) {
|
||||
const parts = schema.allOf
|
||||
.map((part) => toType(part as CodemodeJsonSchema, context))
|
||||
.filter((part) => part !== "unknown");
|
||||
return parts.length === 0
|
||||
? "unknown"
|
||||
: parts.map((part) => (part.includes(" | ") ? `(${part})` : part)).join(" & ");
|
||||
}
|
||||
|
||||
const type = schema.type;
|
||||
if (Array.isArray(type)) return union(type.map((entry) => toType({ ...schema, type: entry }, context)));
|
||||
switch (type) {
|
||||
case "string":
|
||||
return "string";
|
||||
case "number":
|
||||
case "integer":
|
||||
return "number";
|
||||
case "boolean":
|
||||
return "boolean";
|
||||
case "null":
|
||||
return "null";
|
||||
case "array":
|
||||
return arrayType(schema, context);
|
||||
case "object":
|
||||
return objectType(schema, context);
|
||||
case undefined:
|
||||
if ("properties" in schema || "additionalProperties" in schema || "required" in schema) {
|
||||
return objectType(schema, context);
|
||||
}
|
||||
if ("items" in schema || "prefixItems" in schema) return arrayType(schema, context);
|
||||
return "unknown";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function arrayType(schema: Record<string, unknown>, context: SchemaContext): string {
|
||||
if (schema.items !== undefined && !Array.isArray(schema.items)) {
|
||||
return `Array<${toType(schema.items as CodemodeJsonSchema, context)}>`;
|
||||
}
|
||||
const tuple = Array.isArray(schema.prefixItems)
|
||||
? schema.prefixItems
|
||||
: Array.isArray(schema.items)
|
||||
? schema.items
|
||||
: [];
|
||||
if (tuple.length > 0) return `[${tuple.map((item) => toType(item as CodemodeJsonSchema, context)).join(", ")}]`;
|
||||
return "unknown[]";
|
||||
}
|
||||
|
||||
function descriptionOf(property: unknown): string {
|
||||
const description = (property as { description?: unknown } | null | undefined)?.description;
|
||||
return typeof description === "string" ? description.trim() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Constraints of a property that its TypeScript type cannot express, as JSDoc-style tags:
|
||||
* `@integer @minimum 1 @maximum 100 @default 20`. Empty when there are none.
|
||||
*/
|
||||
function constraintTags(schema: unknown): string {
|
||||
if (typeof schema !== "object" || schema === null || Array.isArray(schema)) return "";
|
||||
const property = schema as Record<string, unknown>;
|
||||
// Optional fields such as Pydantic's `int | None` keep their constraints in the non-null branch.
|
||||
// The branch's constraints apply together with the property's, so both are read.
|
||||
const variants = Array.isArray(property.anyOf) ? property.anyOf : property.oneOf;
|
||||
const nonNull: unknown[] = Array.isArray(variants)
|
||||
? variants.filter((variant) => (variant as { type?: unknown } | null)?.type !== "null")
|
||||
: [];
|
||||
const branch = nonNull.length === 1 ? nonNull[0] : undefined;
|
||||
const branches =
|
||||
typeof branch === "object" && branch !== null && !Array.isArray(branch)
|
||||
? [property, branch as Record<string, unknown>]
|
||||
: [property];
|
||||
// Draft-04 and OpenAPI 3.0 make `minimum`/`maximum` exclusive with a boolean `exclusiveMinimum`/`exclusiveMaximum`.
|
||||
const schemas = branches.map(
|
||||
(schema): Record<string, unknown> => ({
|
||||
...schema,
|
||||
...(schema.exclusiveMinimum === true ? { minimum: undefined, exclusiveMinimum: schema.minimum } : {}),
|
||||
...(schema.exclusiveMaximum === true ? { maximum: undefined, exclusiveMaximum: schema.maximum } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
const tags: string[] = [];
|
||||
const isInteger = (type: unknown) => type === "integer" || (Array.isArray(type) && type.includes("integer"));
|
||||
if (schemas.some((schema) => isInteger(schema.type))) tags.push("@integer");
|
||||
for (const keyword of TAG_KEYWORDS) {
|
||||
const values = schemas.map((schema) => schema[keyword]);
|
||||
if (LOWER_BOUNDS.has(keyword) || UPPER_BOUNDS.has(keyword)) {
|
||||
const numbers = values.filter((value) => typeof value === "number");
|
||||
if (numbers.length === 0) continue;
|
||||
tags.push(`@${keyword} ${LOWER_BOUNDS.has(keyword) ? Math.max(...numbers) : Math.min(...numbers)}`);
|
||||
continue;
|
||||
}
|
||||
for (const value of new Set(values)) {
|
||||
if (typeof value === "number" || typeof value === "string") tags.push(`@${keyword} ${value}`);
|
||||
}
|
||||
}
|
||||
if (schemas.some((schema) => schema.uniqueItems === true)) tags.push("@uniqueItems");
|
||||
// Annotations, unlike constraints, come from the property itself.
|
||||
if (property.default !== undefined && property.default !== null) {
|
||||
tags.push(`@default ${JSON.stringify(property.default)}`);
|
||||
}
|
||||
if (property.deprecated === true) tags.push("@deprecated");
|
||||
return tags
|
||||
.filter((tag) => tag.length <= MAX_TAG_CHARS)
|
||||
.map((tag) => tag.replace(LINE_TERMINATORS, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function objectType(schema: Record<string, unknown>, context: SchemaContext): string {
|
||||
const properties =
|
||||
typeof schema.properties === "object" && schema.properties !== null && !Array.isArray(schema.properties)
|
||||
? (schema.properties as Record<string, unknown>)
|
||||
: {};
|
||||
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
||||
const names = Object.keys(properties).sort();
|
||||
const members = names.map((name) => {
|
||||
const optional = required.has(name) ? "" : "?";
|
||||
return `${propertyKey(name)}${optional}: ${toType(properties[name] as CodemodeJsonSchema, context)};`;
|
||||
});
|
||||
const additional = schema.additionalProperties;
|
||||
if (additional !== undefined && additional !== false) {
|
||||
const type = additional === true ? "unknown" : toType(additional as CodemodeJsonSchema, context);
|
||||
members.push(`[key: string]: ${type};`);
|
||||
} else if (additional === undefined && names.length === 0) {
|
||||
members.push("[key: string]: unknown;");
|
||||
}
|
||||
if (members.length === 0) return "{}";
|
||||
const comments = names.map((name) => {
|
||||
const lines = descriptionOf(properties[name])
|
||||
.split(LINE_TERMINATORS)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const tags = context.tags ? constraintTags(properties[name]) : "";
|
||||
return tags ? [...lines, tags] : lines;
|
||||
});
|
||||
if (!comments.some((comment) => comment.length > 0)) return `{ ${members.join(" ")} }`;
|
||||
|
||||
const lines = ["{"];
|
||||
comments.forEach((comment, index) => {
|
||||
for (const line of comment) lines.push(`${INDENT}// ${line}`);
|
||||
lines.push(`${INDENT}${members[index].replaceAll("\n", `\n${INDENT}`)}`);
|
||||
});
|
||||
for (const member of members.slice(names.length)) lines.push(`${INDENT}${member}`);
|
||||
lines.push("}");
|
||||
return lines.join("\n");
|
||||
}
|
||||
12
third_party/pi/packages/codemode/src/identifier.ts
vendored
Normal file
12
third_party/pi/packages/codemode/src/identifier.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* The identifier a script uses for a tool: characters that are not valid in a JavaScript
|
||||
* identifier become `_`. `mcp__docs__search` stays as is, `my-tool` becomes `my_tool`.
|
||||
*/
|
||||
export function toCodemodeIdentifier(name: string): string {
|
||||
let identifier = "";
|
||||
for (const char of name) {
|
||||
const valid = identifier === "" ? /^[A-Za-z_$]$/.test(char) : /^[A-Za-z0-9_$]$/.test(char);
|
||||
identifier += valid ? char : "_";
|
||||
}
|
||||
return identifier === "" ? "_" : identifier;
|
||||
}
|
||||
65
third_party/pi/packages/codemode/src/index.ts
vendored
Normal file
65
third_party/pi/packages/codemode/src/index.ts
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
export {
|
||||
DEFAULT_INPUT_SCHEMA_MAX_CHARS,
|
||||
type RenderDeclarationsOptions,
|
||||
renderDeclarations,
|
||||
renderToolOutputType,
|
||||
renderToolSample,
|
||||
renderToolSignature,
|
||||
schemaToType,
|
||||
} from "./declarations.ts";
|
||||
export { toCodemodeIdentifier } from "./identifier.ts";
|
||||
export { CodemodeSandboxBase } from "./runtime/execution.ts";
|
||||
export { CodemodeSandbox } from "./runtime/host.ts";
|
||||
export {
|
||||
DEFAULT_INTERRUPT_BUDGET,
|
||||
InlineCodemodeSandbox,
|
||||
type InlineCodemodeSandboxOptions,
|
||||
} from "./runtime/inline.ts";
|
||||
export {
|
||||
MAX_OUTPUT_CHARS,
|
||||
MAX_OUTPUT_ITEMS,
|
||||
MAX_STORE_TOTAL_CHARS,
|
||||
MAX_STORE_VALUE_CHARS,
|
||||
} from "./runtime/prelude-source.ts";
|
||||
export {
|
||||
type CodemodeRemote,
|
||||
type CodemodeRemoteExchange,
|
||||
type CodemodeRemoteMessage,
|
||||
type CodemodeRemoteReply,
|
||||
type CodemodeRemoteRequest,
|
||||
type CodemodeServeOptions,
|
||||
RemoteCodemodeSandbox,
|
||||
type RemoteCodemodeSandboxOptions,
|
||||
serveCodemodeRemote,
|
||||
} from "./runtime/remote.ts";
|
||||
export {
|
||||
CODEMODE_OPTIONS_PREFIX,
|
||||
CODEMODE_SOURCE_GRAMMAR,
|
||||
CodemodeSourceError,
|
||||
type CodemodeSourceOptions,
|
||||
type ParsedCodemodeSource,
|
||||
parseCodemodeSource,
|
||||
} from "./source.ts";
|
||||
export type {
|
||||
CodemodeCall,
|
||||
CodemodeCallStatus,
|
||||
CodemodeDeclaredGlobal,
|
||||
CodemodeDeclaredTool,
|
||||
CodemodeDuring,
|
||||
CodemodeError,
|
||||
CodemodeErrorKind,
|
||||
CodemodeExecuteOptions,
|
||||
CodemodeHostCall,
|
||||
CodemodeJsonSchema,
|
||||
CodemodeOutcome,
|
||||
CodemodeOutputItem,
|
||||
CodemodeResult,
|
||||
CodemodeRun,
|
||||
CodemodeRunOptions,
|
||||
CodemodeSandboxBaseOptions,
|
||||
CodemodeSandboxOptions,
|
||||
CodemodeStoreWrites,
|
||||
CodemodeTool,
|
||||
CodemodeToolContext,
|
||||
} from "./types.ts";
|
||||
export { type CodemodeWasmModule, loadQuickJSWasm } from "./wasm.ts";
|
||||
53
third_party/pi/packages/codemode/src/portable.ts
vendored
Normal file
53
third_party/pi/packages/codemode/src/portable.ts
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Sandboxes that need no Node built-ins: `InlineCodemodeSandbox` runs the VM on the calling thread
|
||||
* and `RemoteCodemodeSandbox` runs it in another isolate. Neither imports `node:*` modules, so this
|
||||
* entry works in Cloudflare Workers, Durable Objects, and browsers. `CodemodeSandbox`, which uses
|
||||
* worker threads, stays in the main entry.
|
||||
*/
|
||||
export { toCodemodeIdentifier } from "./identifier.ts";
|
||||
export { CodemodeSandboxBase } from "./runtime/execution.ts";
|
||||
export {
|
||||
DEFAULT_INTERRUPT_BUDGET,
|
||||
InlineCodemodeSandbox,
|
||||
type InlineCodemodeSandboxOptions,
|
||||
} from "./runtime/inline.ts";
|
||||
export {
|
||||
MAX_OUTPUT_CHARS,
|
||||
MAX_OUTPUT_ITEMS,
|
||||
MAX_STORE_TOTAL_CHARS,
|
||||
MAX_STORE_VALUE_CHARS,
|
||||
} from "./runtime/prelude-source.ts";
|
||||
export type { HostToWorkerMessage, VmStartData, WorkerToHostMessage } from "./runtime/protocol.ts";
|
||||
export {
|
||||
type CodemodeRemote,
|
||||
type CodemodeRemoteExchange,
|
||||
type CodemodeRemoteMessage,
|
||||
type CodemodeRemoteReply,
|
||||
type CodemodeRemoteRequest,
|
||||
type CodemodeServeOptions,
|
||||
RemoteCodemodeSandbox,
|
||||
type RemoteCodemodeSandboxOptions,
|
||||
serveCodemodeRemote,
|
||||
} from "./runtime/remote.ts";
|
||||
export type {
|
||||
CodemodeCall,
|
||||
CodemodeCallStatus,
|
||||
CodemodeDeclaredGlobal,
|
||||
CodemodeDeclaredTool,
|
||||
CodemodeDuring,
|
||||
CodemodeError,
|
||||
CodemodeErrorKind,
|
||||
CodemodeExecuteOptions,
|
||||
CodemodeHostCall,
|
||||
CodemodeJsonSchema,
|
||||
CodemodeOutcome,
|
||||
CodemodeOutputItem,
|
||||
CodemodeResult,
|
||||
CodemodeRun,
|
||||
CodemodeRunOptions,
|
||||
CodemodeSandboxBaseOptions,
|
||||
CodemodeStoreWrites,
|
||||
CodemodeTool,
|
||||
CodemodeToolContext,
|
||||
} from "./types.ts";
|
||||
export type { CodemodeWasmModule } from "./wasm.ts";
|
||||
573
third_party/pi/packages/codemode/src/runtime/execution.ts
vendored
Normal file
573
third_party/pi/packages/codemode/src/runtime/execution.ts
vendored
Normal file
@@ -0,0 +1,573 @@
|
||||
/**
|
||||
* Host side of an execution, shared by every place a VM can run.
|
||||
*
|
||||
* The host owns everything the script must not influence: the tool table, call records, output,
|
||||
* the deadline, the caller's abort signal, and the decoding of what the VM reports. The VM itself
|
||||
* runs somewhere a {@link VmLauncher} decides: a worker thread (`CodemodeSandbox`), the calling
|
||||
* thread (`InlineCodemodeSandbox`), or another isolate reached over RPC (`RemoteCodemodeSandbox`).
|
||||
* The two sides only exchange the messages in `protocol.ts`, so the launchers differ in transport
|
||||
* and termination, not in behavior.
|
||||
*
|
||||
* Lifecycle of one execution:
|
||||
* 1. The constructor arms the deadline and abort listener, then asks the launcher for a channel.
|
||||
* 2. Messages from the VM arrive through {@link VmEvents.message}; tool calls run here and their
|
||||
* results go back through {@link VmChannel.post}. Replies produced before the launcher has
|
||||
* returned its channel are queued and flushed when it arrives.
|
||||
* 3. The first terminal event wins: `done`, `crash`, a launcher failure, the deadline, or an abort.
|
||||
* `finish()` aborts pending tool calls, asks the channel to stop, and resolves the result once
|
||||
* that request completes. A worker or remote VM may exit later. Later events are ignored.
|
||||
*
|
||||
* The launcher, not this class, is responsible for actually stopping a running VM: a worker is
|
||||
* terminated, an in-thread VM is interrupted and disposed, and a remote VM is told to stop at its
|
||||
* next exchange.
|
||||
*/
|
||||
import { toCodemodeIdentifier } from "../identifier.ts";
|
||||
import type {
|
||||
CodemodeCall,
|
||||
CodemodeCallStatus,
|
||||
CodemodeDeclaredGlobal,
|
||||
CodemodeDeclaredTool,
|
||||
CodemodeDuring,
|
||||
CodemodeError,
|
||||
CodemodeExecuteOptions,
|
||||
CodemodeOutcome,
|
||||
CodemodeOutputItem,
|
||||
CodemodeResult,
|
||||
CodemodeRun,
|
||||
CodemodeRunOptions,
|
||||
CodemodeSandboxBaseOptions,
|
||||
CodemodeStoreWrites,
|
||||
CodemodeTool,
|
||||
} from "../types.ts";
|
||||
import {
|
||||
type HostToWorkerMessage,
|
||||
isWorkerToHostMessage,
|
||||
type VmStartData,
|
||||
type WorkerToHostMessage,
|
||||
} from "./protocol.ts";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 300_000;
|
||||
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
||||
const RESERVED_GLOBALS: ReadonlySet<string> = new Set([
|
||||
"tools",
|
||||
"ALL_TOOLS",
|
||||
"console",
|
||||
"text",
|
||||
"image",
|
||||
"exit",
|
||||
"globalThis",
|
||||
"store",
|
||||
"load",
|
||||
]);
|
||||
|
||||
/** Callbacks a launcher uses to report what its VM does. */
|
||||
export interface VmEvents {
|
||||
/** A message from the VM. Validated here, so launchers can pass through anything they receive. */
|
||||
message(message: unknown): void;
|
||||
/** The VM failed or stopped outside the bridge, for example a worker exit or a remote error. */
|
||||
failure(error: CodemodeError): void;
|
||||
}
|
||||
|
||||
/** The host's handle on a running VM. */
|
||||
export interface VmChannel {
|
||||
post(message: HostToWorkerMessage): void;
|
||||
/** Requests VM shutdown. Called once; may resolve before a worker or remote VM actually exits. */
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a VM for one execution. Rejections become `sandbox` errors with the rejection's message.
|
||||
* The VM may start reporting messages before the returned promise settles.
|
||||
*/
|
||||
export type VmLauncher = (data: VmStartData, events: VmEvents) => VmChannel | Promise<VmChannel>;
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function serializeStore(store: Readonly<Record<string, unknown>> | undefined): Record<string, string> {
|
||||
const serialized: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(store ?? {})) {
|
||||
const json = JSON.stringify(value);
|
||||
if (json !== undefined) serialized[key] = json;
|
||||
}
|
||||
return serialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* The prelude that serializes values for the host runs in the same VM as the script, so a script
|
||||
* that patches built-ins (for example `Array.prototype.toJSON`) can make it send malformed data.
|
||||
* Payloads that do not decode throw this error, and the execution fails as a sandbox error.
|
||||
*/
|
||||
class BridgeError extends Error {}
|
||||
|
||||
function parseBridgeJson(json: string, what: string): unknown {
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
throw new BridgeError(`${what} is not valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseStoreWrites(json: string): CodemodeStoreWrites {
|
||||
const entries = parseBridgeJson(json, "store writes");
|
||||
if (!Array.isArray(entries)) throw new BridgeError("store writes are not an array");
|
||||
const writes: CodemodeStoreWrites = { set: {}, delete: [] };
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
!Array.isArray(entry) ||
|
||||
typeof entry[0] !== "string" ||
|
||||
(entry.length === 2 ? typeof entry[1] !== "string" : entry.length !== 1)
|
||||
) {
|
||||
throw new BridgeError("store writes contain a malformed entry");
|
||||
}
|
||||
const [key, value] = entry as [string, string?];
|
||||
if (value === undefined) writes.delete.push(key);
|
||||
else writes.set[key] = parseBridgeJson(value, `store value for ${JSON.stringify(key)}`);
|
||||
}
|
||||
return writes;
|
||||
}
|
||||
|
||||
function parseScriptError(json: string): CodemodeError {
|
||||
const parsed = parseBridgeJson(json, "script error");
|
||||
if (typeof parsed !== "object" || parsed === null) throw new BridgeError("script error is not an object");
|
||||
const { name, message, stack } = parsed as Record<string, unknown>;
|
||||
if (
|
||||
typeof message !== "string" ||
|
||||
(name !== undefined && typeof name !== "string") ||
|
||||
(stack !== undefined && typeof stack !== "string")
|
||||
) {
|
||||
throw new BridgeError("script error is malformed");
|
||||
}
|
||||
return { kind: "script", name, message, stack };
|
||||
}
|
||||
|
||||
interface PendingCall {
|
||||
record: CodemodeCall | undefined;
|
||||
startedAt: number;
|
||||
controller: AbortController;
|
||||
}
|
||||
|
||||
/** Checks global names: identifiers or `namespace.member`, not shadowing a built-in global or each other. */
|
||||
function checkGlobals(globals: readonly CodemodeDeclaredGlobal[]): void {
|
||||
const names = new Set<string>();
|
||||
const namespaces = new Set<string>();
|
||||
for (const global of globals) {
|
||||
const parts = global.name.split(".");
|
||||
if (parts.length > 2 || !parts.every((part) => IDENTIFIER.test(part)) || RESERVED_GLOBALS.has(parts[0])) {
|
||||
throw new Error(`Invalid global name "${global.name}"`);
|
||||
}
|
||||
if (names.has(global.name)) throw new Error(`Global "${global.name}" is already registered`);
|
||||
if (parts.length === 2) namespaces.add(parts[0]);
|
||||
names.add(global.name);
|
||||
}
|
||||
for (const name of namespaces) {
|
||||
if (names.has(name)) throw new Error(`Global "${name}" conflicts with the namespace "${name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Callbacks of a host-controlled run: its calls go to the caller, which answers them with `deliver()`. */
|
||||
type Control = Pick<CodemodeRunOptions, "onStarted" | "onCall" | "onIdle">;
|
||||
|
||||
interface ExecutionOptions {
|
||||
code: string;
|
||||
tools: readonly CodemodeDeclaredTool[];
|
||||
globals: readonly CodemodeDeclaredGlobal[];
|
||||
/** Run the script's calls; unused when `control` is set. */
|
||||
executors: { tools: ReadonlyMap<string, CodemodeTool>; globals: ReadonlyMap<string, CodemodeTool> };
|
||||
timeoutMs: number;
|
||||
signal: AbortSignal | undefined;
|
||||
memoryLimitBytes: number | undefined;
|
||||
store: Record<string, string>;
|
||||
deterministic?: { startedAt: number; seed: number };
|
||||
control?: Control;
|
||||
launch: VmLauncher;
|
||||
}
|
||||
|
||||
/** One script run in its own VM. */
|
||||
class Execution {
|
||||
readonly promise: Promise<CodemodeResult>;
|
||||
private resolveResult!: (result: CodemodeResult) => void;
|
||||
private channel: VmChannel | undefined;
|
||||
/** Replies produced before the launcher returned the channel. */
|
||||
private unsent: HostToWorkerMessage[] = [];
|
||||
private readonly executors: ExecutionOptions["executors"];
|
||||
private readonly control: Control | undefined;
|
||||
/** Result numbers `deliver()` used. */
|
||||
private readonly delivered = new Set<number>();
|
||||
private readonly signal: AbortSignal | undefined;
|
||||
private readonly timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private readonly output: CodemodeOutputItem[] = [];
|
||||
private readonly calls: CodemodeCall[] = [];
|
||||
private readonly pending = new Map<number, PendingCall>();
|
||||
/** Whether the execution has its outcome. */
|
||||
finished = false;
|
||||
|
||||
constructor(options: ExecutionOptions) {
|
||||
this.promise = new Promise<CodemodeResult>((resolve) => {
|
||||
this.resolveResult = resolve;
|
||||
});
|
||||
this.executors = options.executors;
|
||||
this.control = options.control;
|
||||
this.signal = options.signal;
|
||||
|
||||
if (Number.isFinite(options.timeoutMs)) {
|
||||
this.timer = setTimeout(() => {
|
||||
this.finish({ kind: "timeout", message: `Execution timed out after ${options.timeoutMs} ms` });
|
||||
}, options.timeoutMs);
|
||||
}
|
||||
|
||||
if (options.signal) {
|
||||
if (options.signal.aborted) {
|
||||
this.onAbort();
|
||||
} else {
|
||||
options.signal.addEventListener("abort", this.onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
if (this.finished) return;
|
||||
|
||||
const data: VmStartData = {
|
||||
code: options.code,
|
||||
tools: options.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
jsName: tool.jsName ?? toCodemodeIdentifier(tool.name),
|
||||
description: tool.description ?? "",
|
||||
})),
|
||||
globals: options.globals.map((global) => ({ name: global.name, spread: global.spread === true })),
|
||||
memoryLimitBytes: options.memoryLimitBytes,
|
||||
store: options.store,
|
||||
...(options.deterministic ? { deterministic: options.deterministic } : {}),
|
||||
};
|
||||
const events: VmEvents = {
|
||||
message: (message) => this.receive(message),
|
||||
failure: (error) => this.finish(error),
|
||||
};
|
||||
new Promise<VmChannel>((resolve) => resolve(options.launch(data, events))).then(
|
||||
(channel) => {
|
||||
if (this.finished) {
|
||||
void channel.stop().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
this.channel = channel;
|
||||
for (const message of this.unsent.splice(0)) channel.post(message);
|
||||
},
|
||||
(error: unknown) => {
|
||||
this.finish({ kind: "sandbox", message: errorMessage(error) });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** `CodemodeRun.deliver()`. */
|
||||
deliver(seq: number, now: number, id: number, outcome: CodemodeOutcome): void {
|
||||
if (this.finished) return;
|
||||
if (!Number.isFinite(now)) throw new Error(`The clock value of result ${seq} is not a finite number`);
|
||||
if (!Number.isInteger(seq) || seq < 1 || this.delivered.has(seq)) {
|
||||
throw new Error(`Result number ${seq} is not a positive integer used once`);
|
||||
}
|
||||
const pending = this.pending.get(id);
|
||||
if (pending === undefined) throw new Error(`Call ${id} is not waiting for a result`);
|
||||
if (!outcome.ok && outcome.properties !== undefined) {
|
||||
const { properties } = outcome;
|
||||
if (typeof properties !== "object" || properties === null || Array.isArray(properties)) {
|
||||
throw new Error(`The properties of result ${seq} are not an object`);
|
||||
}
|
||||
}
|
||||
// Serialized before anything is recorded, so a value JSON cannot hold leaves the call waiting for another answer.
|
||||
const payload = outcome.ok
|
||||
? outcome.value === undefined
|
||||
? undefined
|
||||
: JSON.stringify(outcome.value)
|
||||
: outcome.message;
|
||||
const properties =
|
||||
!outcome.ok && outcome.properties !== undefined ? JSON.stringify(outcome.properties) : undefined;
|
||||
this.delivered.add(seq);
|
||||
this.pending.delete(id);
|
||||
if (pending.record) {
|
||||
pending.record.status = outcome.ok ? "ok" : "error";
|
||||
pending.record.durationMs = performance.now() - pending.startedAt;
|
||||
}
|
||||
this.post({
|
||||
type: "result",
|
||||
id,
|
||||
ok: outcome.ok,
|
||||
payload,
|
||||
seq,
|
||||
now,
|
||||
...(properties === undefined ? {} : { properties }),
|
||||
});
|
||||
}
|
||||
|
||||
abort(message: string): Promise<CodemodeResult> {
|
||||
this.finish({ kind: "aborted", message });
|
||||
return this.promise;
|
||||
}
|
||||
|
||||
private readonly onAbort = (): void => {
|
||||
const reason: unknown = this.signal?.reason;
|
||||
this.finish({ kind: "aborted", message: reason instanceof Error ? reason.message : "Execution aborted" });
|
||||
};
|
||||
|
||||
private post(message: HostToWorkerMessage): void {
|
||||
if (this.channel) this.channel.post(message);
|
||||
else this.unsent.push(message);
|
||||
}
|
||||
|
||||
/** An exception here would escape into the launcher's transport and leave the execution unsettled. */
|
||||
private receive(message: unknown): void {
|
||||
try {
|
||||
this.handleMessage(message);
|
||||
} catch (error) {
|
||||
this.finish({
|
||||
kind: "sandbox",
|
||||
message:
|
||||
error instanceof BridgeError
|
||||
? `Sandbox bridge broken: ${error.message}. The script may have modified built-ins such as a prototype's toJSON.`
|
||||
: `Sandbox host failed: ${errorMessage(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(message: unknown): void {
|
||||
if (this.finished) return;
|
||||
if (!isWorkerToHostMessage(message)) throw new BridgeError("unknown message from the worker");
|
||||
switch (message.type) {
|
||||
case "output":
|
||||
this.output.push(message.item);
|
||||
break;
|
||||
case "call":
|
||||
if (this.pending.has(message.id)) throw new BridgeError(`duplicate call id ${message.id}`);
|
||||
if (this.control) this.forwardCall(message, this.control);
|
||||
else void this.handleCall(message);
|
||||
break;
|
||||
case "started":
|
||||
this.control?.onStarted?.(message.fingerprint);
|
||||
break;
|
||||
case "idle":
|
||||
this.control?.onIdle?.(message.seq, message.issued);
|
||||
break;
|
||||
case "done":
|
||||
this.handleDone(message);
|
||||
break;
|
||||
case "crash":
|
||||
this.finish({ kind: "sandbox", message: message.message });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleDone(message: Extract<WorkerToHostMessage, { type: "done" }>): void {
|
||||
// Decode everything before finish(), which must not throw once it starts.
|
||||
if (!message.ok) {
|
||||
this.finish(parseScriptError(message.error), undefined, undefined, message.during);
|
||||
return;
|
||||
}
|
||||
const value = message.value === undefined ? undefined : parseBridgeJson(message.value, "return value");
|
||||
this.finish(undefined, value, parseStoreWrites(message.writes), message.during);
|
||||
}
|
||||
|
||||
/** A host-controlled run: record the call and hand it to the caller, which answers with `deliver()`. */
|
||||
private forwardCall(message: Extract<WorkerToHostMessage, { type: "call" }>, control: Control): void {
|
||||
const { id, name, target } = message;
|
||||
const args = message.args === undefined ? undefined : parseBridgeJson(message.args, "call arguments");
|
||||
const record: CodemodeCall | undefined =
|
||||
target === "tool" ? { name, status: "cancelled", durationMs: 0 } : undefined;
|
||||
if (record) this.calls.push(record);
|
||||
this.pending.set(id, { record, startedAt: performance.now(), controller: new AbortController() });
|
||||
control.onCall({ id, target, name, args });
|
||||
}
|
||||
|
||||
private async handleCall(message: Extract<WorkerToHostMessage, { type: "call" }>): Promise<void> {
|
||||
const { id, name } = message;
|
||||
const isTool = message.target === "tool";
|
||||
const record: CodemodeCall | undefined = isTool ? { name, status: "cancelled", durationMs: 0 } : undefined;
|
||||
if (record) this.calls.push(record);
|
||||
const pending: PendingCall = { record, startedAt: performance.now(), controller: new AbortController() };
|
||||
this.pending.set(id, pending);
|
||||
|
||||
let status: CodemodeCallStatus;
|
||||
let reply: HostToWorkerMessage;
|
||||
try {
|
||||
const tool = (isTool ? this.executors.tools : this.executors.globals).get(name);
|
||||
if (!tool) throw new Error(`Unknown ${isTool ? "tool" : "global"} "${name}"`);
|
||||
const args: unknown = message.args === undefined ? undefined : JSON.parse(message.args);
|
||||
const value = await tool.execute(args, { signal: pending.controller.signal });
|
||||
reply = { type: "result", id, ok: true, payload: value === undefined ? undefined : JSON.stringify(value) };
|
||||
status = "ok";
|
||||
} catch (error) {
|
||||
reply = { type: "result", id, ok: false, payload: errorMessage(error) };
|
||||
status = "error";
|
||||
}
|
||||
|
||||
// Already cancelled by finish(): the record keeps "cancelled" and the
|
||||
// VM is gone or going.
|
||||
if (!this.pending.delete(id)) return;
|
||||
if (record) {
|
||||
record.status = status;
|
||||
record.durationMs = performance.now() - pending.startedAt;
|
||||
}
|
||||
this.post(reply);
|
||||
}
|
||||
|
||||
private finish(
|
||||
error: CodemodeError | undefined,
|
||||
value?: unknown,
|
||||
writes?: CodemodeStoreWrites,
|
||||
during?: CodemodeDuring,
|
||||
): void {
|
||||
if (this.finished) return;
|
||||
this.finished = true;
|
||||
clearTimeout(this.timer);
|
||||
this.signal?.removeEventListener("abort", this.onAbort);
|
||||
|
||||
const now = performance.now();
|
||||
for (const pending of this.pending.values()) {
|
||||
if (pending.record) pending.record.durationMs = now - pending.startedAt;
|
||||
pending.controller.abort();
|
||||
}
|
||||
this.pending.clear();
|
||||
this.unsent = [];
|
||||
|
||||
const ended = during === undefined ? {} : { during: { seq: during.seq, issued: during.issued } };
|
||||
const result: CodemodeResult = error
|
||||
? { ok: false, error, output: this.output, calls: this.calls, ...ended }
|
||||
: {
|
||||
ok: true,
|
||||
value,
|
||||
output: this.output,
|
||||
calls: this.calls,
|
||||
storeWrites: writes ?? { set: {}, delete: [] },
|
||||
...ended,
|
||||
};
|
||||
if (!this.channel) {
|
||||
this.resolveResult(result);
|
||||
return;
|
||||
}
|
||||
this.channel
|
||||
.stop()
|
||||
.catch(() => undefined)
|
||||
.then(() => this.resolveResult(result));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool table, defaults, and in-flight executions; the behavior every sandbox shares. Subclasses
|
||||
* only choose where the VM runs by passing a {@link VmLauncher}.
|
||||
*
|
||||
* The script sees `tools.<name>(args)` for every registered tool, `ALL_TOOLS`, the output helpers
|
||||
* `text`, `image`, `exit`, and `console.*`, `store`/`load`, and the configured globals; nothing
|
||||
* else (no timers, `fetch`, `process`, `require`, modules). Each `execute()` gets its own VM; the
|
||||
* sandbox only holds the tool table and defaults. `close()` aborts in-flight executions.
|
||||
*/
|
||||
export class CodemodeSandboxBase {
|
||||
private readonly toolsByName = new Map<string, CodemodeTool>();
|
||||
private readonly globalsByName = new Map<string, CodemodeTool>();
|
||||
private readonly timeoutMs: number;
|
||||
private readonly memoryLimitBytes: number | undefined;
|
||||
private readonly launch: VmLauncher;
|
||||
private readonly running = new Set<Execution>();
|
||||
private closed = false;
|
||||
|
||||
constructor(options: CodemodeSandboxBaseOptions, launch: VmLauncher) {
|
||||
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
this.memoryLimitBytes = options.memoryLimitBytes;
|
||||
this.launch = launch;
|
||||
for (const tool of options.tools ?? []) this.registerTool(tool);
|
||||
checkGlobals(options.globals ?? []);
|
||||
for (const global of options.globals ?? []) this.globalsByName.set(global.name, global);
|
||||
}
|
||||
|
||||
/** Throws if a tool with the same name is already registered. */
|
||||
registerTool(tool: CodemodeTool): void {
|
||||
if (this.toolsByName.has(tool.name)) throw new Error(`Tool "${tool.name}" is already registered`);
|
||||
this.toolsByName.set(tool.name, tool);
|
||||
}
|
||||
|
||||
unregisterTool(name: string): boolean {
|
||||
return this.toolsByName.delete(name);
|
||||
}
|
||||
|
||||
get tools(): CodemodeTool[] {
|
||||
return [...this.toolsByName.values()];
|
||||
}
|
||||
|
||||
get globals(): CodemodeTool[] {
|
||||
return [...this.globalsByName.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* `code` is an async function body: `return` and top-level `await` work.
|
||||
* Never rejects for script failures; those come back as `{ ok: false }`.
|
||||
* The script can use `store(key, value)` and `load(key)` on `options.store`.
|
||||
*/
|
||||
execute(code: string, options: CodemodeExecuteOptions = {}): Promise<CodemodeResult> {
|
||||
if (this.closed) return Promise.reject(new Error("Sandbox is closed"));
|
||||
const tools = new Map(this.toolsByName);
|
||||
const execution = new Execution({
|
||||
code,
|
||||
tools: [...tools.values()],
|
||||
globals: [...this.globalsByName.values()],
|
||||
executors: { tools, globals: this.globalsByName },
|
||||
timeoutMs: options.timeoutMs ?? this.timeoutMs,
|
||||
signal: options.signal,
|
||||
memoryLimitBytes: this.memoryLimitBytes,
|
||||
store: serializeStore(options.store),
|
||||
launch: this.launch,
|
||||
});
|
||||
this.running.add(execution);
|
||||
return execution.promise.finally(() => this.running.delete(execution));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `code` deterministically, with the caller answering its calls: the same script, start time, seed, tools,
|
||||
* globals, and store, answered with the same results in the same `seq` order, makes the same calls with the same
|
||||
* arguments and produces the same output, on any VM with the same fingerprint. A host can therefore replay it
|
||||
* after a restart. The VM runs in UTC, its clock reads `startedAt` and then the `now` of the result being
|
||||
* processed, its randomness comes from `seed`, and `WeakRef` and `FinalizationRegistry` do not exist.
|
||||
*
|
||||
* The sandbox's own tools and globals are not used: the run declares its own, and every call goes to
|
||||
* `options.onCall`. Never rejects for script failures; abort through `options.signal` to end the run with an
|
||||
* error the script cannot catch.
|
||||
*/
|
||||
start(code: string, options: CodemodeRunOptions): CodemodeRun {
|
||||
if (this.closed) throw new Error("Sandbox is closed");
|
||||
if (!Number.isFinite(options.startedAt) || options.startedAt < 0) {
|
||||
throw new Error("startedAt must be a non-negative number of milliseconds since the epoch");
|
||||
}
|
||||
if (!Number.isInteger(options.seed) || options.seed < 0 || options.seed > 0xffffffff) {
|
||||
throw new Error("seed must be a 32-bit unsigned integer");
|
||||
}
|
||||
const tools = options.tools ?? [];
|
||||
if (new Set(tools.map((tool) => tool.name)).size !== tools.length) throw new Error("Tool names must be unique");
|
||||
const scriptNames = tools.map((tool) => tool.jsName ?? toCodemodeIdentifier(tool.name));
|
||||
if (new Set(scriptNames).size !== tools.length) throw new Error("Tool script names must be unique");
|
||||
const globals = options.globals ?? [];
|
||||
checkGlobals(globals);
|
||||
const execution = new Execution({
|
||||
code,
|
||||
tools,
|
||||
globals,
|
||||
executors: { tools: new Map(), globals: new Map() },
|
||||
timeoutMs: options.timeoutMs ?? Number.POSITIVE_INFINITY,
|
||||
signal: options.signal,
|
||||
memoryLimitBytes: this.memoryLimitBytes,
|
||||
store: serializeStore(options.store),
|
||||
deterministic: { startedAt: options.startedAt, seed: options.seed },
|
||||
control: { onStarted: options.onStarted, onCall: options.onCall, onIdle: options.onIdle },
|
||||
launch: this.launch,
|
||||
});
|
||||
this.running.add(execution);
|
||||
const result = execution.promise.finally(() => this.running.delete(execution));
|
||||
return {
|
||||
result,
|
||||
deliver: (seq, now, id, outcome) => execution.deliver(seq, now, id, outcome),
|
||||
get finished() {
|
||||
return execution.finished;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Aborts in-flight executions (they resolve with `kind: "aborted"`) and rejects new ones. */
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
await Promise.all([...this.running].map((execution) => execution.abort("Sandbox closed")));
|
||||
}
|
||||
}
|
||||
88
third_party/pi/packages/codemode/src/runtime/host.ts
vendored
Normal file
88
third_party/pi/packages/codemode/src/runtime/host.ts
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Worker } from "node:worker_threads";
|
||||
import type { CodemodeSandboxOptions } from "../types.ts";
|
||||
import { type CodemodeWasmModule, loadQuickJSWasm } from "../wasm.ts";
|
||||
import { CodemodeSandboxBase, errorMessage, type VmLauncher } from "./execution.ts";
|
||||
import type { WorkerData } from "./protocol.ts";
|
||||
|
||||
function defaultWorkerUrl(): URL {
|
||||
// `.ts` when running from source (tests, tsx), `.js` from the published dist.
|
||||
return new URL(import.meta.url.endsWith(".ts") ? "./worker.ts" : "./worker.js", import.meta.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Each execution gets its own worker thread and QuickJS VM. A fresh worker per run keeps
|
||||
* termination simple: a runaway script, including one that only spins the microtask queue, is
|
||||
* killed with `terminate()` and cannot poison a later run. Before terminating, the host sets a
|
||||
* shared interrupt flag that the VM polls, because Bun's `terminate()` cannot stop a thread that
|
||||
* is spinning in wasm. Stopping requests termination without waiting for exit: some built-ins do
|
||||
* not poll the flag, so on Bun a worker can outlive the script's result.
|
||||
*/
|
||||
function workerLauncher(options: CodemodeSandboxOptions): VmLauncher {
|
||||
return async (data, events) => {
|
||||
let module: CodemodeWasmModule;
|
||||
try {
|
||||
module = await (options.wasm ?? loadQuickJSWasm());
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load QuickJS: ${errorMessage(error)}`);
|
||||
}
|
||||
const interrupt = new SharedArrayBuffer(4);
|
||||
const workerData: WorkerData = { ...data, wasm: module, interrupt };
|
||||
const releaseWorker = options.acquireWorker?.();
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = new Worker(options.workerUrl ?? defaultWorkerUrl(), { workerData });
|
||||
} catch (error) {
|
||||
releaseWorker?.();
|
||||
throw new Error(`Failed to start worker: ${errorMessage(error)}`);
|
||||
}
|
||||
if (releaseWorker) worker.once("exit", releaseWorker);
|
||||
worker.on("message", (message: unknown) => {
|
||||
// Node 24/26 --watch leaks dependency reports onto the worker channel (nodejs/node#65044).
|
||||
if (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
("watch:import" in message || "watch:require" in message)
|
||||
)
|
||||
return;
|
||||
events.message(message);
|
||||
});
|
||||
worker.on("error", (error: unknown) => {
|
||||
events.failure({
|
||||
kind: "sandbox",
|
||||
name: error instanceof Error ? error.name : undefined,
|
||||
message: errorMessage(error),
|
||||
});
|
||||
});
|
||||
worker.on("exit", (code) => {
|
||||
events.failure({ kind: "sandbox", message: `Worker exited with code ${code} before the script settled` });
|
||||
});
|
||||
const stop = async (): Promise<void> => {
|
||||
Atomics.store(new Int32Array(interrupt), 0, 1);
|
||||
// A non-interruptible built-in can delay Bun's terminate() until it returns (#10748).
|
||||
// Do not hold up the result while that worker exits.
|
||||
void worker.terminate().catch(() => undefined);
|
||||
// terminate() refs the worker internally; unref it after requesting termination.
|
||||
worker.unref();
|
||||
};
|
||||
try {
|
||||
options.onWorkerStarted?.(new Promise<void>((resolve) => worker.once("exit", () => resolve())));
|
||||
} catch (error) {
|
||||
void stop();
|
||||
throw error;
|
||||
}
|
||||
return { post: (message) => worker.postMessage(message), stop };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs JavaScript in a QuickJS VM (a separate wasm instance) inside a worker thread. The worker
|
||||
* keeps script execution off the host thread: QuickJS runs synchronously, so a spinning script on
|
||||
* the host thread would block its event loop. Requires `node:worker_threads` (Node or Bun); see
|
||||
* `InlineCodemodeSandbox` and `RemoteCodemodeSandbox` in `@earendil-works/pi-codemode/portable`
|
||||
* for other runtimes.
|
||||
*/
|
||||
export class CodemodeSandbox extends CodemodeSandboxBase {
|
||||
constructor(options: CodemodeSandboxOptions = {}) {
|
||||
super(options, workerLauncher(options));
|
||||
}
|
||||
}
|
||||
126
third_party/pi/packages/codemode/src/runtime/inline.ts
vendored
Normal file
126
third_party/pi/packages/codemode/src/runtime/inline.ts
vendored
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Runs the VM on the calling thread, for runtimes without worker threads (Cloudflare Workers and
|
||||
* Durable Objects, browsers) and for the server side of `RemoteCodemodeSandbox`.
|
||||
*
|
||||
* Problem: QuickJS runs synchronously. While a script computes, the host's event loop does not
|
||||
* run, so neither the deadline timer nor an abort signal can fire. On Workers even `Date.now()` is
|
||||
* frozen during synchronous execution, so the VM cannot check a clock either. Example: with a
|
||||
* worker thread, `while (true) {}` is stopped by the host's 200 ms timer; on the calling thread
|
||||
* that timer never gets a chance to run.
|
||||
*
|
||||
* Solution: an interrupt budget. QuickJS polls its interrupt handler periodically while a script
|
||||
* runs; this launcher counts the polls across the whole execution and interrupts the script once
|
||||
* the count passes the budget. The execution then fails with `kind: "timeout"`. The deadline and
|
||||
* abort signal still work whenever the script waits on a tool, since the event loop runs then.
|
||||
*
|
||||
* The rate of polls depends on the code and the machine. Measured with a tight loop: about 50,000
|
||||
* per second of CPU on an Apple M-series laptop and about 10,000 per second on Cloudflare Durable
|
||||
* Objects (October 2026). The default of 100,000 allows roughly 2 to 10 seconds of uninterrupted
|
||||
* computation; keep it well below the platform's own CPU limit, because hitting that limit resets
|
||||
* a Durable Object instead of failing one script.
|
||||
*
|
||||
* Once the VM reports `done` or `crash`, the result is decided, so the VM is interrupted at the
|
||||
* next poll instead of running on until the host stops it. Example: a script that keeps printing in
|
||||
* a `for (;;)` loop after its output limit was reached would otherwise run until the budget ran out
|
||||
* and be reported as a timeout.
|
||||
*
|
||||
* Stack: the VM runs on the caller's stack, which is smaller than a worker thread's (about 1 MB
|
||||
* on Node's main thread), so QuickJS's stack guard is set to {@link INLINE_MAX_STACK_SIZE}.
|
||||
*
|
||||
* Reentrancy: VM callbacks run inside QuickJS. Messages from the VM are delivered to the host in
|
||||
* a microtask, after the VM has returned, so host code (tool calls, `finish()`, disposal) never
|
||||
* runs inside a VM callback and never re-enters the VM.
|
||||
*/
|
||||
import type { CodemodeSandboxBaseOptions } from "../types.ts";
|
||||
import type { CodemodeWasmModule } from "../wasm.ts";
|
||||
import { CodemodeSandboxBase, errorMessage, type VmLauncher } from "./execution.ts";
|
||||
import { startVm } from "./vm.ts";
|
||||
|
||||
export const DEFAULT_INTERRUPT_BUDGET = 100_000;
|
||||
|
||||
/** Allows recursion about 1,000 calls deep and trips well before a 1 MB host stack overflows. */
|
||||
export const INLINE_MAX_STACK_SIZE = 256 * 1024;
|
||||
|
||||
export interface InlineCodemodeSandboxOptions extends CodemodeSandboxBaseOptions {
|
||||
/**
|
||||
* Compiled `quickjs-wasi/quickjs.wasm`. On Cloudflare, import it with Wrangler's `CompiledWasm`
|
||||
* rule (`@earendil-works/pi-codemode/cloudflare` does this); on Node, `loadQuickJSWasm()`.
|
||||
*/
|
||||
wasm: CodemodeWasmModule | Promise<CodemodeWasmModule>;
|
||||
/**
|
||||
* Interrupt polls an execution may use before it fails with `kind: "timeout"`. The only limit on
|
||||
* a script that computes without awaiting a tool. Default: {@link DEFAULT_INTERRUPT_BUDGET}.
|
||||
*/
|
||||
interruptBudget?: number;
|
||||
}
|
||||
|
||||
export function inlineLauncher(
|
||||
wasm: CodemodeWasmModule | Promise<CodemodeWasmModule>,
|
||||
interruptBudget: number = DEFAULT_INTERRUPT_BUDGET,
|
||||
): VmLauncher {
|
||||
return async (data, events) => {
|
||||
let module: CodemodeWasmModule;
|
||||
try {
|
||||
module = await wasm;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load QuickJS: ${errorMessage(error)}`);
|
||||
}
|
||||
let stopped = false;
|
||||
let settled = false;
|
||||
let polls = 0;
|
||||
let exhausted = false;
|
||||
const session = await startVm({
|
||||
data,
|
||||
wasm: module,
|
||||
maxStackSize: INLINE_MAX_STACK_SIZE,
|
||||
interruptBudget,
|
||||
shouldInterrupt: () => {
|
||||
if (stopped || settled || exhausted) return true;
|
||||
if (++polls <= interruptBudget) return false;
|
||||
exhausted = true;
|
||||
return true;
|
||||
},
|
||||
post: (message) => {
|
||||
const terminal = message.type === "done" || message.type === "crash";
|
||||
// Only the first terminal message counts: a crash caused by interrupting a settled VM is noise.
|
||||
if (terminal && settled) return;
|
||||
if (terminal) settled = true;
|
||||
queueMicrotask(() => {
|
||||
if (stopped) return;
|
||||
// The interrupt surfaces as a crash or a failed done; report what caused it instead.
|
||||
if (exhausted && (message.type === "done" || message.type === "crash")) {
|
||||
events.failure({
|
||||
kind: "timeout",
|
||||
message: `Execution exceeded its interrupt budget of ${interruptBudget}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
events.message(message);
|
||||
});
|
||||
},
|
||||
});
|
||||
return {
|
||||
post: (message) => {
|
||||
if (!stopped) session.deliver(message);
|
||||
},
|
||||
stop: async () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
session.dispose();
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs each execution's QuickJS VM on the calling thread. Works in any runtime with WebAssembly
|
||||
* and microtasks; needs no worker threads, files, or Node built-ins. A script that computes
|
||||
* blocks the caller's thread until it awaits a tool or uses up its interrupt budget, so prefer
|
||||
* `CodemodeSandbox` where worker threads exist, and `RemoteCodemodeSandbox` to keep scripts off a
|
||||
* thread that must stay responsive (such as a Durable Object that serves a session).
|
||||
*/
|
||||
export class InlineCodemodeSandbox extends CodemodeSandboxBase {
|
||||
constructor(options: InlineCodemodeSandboxOptions) {
|
||||
super(options, inlineLauncher(options.wasm, options.interruptBudget));
|
||||
}
|
||||
}
|
||||
522
third_party/pi/packages/codemode/src/runtime/prelude-source.ts
vendored
Normal file
522
third_party/pi/packages/codemode/src/runtime/prelude-source.ts
vendored
Normal file
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* JavaScript evaluated inside the QuickJS VM before the script runs.
|
||||
*
|
||||
* The VM is its own wasm instance, so nothing here guards a realm boundary.
|
||||
* The prelude keeps the host bridge in a closure so the script cannot call it
|
||||
* directly, and builds `tools`, `ALL_TOOLS`, the output helpers (`text`, `image`,
|
||||
* `exit`, `console`), and globals on top of it. Tool arguments and results cross
|
||||
* as JSON strings and are parsed on this side.
|
||||
*
|
||||
* Output helpers: `text(value)` appends a text item (non-strings are
|
||||
* JSON-stringified), `image(urlOrItem)` appends an image from a base64 `data:` URL, an
|
||||
* `{ image_url }` object, or an image block `{ type: "image", data, mimeType }`, and `exit()` ends the script
|
||||
* successfully. `console.*` appends text items marked as console output.
|
||||
*
|
||||
* `store(key, value)` and `load(key)` are synchronous: they work on a snapshot of
|
||||
* JSON text passed in as `storeJson`, and the keys the script wrote are reported
|
||||
* with a successful "done".
|
||||
*
|
||||
* Evaluates to a function `(bridge, toolsJson, globalsJson, storeJson) => { settle, run, stalled }`.
|
||||
* `settle(id, ok, payload, propertiesJson)` settles call `id`; a rejection's `Error` gets the own properties of the
|
||||
* `propertiesJson` object, except `name`, `message`, and `stack`.
|
||||
* `toolsJson` lists `{ name, jsName, description }`: `tools[jsName]` and `tools[name]` call the
|
||||
* tool, and `ALL_TOOLS` lists `{ name: jsName, description }`.
|
||||
* `stalled()` reports a script that has not finished while no host call is pending: with no timers
|
||||
* or I/O in the VM, nothing can ever resume it.
|
||||
* `globalsJson` lists `{ name, spread }`; `a.b` names are grouped into a frozen `a` object.
|
||||
* `bridge(kind, a, b, c)` with kind "call" or "global" (id, name, argsJson),
|
||||
* "output" ("text" or "console", text) or ("image", data, mimeType), or "done" (ok, valueJsonOrErrorJson, writesJson).
|
||||
*
|
||||
* Before anything else the prelude freezes the built-ins and makes the built-in globals read-only.
|
||||
* The prelude shares them with the script, so a script that patched one (for example
|
||||
* `Array.prototype.toJSON`) could otherwise corrupt what the prelude reports to the host.
|
||||
*/
|
||||
export const MAX_STORE_VALUE_CHARS = 256 * 1024;
|
||||
export const MAX_STORE_TOTAL_CHARS = 1024 * 1024;
|
||||
/**
|
||||
* Output one script may produce with `text()`, `image()`, and `console.*`: characters of text and
|
||||
* base64 image data, and items. The host keeps all output until the script ends, so without a
|
||||
* limit a script that prints in a loop grows the host's memory until it crashes. The item limit
|
||||
* covers loops that print empty strings.
|
||||
*/
|
||||
export const MAX_OUTPUT_CHARS = 16 * 1024 * 1024;
|
||||
export const MAX_OUTPUT_ITEMS = 100_000;
|
||||
|
||||
const IMAGE_HELPER_EXPECTS =
|
||||
'image expects a non-empty image URL string, an object with image_url, or an image block { type: "image", data, mimeType }';
|
||||
|
||||
export const PRELUDE_SOURCE: string = `(function (bridge, toolsJson, globalsJson, storeJson) {
|
||||
"use strict";
|
||||
|
||||
// Freeze every object reachable from the built-in globals, plus the intrinsics that are only
|
||||
// reachable from instances (iterator and generator prototypes, %TypedArray%).
|
||||
//
|
||||
// Freezing alone breaks ordinary code through the "override mistake": a data property that is
|
||||
// read-only on a prototype cannot be assigned on an instance either, so
|
||||
// this.name = "MyError" in an Error subclass would throw. Commonly overridden properties
|
||||
// become accessors whose setter defines an own property on the instance instead.
|
||||
(function lockdown() {
|
||||
const OVERRIDABLE = new Set(["constructor", "name", "message", "toString", "toLocaleString", "valueOf", "toJSON"]);
|
||||
const seen = new Set([globalThis]);
|
||||
const queue = [];
|
||||
const add = (value) => {
|
||||
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
||||
if (!seen.has(value)) {
|
||||
seen.add(value);
|
||||
queue.push(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Accessors from an object literal have no own prototype object, unlike function expressions,
|
||||
// whose prototype.constructor would be converted again without end.
|
||||
function allowOverride(object, key, value, enumerable) {
|
||||
const { get, set } = Object.getOwnPropertyDescriptor(
|
||||
{
|
||||
get accessor() {
|
||||
return value;
|
||||
},
|
||||
set accessor(next) {
|
||||
if (this === object) {
|
||||
throw new TypeError("Cannot assign to read only property '" + String(key) + "' of a built-in");
|
||||
}
|
||||
if ((typeof this !== "object" || this === null) && typeof this !== "function") return;
|
||||
Object.defineProperty(this, key, { value: next, writable: true, enumerable: true, configurable: true });
|
||||
},
|
||||
},
|
||||
"accessor",
|
||||
);
|
||||
Object.defineProperty(object, key, { get, set, enumerable, configurable: false });
|
||||
add(get);
|
||||
add(set);
|
||||
}
|
||||
|
||||
for (const key of Reflect.ownKeys(globalThis)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(globalThis, key);
|
||||
add(descriptor.value);
|
||||
add(descriptor.get);
|
||||
add(descriptor.set);
|
||||
if ("value" in descriptor && descriptor.configurable) {
|
||||
Object.defineProperty(globalThis, key, { writable: false, configurable: false });
|
||||
}
|
||||
}
|
||||
add(Object.getPrototypeOf(function* () {}));
|
||||
add(Object.getPrototypeOf(async function () {}));
|
||||
add(Object.getPrototypeOf(async function* () {}));
|
||||
add(Object.getPrototypeOf(Int8Array));
|
||||
add(Object.getPrototypeOf([][Symbol.iterator]()));
|
||||
add(Object.getPrototypeOf(new Map()[Symbol.iterator]()));
|
||||
add(Object.getPrototypeOf(new Set()[Symbol.iterator]()));
|
||||
add(Object.getPrototypeOf(""[Symbol.iterator]()));
|
||||
add(Object.getPrototypeOf(/a/[Symbol.matchAll]("")));
|
||||
if (typeof Iterator === "function") {
|
||||
if (typeof Iterator.prototype.map === "function") add(Object.getPrototypeOf([].values().map((x) => x)));
|
||||
if (typeof Iterator.from === "function") add(Object.getPrototypeOf(Iterator.from({ next() {} })));
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const object = queue.pop();
|
||||
add(Object.getPrototypeOf(object));
|
||||
const descriptors = Object.getOwnPropertyDescriptors(object);
|
||||
for (const key of Reflect.ownKeys(descriptors)) {
|
||||
const descriptor = descriptors[key];
|
||||
if ("value" in descriptor) {
|
||||
add(descriptor.value);
|
||||
if (
|
||||
descriptor.writable &&
|
||||
descriptor.configurable &&
|
||||
(object === Object.prototype || OVERRIDABLE.has(key))
|
||||
) {
|
||||
allowOverride(object, key, descriptor.value, descriptor.enumerable);
|
||||
}
|
||||
} else {
|
||||
add(descriptor.get);
|
||||
add(descriptor.set);
|
||||
}
|
||||
}
|
||||
Object.freeze(object);
|
||||
}
|
||||
})();
|
||||
const stringify = JSON.stringify;
|
||||
const parse = JSON.parse;
|
||||
const promiseThen = Promise.prototype.then;
|
||||
const ErrorCtor = Error;
|
||||
const TypeErrorCtor = TypeError;
|
||||
const RangeErrorCtor = RangeError;
|
||||
const pending = new Map();
|
||||
let nextId = 1;
|
||||
let finished = false;
|
||||
// Thrown by exit() to unwind the script after it already reported success.
|
||||
const EXIT = Object.freeze({});
|
||||
|
||||
function done(ok, payload, writes) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
bridge("done", ok, payload, writes);
|
||||
}
|
||||
|
||||
function serialize(value) {
|
||||
return value === undefined ? undefined : stringify(value);
|
||||
}
|
||||
|
||||
// QuickJS stacks list frames only. Prefix "Name: message" like V8 so the
|
||||
// text reads the same as a Node error, and drop this prelude's frames.
|
||||
function errorText(error) {
|
||||
const head = error.message ? error.name + ": " + error.message : String(error.name);
|
||||
const frames =
|
||||
typeof error.stack === "string"
|
||||
? error.stack.split("\\n").filter((line) => line.trim() && !line.includes("codemode-prelude.js"))
|
||||
: [];
|
||||
return [head, ...frames].join("\\n");
|
||||
}
|
||||
|
||||
function format(value) {
|
||||
if (typeof value === "string") return value;
|
||||
if (value instanceof ErrorCtor) return errorText(value);
|
||||
try {
|
||||
const json = stringify(value);
|
||||
return json === undefined ? String(value) : json;
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// The host requires string fields. A script can set an error's name or message to anything, so
|
||||
// they are coerced, and a value whose coercion throws gets a placeholder.
|
||||
function describeError(error) {
|
||||
try {
|
||||
if (error instanceof ErrorCtor) {
|
||||
return stringify({ name: String(error.name), message: String(error.message), stack: errorText(error) });
|
||||
}
|
||||
return stringify({ message: String(format(error)) });
|
||||
} catch {
|
||||
return stringify({ message: "The script threw a value that cannot be described" });
|
||||
}
|
||||
}
|
||||
|
||||
function caller(kind, name, spread) {
|
||||
return (...args) =>
|
||||
new Promise((resolve, reject) => {
|
||||
let json;
|
||||
try {
|
||||
json = serialize(spread ? args : args[0]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
const id = nextId++;
|
||||
pending.set(id, { resolve, reject });
|
||||
bridge(kind, id, name, json);
|
||||
});
|
||||
}
|
||||
|
||||
const tools = Object.create(null);
|
||||
const allTools = [];
|
||||
for (const { name, jsName, description } of parse(toolsJson)) {
|
||||
const fn = caller("call", name);
|
||||
// The first tool wins when two names normalize to the same identifier.
|
||||
if (!(jsName in tools)) {
|
||||
tools[jsName] = fn;
|
||||
allTools.push(Object.freeze({ name: jsName, description }));
|
||||
}
|
||||
if (!(name in tools)) tools[name] = fn;
|
||||
}
|
||||
Object.freeze(tools);
|
||||
Object.freeze(allTools);
|
||||
|
||||
// Reading a member that does not exist throws an error that names the close matches, instead of
|
||||
// a later "not a function". \`in\` checks still work.
|
||||
const comparable = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
function guard(target, label, names, hint) {
|
||||
return new Proxy(target, {
|
||||
get(object, property, receiver) {
|
||||
if (typeof property !== "string" || property in object || property in Object.prototype || property === "then" || property === "toJSON") {
|
||||
return Reflect.get(object, property, receiver);
|
||||
}
|
||||
const wanted = comparable(property);
|
||||
const exact = names.filter((name) => comparable(name) === wanted);
|
||||
const close = exact.length > 0 ? exact : names.filter((name) => wanted && (comparable(name).includes(wanted) || wanted.includes(comparable(name))));
|
||||
let message = label + "." + property + " does not exist.";
|
||||
if (close.length > 0) message += " Did you mean " + close.slice(0, 5).map((name) => label + "." + name).join(", ") + "?";
|
||||
else if (names.length <= 20) message += " Available: " + names.join(", ") + ".";
|
||||
if (hint) message += " " + hint;
|
||||
message += ' Check for a member with "' + property + '" in ' + label + ".";
|
||||
throw new TypeErrorCtor(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
const toolsProxy = guard(
|
||||
tools,
|
||||
"tools",
|
||||
allTools.map((tool) => tool.name),
|
||||
"ALL_TOOLS lists every tool; searchTools(query) finds tools by topic.",
|
||||
);
|
||||
|
||||
const namespaces = new Map();
|
||||
for (const { name, spread } of parse(globalsJson)) {
|
||||
const fn = caller("global", name, spread);
|
||||
const dot = name.indexOf(".");
|
||||
if (dot === -1) {
|
||||
Object.defineProperty(globalThis, name, { value: fn, enumerable: true });
|
||||
continue;
|
||||
}
|
||||
const namespace = name.slice(0, dot);
|
||||
if (!namespaces.has(namespace)) namespaces.set(namespace, Object.create(null));
|
||||
namespaces.get(namespace)[name.slice(dot + 1)] = fn;
|
||||
}
|
||||
for (const [namespace, members] of namespaces) {
|
||||
Object.freeze(members);
|
||||
const value = guard(members, namespace, Object.keys(members));
|
||||
Object.defineProperty(globalThis, namespace, { value, enumerable: true });
|
||||
}
|
||||
|
||||
// key -> JSON text. Sizes count key and JSON characters.
|
||||
const stored = new Map(Object.entries(parse(storeJson)));
|
||||
const writes = new Map();
|
||||
let storedChars = 0;
|
||||
for (const [key, json] of stored) storedChars += key.length + json.length;
|
||||
|
||||
const STORE_HINT =
|
||||
"store() is for small state such as IDs or summaries. Show images with image(), keep large data in variables, or write it to a file with a tool.";
|
||||
|
||||
function checkKey(name, key) {
|
||||
if (typeof key !== "string") throw new TypeError(name + "() key must be a string");
|
||||
}
|
||||
|
||||
function store(key, value) {
|
||||
checkKey("store", key);
|
||||
const previous = stored.has(key) ? key.length + stored.get(key).length : 0;
|
||||
if (value === undefined) {
|
||||
stored.delete(key);
|
||||
storedChars -= previous;
|
||||
writes.set(key, undefined);
|
||||
return;
|
||||
}
|
||||
let json;
|
||||
try {
|
||||
json = stringify(value);
|
||||
} catch (error) {
|
||||
throw new TypeError("store(" + stringify(key) + ") value is not JSON-serializable: " + format(error));
|
||||
}
|
||||
if (json === undefined) {
|
||||
throw new TypeError("store(" + stringify(key) + ") value is not JSON-serializable");
|
||||
}
|
||||
if (json.length > ${MAX_STORE_VALUE_CHARS}) {
|
||||
throw new RangeError(
|
||||
"store(" + stringify(key) + ") value has " + json.length + " characters of JSON, more than the limit of ${MAX_STORE_VALUE_CHARS}. " +
|
||||
STORE_HINT,
|
||||
);
|
||||
}
|
||||
const next = storedChars - previous + key.length + json.length;
|
||||
if (next > ${MAX_STORE_TOTAL_CHARS}) {
|
||||
throw new RangeError(
|
||||
"store is full: stored values would exceed ${MAX_STORE_TOTAL_CHARS} characters of JSON. Delete keys with store(key, undefined). " +
|
||||
STORE_HINT,
|
||||
);
|
||||
}
|
||||
stored.set(key, json);
|
||||
storedChars = next;
|
||||
writes.set(key, json);
|
||||
}
|
||||
|
||||
function load(key) {
|
||||
checkKey("load", key);
|
||||
const json = stored.get(key);
|
||||
return json === undefined ? undefined : parse(json);
|
||||
}
|
||||
|
||||
function serializeWrites() {
|
||||
const entries = [];
|
||||
for (const [key, json] of writes) entries.push(json === undefined ? [key] : [key, json]);
|
||||
return stringify(entries);
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, "store", { value: store, enumerable: true });
|
||||
Object.defineProperty(globalThis, "load", { value: load, enumerable: true });
|
||||
|
||||
let outputChars = 0;
|
||||
let outputItems = 0;
|
||||
|
||||
// Past the output limits the script fails: done() reports the error, so catching it does not
|
||||
// resume output, and the host ends the script.
|
||||
function output(kind, data, mimeType) {
|
||||
if (finished) return;
|
||||
outputChars += data.length;
|
||||
outputItems++;
|
||||
if (outputChars > ${MAX_OUTPUT_CHARS} || outputItems > ${MAX_OUTPUT_ITEMS}) {
|
||||
const error = new RangeErrorCtor(
|
||||
"script output exceeded the limit of ${MAX_OUTPUT_CHARS} characters or ${MAX_OUTPUT_ITEMS} text(), image(), and console calls. " +
|
||||
"Print a summary instead, or write large data to a file with a tool.",
|
||||
);
|
||||
done(false, describeError(error));
|
||||
throw error;
|
||||
}
|
||||
bridge("output", kind, data, mimeType);
|
||||
}
|
||||
|
||||
// Primitives become their string form, everything else JSON.
|
||||
function outputText(value) {
|
||||
if (value === undefined || value === null || typeof value !== "object" && typeof value !== "function") {
|
||||
return String(value);
|
||||
}
|
||||
const json = stringify(value);
|
||||
return json === undefined ? String(value) : json;
|
||||
}
|
||||
|
||||
function text(value) {
|
||||
let rendered;
|
||||
try {
|
||||
rendered = outputText(value);
|
||||
} catch (error) {
|
||||
throw new TypeErrorCtor(error instanceof ErrorCtor ? error.message : String(error));
|
||||
}
|
||||
output("text", rendered);
|
||||
}
|
||||
|
||||
function imageUrl(value) {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new TypeErrorCtor(${JSON.stringify(IMAGE_HELPER_EXPECTS)});
|
||||
}
|
||||
if (value.image_url !== undefined) {
|
||||
if (typeof value.image_url !== "string") throw new TypeErrorCtor(${JSON.stringify(IMAGE_HELPER_EXPECTS)});
|
||||
return value.image_url;
|
||||
}
|
||||
if (typeof value.type !== "string") throw new TypeErrorCtor(${JSON.stringify(IMAGE_HELPER_EXPECTS)});
|
||||
if (value.type !== "image") {
|
||||
throw new TypeErrorCtor('image only accepts image blocks, got "' + value.type + '"');
|
||||
}
|
||||
if (typeof value.data !== "string" || value.data === "") throw new TypeErrorCtor("image expected image block data");
|
||||
if (value.data.toLowerCase().startsWith("data:")) return value.data;
|
||||
return "data:;base64," + value.data;
|
||||
}
|
||||
|
||||
// Base64 of the signatures of the formats providers accept inline (PNG, JPEG except
|
||||
// JPEG-LS, GIF, "RIFF....WEBP"). Signatures start at byte 0, so their encodings are prefixes.
|
||||
const IMAGE_SIGNATURES = [
|
||||
["image/png", /^iVBORw0KGg/],
|
||||
["image/jpeg", /^[/]9j[/](?!9)/],
|
||||
["image/gif", /^R0lGOD[dl]h/],
|
||||
["image/webp", /^UklG.{8}RUJQ/],
|
||||
];
|
||||
|
||||
function image(value) {
|
||||
const url = imageUrl(value);
|
||||
if (url === "") throw new TypeErrorCtor(${JSON.stringify(IMAGE_HELPER_EXPECTS)});
|
||||
const colon = url.indexOf(":");
|
||||
const scheme = colon === -1 ? "" : url.slice(0, colon).toLowerCase();
|
||||
if (scheme === "http" || scheme === "https") {
|
||||
throw new TypeErrorCtor("remote image URLs are not supported in tool outputs. Pass a base64 data URI instead");
|
||||
}
|
||||
const comma = url.indexOf(",");
|
||||
const header = comma === -1 ? [] : url.slice(colon + 1, comma).split(";");
|
||||
if (scheme !== "data" || comma === -1 || header.slice(1).every((part) => part.toLowerCase() !== "base64")) {
|
||||
throw new TypeErrorCtor("invalid image output. Pass a base64 data URI instead");
|
||||
}
|
||||
// Providers reject the whole request on a bad image, and a persisted image block would be
|
||||
// resent on every later turn. Line breaks from wrapped base64 are dropped. The declared type
|
||||
// is ignored in favor of the detected one, as providers also reject mismatches.
|
||||
const data = url.slice(comma + 1).replace(/\\s+/g, "");
|
||||
if (data.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) {
|
||||
throw new TypeErrorCtor("invalid image output. The image data is not valid base64 (truncated or corrupted?)");
|
||||
}
|
||||
const head = data.slice(0, 16);
|
||||
const signature = IMAGE_SIGNATURES.find(([, pattern]) => pattern.test(head));
|
||||
if (!signature) {
|
||||
throw new TypeErrorCtor("invalid image output. The image data is not a PNG, JPEG, GIF, or WebP image");
|
||||
}
|
||||
output("image", data, signature[0]);
|
||||
}
|
||||
|
||||
function exit() {
|
||||
let writesJson;
|
||||
try {
|
||||
writesJson = serializeWrites();
|
||||
} catch (error) {
|
||||
done(false, describeError(error));
|
||||
throw EXIT;
|
||||
}
|
||||
done(true, undefined, writesJson);
|
||||
throw EXIT;
|
||||
}
|
||||
|
||||
const console = {};
|
||||
for (const level of ["log", "info", "warn", "error", "debug"]) {
|
||||
console[level] = (...args) => {
|
||||
output("console", args.map(format).join(" "));
|
||||
};
|
||||
}
|
||||
Object.freeze(console);
|
||||
|
||||
Object.defineProperty(globalThis, "tools", { value: toolsProxy, enumerable: true });
|
||||
Object.defineProperty(globalThis, "ALL_TOOLS", { value: allTools, enumerable: true });
|
||||
Object.defineProperty(globalThis, "console", { value: console, enumerable: true });
|
||||
Object.defineProperty(globalThis, "text", { value: text, enumerable: true });
|
||||
Object.defineProperty(globalThis, "image", { value: image, enumerable: true });
|
||||
Object.defineProperty(globalThis, "exit", { value: exit, enumerable: true });
|
||||
|
||||
return {
|
||||
settle(id, ok, payload, propertiesJson) {
|
||||
const entry = pending.get(id);
|
||||
if (!entry) return;
|
||||
pending.delete(id);
|
||||
if (!ok) {
|
||||
const error = new ErrorCtor(payload);
|
||||
if (propertiesJson !== undefined) {
|
||||
const properties = parse(propertiesJson);
|
||||
for (const key of Object.keys(properties)) {
|
||||
if (key === "name" || key === "message" || key === "stack") continue;
|
||||
Object.defineProperty(error, key, { value: properties[key], writable: true, enumerable: true, configurable: true });
|
||||
}
|
||||
}
|
||||
entry.reject(error);
|
||||
return;
|
||||
}
|
||||
let value;
|
||||
try {
|
||||
value = payload === undefined ? undefined : parse(payload);
|
||||
} catch (error) {
|
||||
entry.reject(error);
|
||||
return;
|
||||
}
|
||||
entry.resolve(value);
|
||||
},
|
||||
run(fn) {
|
||||
let promise;
|
||||
try {
|
||||
promise = fn(toolsProxy, console);
|
||||
} catch (error) {
|
||||
done(false, describeError(error));
|
||||
return;
|
||||
}
|
||||
promiseThen.call(
|
||||
promise,
|
||||
(value) => {
|
||||
let json;
|
||||
try {
|
||||
json = serialize(value);
|
||||
} catch (error) {
|
||||
done(false, describeError(error));
|
||||
return;
|
||||
}
|
||||
done(true, json, serializeWrites());
|
||||
},
|
||||
(error) => {
|
||||
done(false, describeError(error));
|
||||
},
|
||||
);
|
||||
},
|
||||
stalled() {
|
||||
if (finished || pending.size > 0) return false;
|
||||
done(
|
||||
false,
|
||||
stringify({
|
||||
name: "Error",
|
||||
message:
|
||||
"The script is waiting on a promise that can never settle: no tool call is pending, and timers do not exist here.",
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
})`;
|
||||
93
third_party/pi/packages/codemode/src/runtime/protocol.ts
vendored
Normal file
93
third_party/pi/packages/codemode/src/runtime/protocol.ts
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { CodemodeDuring, CodemodeOutputItem } from "../types.ts";
|
||||
import type { CodemodeWasmModule } from "../wasm.ts";
|
||||
|
||||
/**
|
||||
* Messages between the host (main thread) and the worker. Tool arguments,
|
||||
* results, and values cross as JSON strings: the worker passes them into and
|
||||
* out of the QuickJS VM as strings and never builds structured values itself.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Everything a VM needs to run one script, independent of where it runs. Plain data, so it can
|
||||
* cross a `postMessage` or a Workers RPC call unchanged.
|
||||
*/
|
||||
export interface VmStartData {
|
||||
code: string;
|
||||
/** `jsName` is the identifier the script uses; `description` is listed in `ALL_TOOLS`. */
|
||||
tools: { name: string; jsName: string; description: string }[];
|
||||
globals: { name: string; spread: boolean }[];
|
||||
memoryLimitBytes: number | undefined;
|
||||
/** Snapshot for `load()`: key to JSON text. */
|
||||
store: Record<string, string>;
|
||||
/**
|
||||
* Run deterministically (`CodemodeSandboxBase.start()`): UTC, a virtual clock starting at `startedAt` (ms since
|
||||
* the epoch) that moves only with delivered results, randomness from `seed`, no `WeakRef`, results applied in
|
||||
* `seq` order, and `started`/`idle` messages.
|
||||
*/
|
||||
deterministic?: { startedAt: number; seed: number };
|
||||
}
|
||||
|
||||
export interface WorkerData extends VmStartData {
|
||||
/** Compiled `quickjs-wasi` module. Structured clone shares the compiled code with the worker. */
|
||||
wasm: CodemodeWasmModule;
|
||||
/**
|
||||
* One Int32 the host sets to non-zero before terminating the worker. The VM's interrupt handler
|
||||
* polls it, because Bun's `worker.terminate()` cannot stop a thread that is spinning in wasm.
|
||||
*/
|
||||
interrupt: SharedArrayBuffer;
|
||||
}
|
||||
|
||||
/** JSON-encoded `{ name?, message, stack? }` of an error thrown by the script. */
|
||||
export type ScriptErrorJson = string;
|
||||
|
||||
export type WorkerToHostMessage =
|
||||
| { type: "call"; id: number; target: "tool" | "global"; name: string; args: string | undefined }
|
||||
| { type: "output"; item: CodemodeOutputItem }
|
||||
/**
|
||||
* `writes` is a JSON array of `[key, json]` for `store()` and `[key]` for deletions. Deterministic mode: `during`
|
||||
* is the result the VM was processing when the script finished (0: its first run) and the calls issued by then.
|
||||
*/
|
||||
| { type: "done"; ok: true; value: string | undefined; writes: string; during?: CodemodeDuring }
|
||||
| { type: "done"; ok: false; error: ScriptErrorJson; during?: CodemodeDuring }
|
||||
/** The VM failed outside the script's control, for example a wasm trap. */
|
||||
| { type: "crash"; message: string }
|
||||
/** Deterministic mode, before the script runs: what the VM is, so a replay can refuse a different one. */
|
||||
| { type: "started"; fingerprint: string }
|
||||
/**
|
||||
* Deterministic mode, after the script waits again: having processed result `seq` (0: the script's first run), it
|
||||
* has issued `issued` calls in total.
|
||||
*/
|
||||
| { type: "idle"; seq: number; issued: number };
|
||||
|
||||
export type HostToWorkerMessage =
|
||||
/**
|
||||
* `payload` is the JSON result when `ok`, otherwise the error message. Deterministic mode: `seq` numbers the
|
||||
* results from 1 in the order the VM applies them, and the VM's clock reads `now` while it processes this one.
|
||||
*/
|
||||
{
|
||||
type: "result";
|
||||
id: number;
|
||||
ok: boolean;
|
||||
payload: string | undefined;
|
||||
seq?: number;
|
||||
now?: number;
|
||||
/** Deterministic mode, `ok: false`: a JSON object whose properties the script's `Error` gets. */
|
||||
properties?: string;
|
||||
};
|
||||
|
||||
export function isWorkerToHostMessage(value: unknown): value is WorkerToHostMessage {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const type = (value as { type?: unknown }).type;
|
||||
return (
|
||||
type === "call" ||
|
||||
type === "output" ||
|
||||
type === "done" ||
|
||||
type === "crash" ||
|
||||
type === "started" ||
|
||||
type === "idle"
|
||||
);
|
||||
}
|
||||
|
||||
export function isHostToWorkerMessage(value: unknown): value is HostToWorkerMessage {
|
||||
return typeof value === "object" && value !== null && (value as { type?: unknown }).type === "result";
|
||||
}
|
||||
277
third_party/pi/packages/codemode/src/runtime/remote.ts
vendored
Normal file
277
third_party/pi/packages/codemode/src/runtime/remote.ts
vendored
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Runs the VM in another isolate, reached over an RPC boundary such as a Cloudflare Durable Object
|
||||
* stub, while tools keep running in the caller.
|
||||
*
|
||||
* Problem: a VM on the calling thread blocks that thread while the script computes. In a Durable
|
||||
* Object that serves an agent session, a CPU-heavy script would stall every other request to the
|
||||
* session (including the user's abort) for seconds. Measured on Cloudflare (October 2026), a
|
||||
* script that computed for 10 seconds delayed the session's other requests by 10 seconds when it
|
||||
* ran in the session object or in a Dynamic Worker, but by about 50 ms when it ran in a Durable
|
||||
* Object of a separately deployed Worker.
|
||||
*
|
||||
* Solution: the caller keeps the host role (`RemoteCodemodeSandbox`: tools, call records,
|
||||
* deadline, abort) and the remote side only runs the VM (`serveCodemodeRemote`, behind
|
||||
* `CodemodeSandboxDurableObject` in `@earendil-works/pi-codemode/cloudflare`).
|
||||
*
|
||||
* Protocol, one RPC call per execution:
|
||||
*
|
||||
* client remote
|
||||
* remote.execute(request, exchange) ───────▶ starts the VM (in-thread, with interrupt budget)
|
||||
* ◀─────── exchange([...outputs, call]) one call per tool call
|
||||
* runs the tool, replies with result ──────▶ VM continues
|
||||
* ◀─────── exchange([...outputs, done]) terminal, reply ignored
|
||||
* execute() resolves ◀────────────────────── VM disposed
|
||||
*
|
||||
* `exchange` is a function the client passes in; over Workers RPC it becomes a stub that calls back
|
||||
* into the client. Output is buffered on the remote side and sent with the next call or the
|
||||
* terminal message, so a script that prints in a loop does not make one RPC per line. Each
|
||||
* `exchange` carries at most one call, so concurrent tool calls are concurrent RPCs. Calls on one
|
||||
* stub are delivered in order, which keeps output ordered before the call or result that follows.
|
||||
*
|
||||
* Termination: the client cannot interrupt a remote VM that is computing. When the client finishes
|
||||
* first (deadline, abort, `close()`), it answers every pending and later `exchange` with
|
||||
* `{ type: "stop" }`, and the remote stops the VM at that point. A script that computes without
|
||||
* calling a tool runs until its interrupt budget is used up. The remote reports that as a
|
||||
* `failure` message, which the client turns into the same `kind: "timeout"` error an in-thread
|
||||
* VM produces.
|
||||
*
|
||||
* Failure: if the RPC call rejects (the remote isolate crashed, hit its CPU limit, or was
|
||||
* redeployed) or returns without a terminal message, the execution fails with `kind: "sandbox"`.
|
||||
*/
|
||||
import type { CodemodeError, CodemodeSandboxBaseOptions } from "../types.ts";
|
||||
import type { CodemodeWasmModule } from "../wasm.ts";
|
||||
import { CodemodeSandboxBase, errorMessage, type VmChannel, type VmLauncher } from "./execution.ts";
|
||||
import { DEFAULT_INTERRUPT_BUDGET, inlineLauncher } from "./inline.ts";
|
||||
import {
|
||||
type HostToWorkerMessage,
|
||||
isWorkerToHostMessage,
|
||||
type VmStartData,
|
||||
type WorkerToHostMessage,
|
||||
} from "./protocol.ts";
|
||||
|
||||
export interface CodemodeRemoteRequest {
|
||||
data: VmStartData;
|
||||
/** Requested interrupt budget. The remote may lower it, see {@link CodemodeServeOptions}. */
|
||||
interruptBudget?: number;
|
||||
}
|
||||
|
||||
/** What the remote sends to the client: VM messages, or a failure outside the VM bridge. */
|
||||
export type CodemodeRemoteMessage = WorkerToHostMessage | { type: "failure"; error: CodemodeError };
|
||||
|
||||
/**
|
||||
* The client's answer to an exchange: the result of the call the exchange carried, `null` when it
|
||||
* carried no call, or `stop` once the client no longer wants the execution.
|
||||
*/
|
||||
export type CodemodeRemoteReply = HostToWorkerMessage | { type: "stop" } | null;
|
||||
|
||||
export type CodemodeRemoteExchange = (messages: CodemodeRemoteMessage[]) => Promise<CodemodeRemoteReply>;
|
||||
|
||||
/**
|
||||
* The remote end, usually a Durable Object stub for `CodemodeSandboxDurableObject`. Anything with
|
||||
* this method works, for example an in-process object in tests.
|
||||
*/
|
||||
export interface CodemodeRemote {
|
||||
execute(request: CodemodeRemoteRequest, exchange: CodemodeRemoteExchange): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RemoteCodemodeSandboxOptions extends CodemodeSandboxBaseOptions {
|
||||
/**
|
||||
* Returns the remote that runs one execution, called once per `execute()`. For a Durable Object,
|
||||
* pick the instance here, for example `() => env.CODEMODE_SANDBOX.getByName(sessionId)`.
|
||||
*/
|
||||
remote: () => CodemodeRemote;
|
||||
/** Interrupt budget requested from the remote. Default: the remote's default. */
|
||||
interruptBudget?: number;
|
||||
}
|
||||
|
||||
/** Output buffered on the remote before it is flushed without waiting for a call. */
|
||||
const FLUSH_ITEMS = 1_000;
|
||||
const FLUSH_CHARS = 1 << 20;
|
||||
|
||||
export interface CodemodeServeOptions {
|
||||
wasm: CodemodeWasmModule | Promise<CodemodeWasmModule>;
|
||||
/** Upper bound for a request's interrupt budget, and the budget when a request names none. */
|
||||
maxInterruptBudget?: number;
|
||||
/** Upper bound for a request's VM memory, and the limit when a request names none. */
|
||||
maxMemoryLimitBytes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote side of {@link RemoteCodemodeSandbox}: runs one execution's VM on this thread and
|
||||
* forwards everything it reports through `exchange`. Resolves once the VM has stopped and the
|
||||
* terminal message has been delivered, or the client asked to stop. Never rejects.
|
||||
*/
|
||||
export function serveCodemodeRemote(
|
||||
request: CodemodeRemoteRequest,
|
||||
exchange: CodemodeRemoteExchange,
|
||||
options: CodemodeServeOptions,
|
||||
): Promise<void> {
|
||||
const maxBudget = options.maxInterruptBudget ?? DEFAULT_INTERRUPT_BUDGET;
|
||||
const budget = Math.min(request.interruptBudget ?? maxBudget, maxBudget);
|
||||
const requestedMemory = request.data.memoryLimitBytes;
|
||||
const maxMemory = options.maxMemoryLimitBytes;
|
||||
const memoryLimitBytes =
|
||||
requestedMemory === undefined
|
||||
? maxMemory
|
||||
: maxMemory === undefined
|
||||
? requestedMemory
|
||||
: Math.min(requestedMemory, maxMemory);
|
||||
const data: VmStartData = { ...request.data, memoryLimitBytes };
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
let channel: VmChannel | undefined;
|
||||
let ended = false;
|
||||
let buffered: CodemodeRemoteMessage[] = [];
|
||||
let bufferedChars = 0;
|
||||
/** Replies that arrived before the launcher returned the channel. */
|
||||
const unsent: HostToWorkerMessage[] = [];
|
||||
|
||||
const takeBuffer = (): CodemodeRemoteMessage[] => {
|
||||
const batch = buffered;
|
||||
buffered = [];
|
||||
bufferedChars = 0;
|
||||
return batch;
|
||||
};
|
||||
const end = (terminal: CodemodeRemoteMessage | undefined): void => {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
const batch = takeBuffer();
|
||||
const delivered = terminal ? exchange([...batch, terminal]).catch(() => null) : Promise.resolve();
|
||||
void delivered
|
||||
.then(() => channel?.stop())
|
||||
.catch(() => undefined)
|
||||
.then(() => resolve());
|
||||
};
|
||||
const reply = (message: CodemodeRemoteReply): void => {
|
||||
if (ended || message === null) return;
|
||||
if (message.type === "stop") {
|
||||
end(undefined);
|
||||
return;
|
||||
}
|
||||
if (channel) channel.post(message);
|
||||
else unsent.push(message);
|
||||
};
|
||||
const onMessage = (message: unknown): void => {
|
||||
if (ended) return;
|
||||
const typed = message as WorkerToHostMessage;
|
||||
switch (typed.type) {
|
||||
case "output": {
|
||||
buffered.push(typed);
|
||||
bufferedChars += typed.item.type === "text" ? typed.item.text.length : typed.item.data.length;
|
||||
if (buffered.length >= FLUSH_ITEMS || bufferedChars >= FLUSH_CHARS) {
|
||||
exchange(takeBuffer()).then(reply, () => end(undefined));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "started":
|
||||
buffered.push(typed);
|
||||
break;
|
||||
// Sent at once: a host replaying a script waits for it to see that the script issued no further call.
|
||||
case "call":
|
||||
case "idle":
|
||||
exchange([...takeBuffer(), typed]).then(reply, () => end(undefined));
|
||||
break;
|
||||
default:
|
||||
end(typed);
|
||||
}
|
||||
};
|
||||
|
||||
const launch = inlineLauncher(options.wasm, budget);
|
||||
Promise.resolve(
|
||||
launch(data, {
|
||||
message: onMessage,
|
||||
failure: (error) => end({ type: "failure", error }),
|
||||
}),
|
||||
).then(
|
||||
(started) => {
|
||||
channel = started;
|
||||
if (ended) {
|
||||
void started.stop().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
for (const message of unsent.splice(0)) started.post(message);
|
||||
},
|
||||
(error: unknown) => end({ type: "failure", error: { kind: "sandbox", message: errorMessage(error) } }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function remoteLauncher(remote: () => CodemodeRemote, interruptBudget: number | undefined): VmLauncher {
|
||||
return (data, events) => {
|
||||
let stopped = false;
|
||||
let settled = false;
|
||||
const waiting = new Map<number, (reply: CodemodeRemoteReply) => void>();
|
||||
|
||||
const exchange: CodemodeRemoteExchange = (messages) =>
|
||||
new Promise<CodemodeRemoteReply>((resolve) => {
|
||||
if (stopped || !Array.isArray(messages)) {
|
||||
resolve({ type: "stop" });
|
||||
return;
|
||||
}
|
||||
// Register the waiter first: the host may answer a call synchronously, for example when the
|
||||
// tool throws before returning a promise.
|
||||
const call = messages.find((message) => isWorkerToHostMessage(message) && message.type === "call");
|
||||
if (call?.type === "call") waiting.set(call.id, resolve);
|
||||
else resolve(null);
|
||||
for (const message of messages) {
|
||||
if (typeof message === "object" && message !== null && message.type === "failure") {
|
||||
settled = true;
|
||||
events.failure(message.error);
|
||||
continue;
|
||||
}
|
||||
// Malformed messages still go to the host, which fails the execution as a broken bridge.
|
||||
events.message(message);
|
||||
if (isWorkerToHostMessage(message) && (message.type === "done" || message.type === "crash"))
|
||||
settled = true;
|
||||
}
|
||||
});
|
||||
|
||||
let target: CodemodeRemote;
|
||||
try {
|
||||
target = remote();
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to reach the remote sandbox: ${errorMessage(error)}`);
|
||||
}
|
||||
const request: CodemodeRemoteRequest = interruptBudget === undefined ? { data } : { data, interruptBudget };
|
||||
Promise.resolve()
|
||||
.then(() => target.execute(request, exchange))
|
||||
.then(
|
||||
() => {
|
||||
if (!settled && !stopped) {
|
||||
events.failure({ kind: "sandbox", message: "Remote sandbox returned before the script settled" });
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!stopped)
|
||||
events.failure({ kind: "sandbox", message: `Remote sandbox failed: ${errorMessage(error)}` });
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
post: (message) => {
|
||||
const resolve = waiting.get(message.id);
|
||||
if (!resolve) return;
|
||||
waiting.delete(message.id);
|
||||
resolve(message);
|
||||
},
|
||||
stop: async () => {
|
||||
stopped = true;
|
||||
for (const resolve of waiting.values()) resolve({ type: "stop" });
|
||||
waiting.clear();
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs each execution's VM on a remote, usually a Cloudflare Durable Object deployed with
|
||||
* `CodemodeSandboxDurableObject`, while tools run here. Same API and results as
|
||||
* `CodemodeSandbox`, so it can replace it. Differences: a script that computes without calling a
|
||||
* tool is limited by the remote's interrupt budget rather than `timeoutMs`, and after a deadline or
|
||||
* abort the remote VM keeps computing until it next calls a tool or uses up that budget.
|
||||
*/
|
||||
export class RemoteCodemodeSandbox extends CodemodeSandboxBase {
|
||||
constructor(options: RemoteCodemodeSandboxOptions) {
|
||||
super(options, remoteLauncher(options.remote, options.interruptBudget));
|
||||
}
|
||||
}
|
||||
341
third_party/pi/packages/codemode/src/runtime/vm.ts
vendored
Normal file
341
third_party/pi/packages/codemode/src/runtime/vm.ts
vendored
Normal file
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Drives one QuickJS VM through one script, independent of the transport that connects it to the
|
||||
* host. The worker thread, the in-thread launcher, and the remote server all run the VM through
|
||||
* this module, so the bridge between prelude and host exists once.
|
||||
*
|
||||
* Contract with the caller:
|
||||
* - `post` receives every message for the host, synchronously from inside VM callbacks. The
|
||||
* caller must not re-enter the VM from `post` (no `deliver`, no `dispose`); a worker posts to
|
||||
* another thread, and the in-thread launcher defers delivery to a microtask.
|
||||
* - `deliver` hands the VM a tool result and runs the script until it waits again. It must only be
|
||||
* called while the VM is idle, which holds for any caller that never re-enters from `post`.
|
||||
* - `shouldInterrupt` is polled by QuickJS while the script runs, and here between batches of
|
||||
* promise jobs. Returning true throws an uncatchable error in the script; the VM then reports
|
||||
* `crash` or a failed `done`. The job batches matter for a script such as `while (true) await
|
||||
* null`: every iteration is a separate job that executes only a few opcodes, so QuickJS's own
|
||||
* polling, which counts opcodes, would let it run for a long time between polls.
|
||||
* - The VM never decides on its own when it is finished: after `done` or `crash` it stays alive
|
||||
* until the caller disposes it (or terminates the worker that hosts it).
|
||||
*
|
||||
* Failures after the VM exists are reported as a `crash` message; `startVm` only rejects when the
|
||||
* VM cannot be created at all.
|
||||
*
|
||||
* Deterministic mode (`data.deterministic`, used by `CodemodeSandboxBase.start()`): a script must make the same
|
||||
* calls with the same arguments every time it runs with the same results, so a host can replay it after a restart.
|
||||
* The VM is otherwise deterministic, because it only runs when the host delivers something and has no timers.
|
||||
* What remains is fixed here:
|
||||
* - Time: every WASI clock reads virtual time, `startedAt` and then the `now` of the result being processed, never going backwards.
|
||||
* While the context is created, the realtime clock reads a value derived from the seed instead, because QuickJS
|
||||
* seeds `Math.random()` from the first clock read, and which read comes first is the engine's business.
|
||||
* - Randomness: `random_get` draws from a PRNG over the seed.
|
||||
* - Timezone: UTC, not the host's.
|
||||
* - Garbage collection: `WeakRef` and `FinalizationRegistry` are not available; collection timing is not.
|
||||
* - Delivery order: results carry a `seq` and are applied strictly in that order, buffering early ones, because a
|
||||
* transport (RPC replies) may deliver them in another order than the host sent them.
|
||||
* The VM reports `started` with its fingerprint before the script runs, and `idle` with the number of calls issued
|
||||
* after each drain, so the host can check a replay step by step.
|
||||
*/
|
||||
import { Intrinsics, JSException, type JSValueHandle, MAX_STACK_SIZE, QuickJS } from "quickjs-wasi";
|
||||
import type { CodemodeWasmModule } from "../wasm.ts";
|
||||
import { PRELUDE_SOURCE } from "./prelude-source.ts";
|
||||
import type { HostToWorkerMessage, VmStartData, WorkerToHostMessage } from "./protocol.ts";
|
||||
|
||||
export interface VmSession {
|
||||
deliver(message: HostToWorkerMessage): void;
|
||||
/** Releases the VM's wasm instance. The session must not be used afterwards. */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface StartVmOptions {
|
||||
data: VmStartData;
|
||||
wasm: CodemodeWasmModule;
|
||||
/**
|
||||
* QuickJS's guard on its own stack, which turns deep recursion into a catchable `RangeError`.
|
||||
* The wasm frames also use the host thread's native stack, so the guard must trip before that
|
||||
* stack runs out: a host overflow fails the whole execution instead. Default: `MAX_STACK_SIZE`
|
||||
* (512 KiB), which fits the 4 MB stack of a Node worker thread but not a 1 MB main thread.
|
||||
*/
|
||||
maxStackSize?: number;
|
||||
/** Interrupt polls this VM may use, if the launcher counts them; part of the fingerprint. */
|
||||
interruptBudget?: number;
|
||||
post(message: WorkerToHostMessage): void;
|
||||
shouldInterrupt(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bumped whenever this module or the prelude changes what a deterministic script observes, beyond what the
|
||||
* fingerprint's other parts (engine versions, prelude hash, limits) already capture.
|
||||
*/
|
||||
const DETERMINISTIC_VERSION = 1;
|
||||
|
||||
/** FNV-1a over UTF-16 code units, as 8 hex digits: identifies the prelude in the fingerprint. */
|
||||
function fnv1a(text: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
hash ^= text.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
const PRELUDE_HASH = fnv1a(PRELUDE_SOURCE);
|
||||
|
||||
/** mulberry32: a small, fast PRNG; one 32-bit draw per call. */
|
||||
function prng(seed: number): () => number {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let value = Math.imul(state ^ (state >>> 15), 1 | state);
|
||||
value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
|
||||
return (value ^ (value >>> 14)) >>> 0;
|
||||
};
|
||||
}
|
||||
|
||||
/** The deterministic VM's clock: seed-derived while the context is created, then virtual time. */
|
||||
interface VirtualClock {
|
||||
creating: boolean;
|
||||
startedAt: number;
|
||||
now: number;
|
||||
seed: number;
|
||||
}
|
||||
|
||||
/** WASI clock and random functions for deterministic mode, on top of {@link discardOutput}. */
|
||||
function deterministicWasi(clock: VirtualClock) {
|
||||
const random = prng(clock.seed ^ 0x9e3779b9);
|
||||
return (memory: { readonly buffer: ArrayBufferLike }) => ({
|
||||
...discardOutput(memory),
|
||||
clock_time_get(clockId: number, _precision: bigint, resultPtr: number): number {
|
||||
let nanoseconds: bigint;
|
||||
// QuickJS seeds Math.random() from this reading in microseconds and turns 0 into 1, so seed s reads s + 1.
|
||||
if (clock.creating) nanoseconds = clockId === 0 ? BigInt(clock.seed + 1) * 1000n : 0n;
|
||||
else if (clockId === 0) nanoseconds = BigInt(Math.round(clock.now)) * 1_000_000n;
|
||||
else nanoseconds = BigInt(Math.round(clock.now - clock.startedAt)) * 1_000_000n;
|
||||
new DataView(memory.buffer).setBigUint64(resultPtr, nanoseconds, true);
|
||||
return 0;
|
||||
},
|
||||
random_get(bufPtr: number, bufLen: number): number {
|
||||
const bytes = new Uint8Array(memory.buffer, bufPtr, bufLen);
|
||||
for (let index = 0; index < bufLen; index += 4) {
|
||||
const value = random();
|
||||
for (let byte = 0; byte < 4 && index + byte < bufLen; byte++) bytes[index + byte] = value >>> (byte * 8);
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* QuickJS writes engine diagnostics to fd 1 and 2, which the default shim
|
||||
* forwards to the host's stdout and stderr. That output belongs to the host
|
||||
* application (for example a TUI), so it is discarded. Reporting every byte as
|
||||
* written keeps libc from retrying.
|
||||
*/
|
||||
function discardOutput(memory: { readonly buffer: ArrayBufferLike }) {
|
||||
return {
|
||||
fd_write(_fd: number, iovsPtr: number, iovsLen: number, nwrittenPtr: number): number {
|
||||
const view = new DataView(memory.buffer);
|
||||
let written = 0;
|
||||
for (let i = 0; i < iovsLen; i++) {
|
||||
written += view.getUint32(iovsPtr + i * 8 + 4, true);
|
||||
}
|
||||
view.setUint32(nwrittenPtr, written, true);
|
||||
return 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function describeException(error: JSException): string {
|
||||
const head = error.message ? `${error.name}: ${error.message}` : error.name;
|
||||
const stack = error.stack?.trimEnd();
|
||||
return JSON.stringify({ name: error.name, message: error.message, stack: stack ? `${head}\n${stack}` : head });
|
||||
}
|
||||
|
||||
/** Promise jobs run between two `shouldInterrupt` polls while draining the job queue. */
|
||||
const JOBS_PER_POLL = 50;
|
||||
|
||||
function crashMessage(error: unknown): WorkerToHostMessage {
|
||||
return { type: "crash", message: error instanceof Error ? `${error.name}: ${error.message}` : String(error) };
|
||||
}
|
||||
|
||||
export async function startVm(options: StartVmOptions): Promise<VmSession> {
|
||||
const { data, post } = options;
|
||||
const maxStackSize = options.maxStackSize ?? MAX_STACK_SIZE;
|
||||
const deterministic = data.deterministic;
|
||||
const clock: VirtualClock | undefined = deterministic && {
|
||||
creating: true,
|
||||
startedAt: deterministic.startedAt,
|
||||
now: deterministic.startedAt,
|
||||
seed: deterministic.seed >>> 0,
|
||||
};
|
||||
const vm = await QuickJS.create({
|
||||
wasm: options.wasm,
|
||||
memoryLimit: data.memoryLimitBytes,
|
||||
// Without a guard, deep recursion overflows the wasm stack and traps instead of throwing a
|
||||
// catchable RangeError.
|
||||
maxStackSize,
|
||||
interruptHandler: options.shouldInterrupt,
|
||||
wasi: clock ? deterministicWasi(clock) : discardOutput,
|
||||
...(clock ? { timezoneOffset: 0, intrinsics: Intrinsics.ALL & ~Intrinsics.WEAK_REF } : {}),
|
||||
});
|
||||
if (clock) clock.creating = false;
|
||||
let disposed = false;
|
||||
/** Calls the script has issued, tools and globals; reported with every `idle`. */
|
||||
let issued = 0;
|
||||
/** Deterministic mode: the next result to apply, and results that arrived before it. */
|
||||
let nextSeq = 1;
|
||||
/** The result being processed, 0 during the script's first run; reported with `done`. */
|
||||
let current = 0;
|
||||
const early = new Map<number, HostToWorkerMessage>();
|
||||
const apply = (message: HostToWorkerMessage): void => {
|
||||
vm.withScope(() => {
|
||||
vm.callFunction(
|
||||
settle,
|
||||
api,
|
||||
vm.newNumber(message.id),
|
||||
message.ok ? vm.true : vm.false,
|
||||
message.payload === undefined ? vm.undefined : vm.newString(message.payload),
|
||||
message.properties === undefined ? vm.undefined : vm.newString(message.properties),
|
||||
);
|
||||
});
|
||||
drain();
|
||||
};
|
||||
const session: VmSession = {
|
||||
deliver(message) {
|
||||
if (disposed) return;
|
||||
try {
|
||||
if (!clock) {
|
||||
apply(message);
|
||||
return;
|
||||
}
|
||||
const { seq } = message;
|
||||
if (seq === undefined || !Number.isInteger(seq) || seq < nextSeq || early.has(seq)) {
|
||||
throw new Error(`Result ${message.id} has sequence number ${seq}, expected ${nextSeq} or later, once`);
|
||||
}
|
||||
early.set(seq, message);
|
||||
for (let next = early.get(nextSeq); next !== undefined; next = early.get(nextSeq)) {
|
||||
early.delete(nextSeq);
|
||||
// Never backwards: a resumed host's clock may be behind the one that started the run.
|
||||
if (next.now !== undefined && Number.isFinite(next.now)) clock.now = Math.max(clock.now, next.now);
|
||||
current = nextSeq;
|
||||
apply(next);
|
||||
post({ type: "idle", seq: nextSeq, issued });
|
||||
nextSeq++;
|
||||
}
|
||||
} catch (error) {
|
||||
post(crashMessage(error));
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
vm.dispose();
|
||||
},
|
||||
};
|
||||
|
||||
let api: JSValueHandle;
|
||||
let settle: JSValueHandle;
|
||||
let stalled: JSValueHandle;
|
||||
// `executePendingJobs()` runs the queue to empty without a chance to interrupt, so jobs run one at a
|
||||
// time through the (internal, but public) exports; quickjs-wasi is pinned to an exact version.
|
||||
const exports = vm._getExports();
|
||||
/** Run queued jobs, then fail a script that waits on nothing that can ever resume it. */
|
||||
const drain = () => {
|
||||
let jobs = 0;
|
||||
while (exports.qjs_is_job_pending()) {
|
||||
// A job only fails for uncatchable errors, such as an interrupt.
|
||||
if (exports.qjs_execute_pending_job() < 0) throw new Error("Promise job failed");
|
||||
if (++jobs % JOBS_PER_POLL === 0 && options.shouldInterrupt()) throw new Error("Execution interrupted");
|
||||
}
|
||||
vm.callFunction(stalled, api).dispose();
|
||||
};
|
||||
|
||||
try {
|
||||
// Called from the prelude with primitives only.
|
||||
const bridge = vm.newFunction("bridge", (kind, a, b, c) => {
|
||||
switch (kind.toString()) {
|
||||
case "call":
|
||||
case "global":
|
||||
issued++;
|
||||
post({
|
||||
type: "call",
|
||||
id: a.toNumber(),
|
||||
target: kind.toString() === "call" ? "tool" : "global",
|
||||
name: b.toString(),
|
||||
args: c === undefined || c.isUndefined ? undefined : c.toString(),
|
||||
});
|
||||
break;
|
||||
case "output":
|
||||
post({
|
||||
type: "output",
|
||||
item:
|
||||
a.toString() === "image"
|
||||
? { type: "image", data: b.toString(), mimeType: c.toString() }
|
||||
: a.toString() === "console"
|
||||
? { type: "text", text: b.toString(), console: true }
|
||||
: { type: "text", text: b.toString() },
|
||||
});
|
||||
break;
|
||||
case "done": {
|
||||
// The host stops the VM on `done`, so the `idle` of this drain never arrives: report its numbers here.
|
||||
const during = clock ? { during: { seq: current, issued } } : {};
|
||||
if (a.toBoolean()) {
|
||||
post({
|
||||
type: "done",
|
||||
ok: true,
|
||||
value: b === undefined || b.isUndefined ? undefined : b.toString(),
|
||||
writes: c.toString(),
|
||||
...during,
|
||||
});
|
||||
} else {
|
||||
post({ type: "done", ok: false, error: b.toString(), ...during });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return vm.undefined;
|
||||
});
|
||||
|
||||
// These handles live as long as the VM, which the caller disposes as a whole.
|
||||
api = vm.withScope((scope) =>
|
||||
scope.escape(
|
||||
vm.callFunction(
|
||||
vm.evalCode(PRELUDE_SOURCE, "codemode-prelude.js"),
|
||||
vm.undefined,
|
||||
bridge,
|
||||
vm.newString(JSON.stringify(data.tools)),
|
||||
vm.newString(JSON.stringify(data.globals)),
|
||||
vm.newString(JSON.stringify(data.store)),
|
||||
),
|
||||
),
|
||||
);
|
||||
settle = api.getProp("settle");
|
||||
const run = api.getProp("run");
|
||||
stalled = api.getProp("stalled");
|
||||
if (clock) {
|
||||
const versions = vm.versions;
|
||||
const budget = options.interruptBudget ?? "none";
|
||||
post({
|
||||
type: "started",
|
||||
fingerprint: `quickjs-wasi ${versions["quickjs-wasi"]}, quickjs ${versions.quickjs}, prelude ${PRELUDE_HASH}, deterministic ${DETERMINISTIC_VERSION}, stack ${maxStackSize}, heap ${data.memoryLimitBytes ?? "none"}, budget ${budget}`,
|
||||
});
|
||||
}
|
||||
|
||||
// The prefix shares the first line with the script so reported line numbers
|
||||
// match the script as written.
|
||||
let fn: JSValueHandle;
|
||||
try {
|
||||
fn = vm.evalCode(`(async (tools, console) => {${data.code}\n})`, "codemode.js");
|
||||
} catch (error) {
|
||||
if (!(error instanceof JSException)) throw error;
|
||||
post({ type: "done", ok: false, error: describeException(error) });
|
||||
return session;
|
||||
}
|
||||
vm.callFunction(run, api, fn).dispose();
|
||||
fn.dispose();
|
||||
drain();
|
||||
if (clock) post({ type: "idle", seq: 0, issued });
|
||||
} catch (error) {
|
||||
post(crashMessage(error));
|
||||
}
|
||||
return session;
|
||||
}
|
||||
39
third_party/pi/packages/codemode/src/runtime/worker.ts
vendored
Normal file
39
third_party/pi/packages/codemode/src/runtime/worker.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Worker thread entry. One worker runs one script inside a fresh QuickJS VM
|
||||
* (a separate wasm instance), relays tool calls and output to the host, and
|
||||
* reports the result. The host terminates the worker when the script settles,
|
||||
* times out, or is aborted; the worker exists so that a spinning script never
|
||||
* blocks the host thread.
|
||||
*
|
||||
* Importing this module starts the worker. Hosts that bundle their code (for
|
||||
* example a Bun compiled executable) add a file that imports
|
||||
* `@earendil-works/pi-codemode/worker` as a separate entrypoint and pass its URL
|
||||
* or embedded-module string specifier as `workerUrl`.
|
||||
*/
|
||||
import { parentPort, workerData } from "node:worker_threads";
|
||||
import { isHostToWorkerMessage, type WorkerData, type WorkerToHostMessage } from "./protocol.ts";
|
||||
import { startVm } from "./vm.ts";
|
||||
|
||||
function post(message: WorkerToHostMessage): void {
|
||||
parentPort?.postMessage(message);
|
||||
}
|
||||
|
||||
async function main(data: WorkerData): Promise<void> {
|
||||
const interrupt = new Int32Array(data.interrupt);
|
||||
// The VM lives until the host terminates the worker, so the session is never disposed.
|
||||
const session = await startVm({
|
||||
data,
|
||||
wasm: data.wasm,
|
||||
post,
|
||||
shouldInterrupt: () => Atomics.load(interrupt, 0) !== 0,
|
||||
});
|
||||
parentPort?.on("message", (message: unknown) => {
|
||||
if (isHostToWorkerMessage(message)) session.deliver(message);
|
||||
});
|
||||
}
|
||||
|
||||
if (parentPort) {
|
||||
main(workerData as WorkerData).catch((error: unknown) => {
|
||||
post({ type: "crash", message: error instanceof Error ? `${error.name}: ${error.message}` : String(error) });
|
||||
});
|
||||
}
|
||||
125
third_party/pi/packages/codemode/src/source.ts
vendored
Normal file
125
third_party/pi/packages/codemode/src/source.ts
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Codemode source format: JavaScript, optionally preceded by one options line.
|
||||
*
|
||||
* ```js
|
||||
* // @options: {"max_output_tokens": 2000, "timeout_ms": 30000}
|
||||
* const text = await tools.read({ path: "package.json" });
|
||||
* text(JSON.parse(text).name);
|
||||
* ```
|
||||
*/
|
||||
|
||||
export const CODEMODE_OPTIONS_PREFIX = "// @options:";
|
||||
|
||||
const SUPPORTED_FIELDS = ["max_output_tokens", "timeout_ms"] as const;
|
||||
const SUPPORTED_FIELDS_TEXT = "`max_output_tokens` and `timeout_ms`";
|
||||
/** Largest delay `setTimeout` supports, which bounds `timeout_ms`. */
|
||||
const MAX_TIMEOUT_MS = 2_147_483_647;
|
||||
|
||||
/**
|
||||
* Lark grammar for providers with grammar-constrained tool input. It only fixes the shape of the
|
||||
* options line; the options JSON and the code are checked by {@link parseCodemodeSource}.
|
||||
*/
|
||||
export const CODEMODE_SOURCE_GRAMMAR = String.raw`
|
||||
start: options_source | plain_source
|
||||
options_source: OPTIONS_LINE NEWLINE SOURCE
|
||||
plain_source: SOURCE
|
||||
|
||||
OPTIONS_LINE: /[ \t]*\/\/ @options:[^\r\n]*/
|
||||
NEWLINE: /\r?\n/
|
||||
SOURCE: /[\s\S]+/
|
||||
`;
|
||||
|
||||
export interface CodemodeSourceOptions {
|
||||
/** Token budget for the script's output. */
|
||||
maxOutputTokens?: number;
|
||||
/** Hard deadline for the whole script in milliseconds, including tool calls. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ParsedCodemodeSource {
|
||||
/** The script with the options line replaced by an empty line, so line numbers are unchanged. */
|
||||
code: string;
|
||||
options: CodemodeSourceOptions;
|
||||
}
|
||||
|
||||
export class CodemodeSourceError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "CodemodeSourceError";
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeInteger(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
function parseOptions(directive: string): CodemodeSourceOptions {
|
||||
if (directive === "") {
|
||||
throw new CodemodeSourceError(`@options must be a JSON object with supported fields ${SUPPORTED_FIELDS_TEXT}`);
|
||||
}
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(directive);
|
||||
} catch (error) {
|
||||
throw new CodemodeSourceError(
|
||||
`@options must be valid JSON with supported fields ${SUPPORTED_FIELDS_TEXT}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new CodemodeSourceError(`@options must be a JSON object with supported fields ${SUPPORTED_FIELDS_TEXT}`);
|
||||
}
|
||||
const fields = value as Record<string, unknown>;
|
||||
for (const key of Object.keys(fields)) {
|
||||
if (!(SUPPORTED_FIELDS as readonly string[]).includes(key)) {
|
||||
throw new CodemodeSourceError(`@options only supports ${SUPPORTED_FIELDS_TEXT}; got \`${key}\``);
|
||||
}
|
||||
}
|
||||
const options: CodemodeSourceOptions = {};
|
||||
const { max_output_tokens, timeout_ms } = fields;
|
||||
if (max_output_tokens !== undefined) {
|
||||
if (!isSafeInteger(max_output_tokens)) {
|
||||
throw new CodemodeSourceError("@options field `max_output_tokens` must be a non-negative safe integer");
|
||||
}
|
||||
options.maxOutputTokens = max_output_tokens;
|
||||
}
|
||||
if (timeout_ms !== undefined) {
|
||||
if (!isSafeInteger(timeout_ms) || timeout_ms === 0 || timeout_ms > MAX_TIMEOUT_MS) {
|
||||
throw new CodemodeSourceError(
|
||||
`@options field \`timeout_ms\` must be a positive integer up to ${MAX_TIMEOUT_MS}`,
|
||||
);
|
||||
}
|
||||
options.timeoutMs = timeout_ms;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an optional first-line `// @options: {...}` from the script. Throws
|
||||
* {@link CodemodeSourceError} for empty input, invalid options, and an options line that is not the
|
||||
* first line, which would otherwise be ignored without notice.
|
||||
*/
|
||||
export function parseCodemodeSource(input: string): ParsedCodemodeSource {
|
||||
if (input.trim() === "") {
|
||||
throw new CodemodeSourceError(
|
||||
'Expected JavaScript source text (non-empty). Provide JS only, optionally with a first line `// @options: {"max_output_tokens": 1000}`.',
|
||||
);
|
||||
}
|
||||
const lines = input.split("\n");
|
||||
const misplaced = lines.findIndex(
|
||||
(line, index) => index > 0 && line.trimStart().startsWith(CODEMODE_OPTIONS_PREFIX),
|
||||
);
|
||||
if (misplaced !== -1) {
|
||||
throw new CodemodeSourceError(
|
||||
`The ${CODEMODE_OPTIONS_PREFIX} line must be the first line of the input; found one on line ${misplaced + 1}`,
|
||||
);
|
||||
}
|
||||
const newline = input.indexOf("\n");
|
||||
const firstLine = lines[0].replace(/\r$/, "");
|
||||
const trimmed = firstLine.trimStart();
|
||||
if (!trimmed.startsWith(CODEMODE_OPTIONS_PREFIX)) return { code: input, options: {} };
|
||||
const code = newline === -1 ? "" : input.slice(newline);
|
||||
if (code.trim() === "") {
|
||||
throw new CodemodeSourceError("The @options line must be followed by JavaScript source on subsequent lines");
|
||||
}
|
||||
return { code, options: parseOptions(trimmed.slice(CODEMODE_OPTIONS_PREFIX.length).trim()) };
|
||||
}
|
||||
254
third_party/pi/packages/codemode/src/types.ts
vendored
Normal file
254
third_party/pi/packages/codemode/src/types.ts
vendored
Normal file
@@ -0,0 +1,254 @@
|
||||
import type { CodemodeWasmModule } from "./wasm.ts";
|
||||
|
||||
export interface CodemodeToolContext {
|
||||
/**
|
||||
* Aborted when the script finishes (including unawaited calls), the
|
||||
* execution times out, the caller aborts, or the sandbox is closed.
|
||||
*/
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
/** A JSON Schema document. Only used to render declarations; values are not validated against it. */
|
||||
export type CodemodeJsonSchema = { [key: string]: unknown } | boolean;
|
||||
|
||||
export interface CodemodeTool {
|
||||
/**
|
||||
* The script calls tools as `tools.<id>(args)`, where `<id>` is the name with characters that
|
||||
* are not valid in identifiers replaced by `_` (see `toCodemodeIdentifier`), and also as
|
||||
* `tools["<name>"](args)`. Globals are called as `<name>(args)` and must be identifiers, or
|
||||
* `<namespace>.<member>`, which groups them into a frozen namespace object.
|
||||
*/
|
||||
name: string;
|
||||
/** Shown as a doc comment in {@link renderDeclarations}, and listed in `ALL_TOOLS` for tools. */
|
||||
description?: string;
|
||||
/** Schema of the single argument. Rendered as the parameter type; `unknown` when omitted. */
|
||||
inputSchema?: CodemodeJsonSchema;
|
||||
/** Schema of the resolved value. Rendered as the promise type; `unknown` when omitted. */
|
||||
outputSchema?: CodemodeJsonSchema;
|
||||
/** Globals only: `execute` receives all call arguments as an array instead of the first one. */
|
||||
spread?: boolean;
|
||||
/**
|
||||
* Globals only: TypeScript parameter list and return type for {@link renderDeclarations}, for
|
||||
* example `(type: string, id?: string): Promise<Model[]>`. Replaces the rendering from the schemas.
|
||||
*/
|
||||
signature?: string;
|
||||
/**
|
||||
* `args` is whatever the script passed, after a JSON round trip. The return
|
||||
* value must be JSON-serializable; a thrown error surfaces in the script as
|
||||
* an `Error` with the same message.
|
||||
*/
|
||||
execute(args: unknown, context: CodemodeToolContext): Promise<unknown> | unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* One item of the script's output, in the order the script produced it: `text()` and `console.*`
|
||||
* produce text items, with `console: true` for `console.*`, and `image()` image items. `data` is base64.
|
||||
*/
|
||||
export type CodemodeOutputItem =
|
||||
| { type: "text"; text: string; console?: true }
|
||||
| { type: "image"; data: string; mimeType: string };
|
||||
|
||||
export type CodemodeCallStatus = "ok" | "error" | "cancelled";
|
||||
|
||||
export interface CodemodeCall {
|
||||
name: string;
|
||||
status: CodemodeCallStatus;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export type CodemodeErrorKind =
|
||||
/** The script threw or failed to parse. `name` and `stack` come from the script's error. */
|
||||
| "script"
|
||||
/**
|
||||
* The overall deadline expired, or a VM on the calling thread used up its interrupt budget.
|
||||
* The execution ended and the VM was asked to stop; a worker or remote VM may still be computing.
|
||||
*/
|
||||
| "timeout"
|
||||
/** The caller's signal fired or the sandbox was closed. The execution ended and the VM was asked to stop. */
|
||||
| "aborted"
|
||||
/**
|
||||
* The VM or its transport failed outside the script's control (for example a wasm trap, a missing
|
||||
* worker file, or a remote sandbox that failed).
|
||||
*/
|
||||
| "sandbox";
|
||||
|
||||
export interface CodemodeError {
|
||||
kind: CodemodeErrorKind;
|
||||
name?: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
}
|
||||
|
||||
/** Keys the script changed with `store()`. Only successful executions report writes. */
|
||||
export interface CodemodeStoreWrites {
|
||||
set: Record<string, unknown>;
|
||||
/** Keys stored as `undefined`. */
|
||||
delete: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* `output` is kept for failed executions too, up to the failure. `exit()` completes with `value: undefined`.
|
||||
* `during`: in a {@link CodemodeSandboxBase.start} run that the script itself ended (it returned, threw, or exited),
|
||||
* the result being processed when it did.
|
||||
*/
|
||||
export type CodemodeResult =
|
||||
| {
|
||||
ok: true;
|
||||
value: unknown;
|
||||
output: CodemodeOutputItem[];
|
||||
calls: CodemodeCall[];
|
||||
storeWrites: CodemodeStoreWrites;
|
||||
during?: CodemodeDuring;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: CodemodeError;
|
||||
output: CodemodeOutputItem[];
|
||||
calls: CodemodeCall[];
|
||||
during?: CodemodeDuring;
|
||||
};
|
||||
|
||||
/** Options every sandbox accepts, wherever its VM runs. */
|
||||
export interface CodemodeSandboxBaseOptions {
|
||||
tools?: CodemodeTool[];
|
||||
/**
|
||||
* Functions exposed as top-level identifiers instead of on `tools`, for host helpers such as
|
||||
* attaching an image to the result. They behave like tools (JSON round trip, promise result)
|
||||
* but are not recorded in `result.calls`. Names must be identifiers and may not shadow the
|
||||
* built-in globals (`tools`, `ALL_TOOLS`, `console`, `text`, `image`, `exit`, `store`, `load`).
|
||||
*/
|
||||
globals?: CodemodeTool[];
|
||||
/**
|
||||
* Overall deadline per execution, including time spent in tools. `Infinity` disables the
|
||||
* deadline; the execution then only ends when the script settles or is aborted.
|
||||
* Default: 300000.
|
||||
*/
|
||||
timeoutMs?: number;
|
||||
/**
|
||||
* Maximum memory the QuickJS VM may allocate. Allocations beyond it fail inside the script as
|
||||
* `InternalError: out of memory`. Default: no limit beyond wasm32's 4 GiB address space.
|
||||
*/
|
||||
memoryLimitBytes?: number;
|
||||
}
|
||||
|
||||
/** Options of `CodemodeSandbox`, which runs each VM in a Node or Bun worker thread. */
|
||||
export interface CodemodeSandboxOptions extends CodemodeSandboxBaseOptions {
|
||||
/**
|
||||
* Compiled `quickjs-wasi/quickjs.wasm`, usually from {@link loadQuickJSWasm}. Default:
|
||||
* `loadQuickJSWasm()`, the file in the installed `quickjs-wasi` package. Pass it when that file
|
||||
* is not on disk, for example in a Bun compiled executable.
|
||||
*/
|
||||
wasm?: CodemodeWasmModule | Promise<CodemodeWasmModule>;
|
||||
/**
|
||||
* Worker entry that imports `@earendil-works/pi-codemode/worker`. Default: this package's own
|
||||
* worker file. Pass it when this package is bundled, since the default is resolved relative to
|
||||
* the module that creates the sandbox. Bun compiled executables require the relative string
|
||||
* specifier of an embedded build entrypoint; other hosts usually use a URL.
|
||||
*/
|
||||
workerUrl?: string | URL;
|
||||
/**
|
||||
* Synchronously reserves capacity immediately before creating each worker, after loading wasm.
|
||||
* Throw to deny admission without starting a worker. Return a release callback, called once if
|
||||
* worker construction fails or when the worker actually exits, not when the result settles.
|
||||
* The release callback must not throw. Reservations can be shared across sandbox instances.
|
||||
*/
|
||||
acquireWorker?(): () => void;
|
||||
/**
|
||||
* Called once for each worker started by execute() or start(). `exited` resolves when that worker
|
||||
* actually exits, which may be after the result and close() settle: on Bun a non-interruptible
|
||||
* built-in can delay termination. Use it to keep the worker counted against host concurrency
|
||||
* limits until it exits. A thrown callback error fails the execution and requests worker termination.
|
||||
*/
|
||||
onWorkerStarted?(exited: Promise<void>): void;
|
||||
}
|
||||
|
||||
/** A call the script made in a {@link CodemodeSandboxBase.start} run, answered with `CodemodeRun.deliver()`. */
|
||||
export interface CodemodeHostCall {
|
||||
/** The VM's own number for the call, `1`, `2`, ... in the order the script made its calls (tools and globals). */
|
||||
id: number;
|
||||
target: "tool" | "global";
|
||||
name: string;
|
||||
/** What the script passed, after a JSON round trip; for spread globals the argument list. */
|
||||
args: unknown;
|
||||
}
|
||||
|
||||
/** A tool as a {@link CodemodeSandboxBase.start} run's script sees it: `tools.<name>` and its `ALL_TOOLS` entry. */
|
||||
export interface CodemodeDeclaredTool {
|
||||
name: string;
|
||||
/**
|
||||
* The identifier the script uses, `tools.<jsName>`. Default: `toCodemodeIdentifier(name)`. A host that replays a run
|
||||
* passes the one the run started with, so a changed mapping cannot rename a tool under a resumed script.
|
||||
*/
|
||||
jsName?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** A global as a {@link CodemodeSandboxBase.start} run's script sees it; the same names as `globals` allow. */
|
||||
export interface CodemodeDeclaredGlobal {
|
||||
name: string;
|
||||
spread?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A call's answer. A rejection becomes an `Error` with `message` in the script, carrying the own properties of
|
||||
* `properties` (JSON values; `name`, `message`, and `stack` are ignored), for example `{ structuredOutput }`.
|
||||
*/
|
||||
export type CodemodeOutcome =
|
||||
| { ok: true; value: unknown }
|
||||
| { ok: false; message: string; properties?: Record<string, unknown> };
|
||||
|
||||
/** In a {@link CodemodeSandboxBase.start} run: the result the VM was processing when the script finished, 0 for its first run, and the calls issued by then. */
|
||||
export interface CodemodeDuring {
|
||||
seq: number;
|
||||
issued: number;
|
||||
}
|
||||
|
||||
export interface CodemodeRunOptions {
|
||||
/** The VM clock's start, in ms since the epoch: `Date.now()` until the first result arrives. */
|
||||
startedAt: number;
|
||||
/** Seeds `Math.random()` and the VM's other randomness. A 32-bit unsigned integer. */
|
||||
seed: number;
|
||||
tools?: CodemodeDeclaredTool[];
|
||||
globals?: CodemodeDeclaredGlobal[];
|
||||
store?: Readonly<Record<string, unknown>>;
|
||||
signal?: AbortSignal;
|
||||
/** Deadline of this run in ms. Default: none (not the sandbox's default). */
|
||||
timeoutMs?: number;
|
||||
/** The VM's fingerprint, before the script runs and before any of its calls. */
|
||||
onStarted?(fingerprint: string): void;
|
||||
/** The script made a call. Answer it with `CodemodeRun.deliver()`, now or later. */
|
||||
onCall(call: CodemodeHostCall): void;
|
||||
/**
|
||||
* The VM processed result `seq` (`0`: the script's first run, before any result) and waits again, having issued
|
||||
* `issued` calls in total.
|
||||
*/
|
||||
onIdle?(seq: number, issued: number): void;
|
||||
}
|
||||
|
||||
/** A running {@link CodemodeSandboxBase.start} execution. */
|
||||
export interface CodemodeRun {
|
||||
readonly result: Promise<CodemodeResult>;
|
||||
/**
|
||||
* Whether the run has its outcome: the script ended, timed out, or failed, or the run was aborted. Set when that is
|
||||
* decided, before `result` settles; calls can no longer be answered. It does not mean the VM has exited.
|
||||
*/
|
||||
readonly finished: boolean;
|
||||
/**
|
||||
* Answer call `id` as result number `seq`. Results are numbered 1, 2, ... without gaps, and the VM applies them
|
||||
* strictly in that order, whatever order they arrive in: after a gap it waits for the missing number. While it
|
||||
* processes this one, its clock reads `now` (ms since the epoch), or the previous value if `now` is earlier.
|
||||
* Throws for a call that is not waiting for an answer, a `seq` used before, or a `now` that is not finite.
|
||||
*/
|
||||
deliver(seq: number, now: number, id: number, outcome: CodemodeOutcome): void;
|
||||
}
|
||||
|
||||
export interface CodemodeExecuteOptions {
|
||||
signal?: AbortSignal;
|
||||
/** Overrides the sandbox default for this execution. */
|
||||
timeoutMs?: number;
|
||||
/**
|
||||
* Values the script reads with `load(key)`. Must be JSON-serializable. The script's own
|
||||
* `store()` calls come back as `result.storeWrites`; persisting them is up to the caller.
|
||||
*/
|
||||
store?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
35
third_party/pi/packages/codemode/src/wasm.ts
vendored
Normal file
35
third_party/pi/packages/codemode/src/wasm.ts
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
/**
|
||||
* A compiled `WebAssembly.Module` of `quickjs-wasi/quickjs.wasm`. Typed opaquely because the Node
|
||||
* type definitions do not declare the WebAssembly globals (they live in the DOM lib).
|
||||
*/
|
||||
export type CodemodeWasmModule = object;
|
||||
|
||||
interface WebAssemblyGlobal {
|
||||
compile(bytes: Uint8Array): Promise<CodemodeWasmModule>;
|
||||
}
|
||||
|
||||
const modules = new Map<string, Promise<CodemodeWasmModule>>();
|
||||
|
||||
/**
|
||||
* Read and compile the QuickJS wasm once per path. `path` defaults to the file in the installed
|
||||
* `quickjs-wasi` package; pass it when that file lives elsewhere, for example embedded in a Bun
|
||||
* compiled executable. A failed load is retried on the next call.
|
||||
*/
|
||||
export function loadQuickJSWasm(path?: string): Promise<CodemodeWasmModule> {
|
||||
const resolved = path ?? createRequire(import.meta.url).resolve("quickjs-wasi/quickjs.wasm");
|
||||
let module = modules.get(resolved);
|
||||
if (!module) {
|
||||
const { WebAssembly } = globalThis as unknown as { WebAssembly: WebAssemblyGlobal };
|
||||
module = readFile(resolved)
|
||||
.then((bytes) => WebAssembly.compile(bytes))
|
||||
.catch((error: unknown) => {
|
||||
modules.delete(resolved);
|
||||
throw error;
|
||||
});
|
||||
modules.set(resolved, module);
|
||||
}
|
||||
return module;
|
||||
}
|
||||
Reference in New Issue
Block a user