import { Deferred, Effect, Exit, Schema } from "effect" import { fromJson } from "./data.js" import type { Extension } from "./extension.js" import { executeProgram } from "./interpreter/execute.js" import { extensionGlobals } from "./interpreter/extensions.js" import { globalNames } from "./interpreter/globals.js" import { scanTools } from "./scan.js" import { fromBytes, SnapshotError, SuspendError, toBytes } from "./suspend-error.js" import { type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js" import type { Tools } from "./tools.js" import type { VmEngine } from "./vm/index.js" export { fromBytes, scanTools, SnapshotError, SuspendError, toBytes } export type SnapshotHeader = VmEngine.SnapshotHeader export type Snapshot = VmEngine.Snapshot /** A tool call admitted during an execution. */ export type { CallResult, ExtensionInvocation, Hooks, ToolCall, ToolDescription, ToolInvocation, } from "./tool-runtime.js" /** Signature-construction helpers for host-owned catalog instructions. */ export { searchSignature, toolExpression } from "./tool-runtime.js" /** Resource budgets enforced independently during each CodeMode program execution. */ export type ExecutionLimits = { /** * Wall-clock milliseconds before interruption. Result delivery waits for tool cleanup. * No default: absent means no timeout. */ readonly timeoutMs?: number /** Maximum number of tool calls admitted by the runtime. No default: absent means unlimited. */ readonly maxToolCalls?: number /** * Maximum UTF-8 bytes retained from the result and logs. Warnings have a separate equal budget; * truncation notices and host formatting are additional. */ readonly maxOutputBytes?: number } export type ResolvedExecutionLimits = { readonly timeoutMs: number | undefined readonly maxToolCalls: number | undefined readonly maxOutputBytes: number | undefined } /** Execution engine: the default AST generator interpreter (`"legacy"`) or the experimental bytecode VM (`"vm"`). */ export type Engine = "legacy" | "vm" /** Options for `VmRuntime.program`. */ export type ProgramOptions = { /** Names this run's host calls (`:`); a random UUID by default. */ readonly runId?: string } /** How `program.suspend` saves a run's state (`engine: "vm"` only). */ export type SuspendOptions = { /** The most bytes a saved state may take; a larger one fails the suspend, and the run goes on. */ readonly maxBytes?: number } /** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */ export type Options = {}, Extensions extends ReadonlyArray = []> = { /** Explicit tools exposed to the program as `tools`. */ tools?: Provided & Tools> /** Hooks around every tool and extension call the program makes; see `Hooks`. */ hooks?: ToolRuntime.Hooks> /** Host functions and objects exposed as globals, also importable by Node module name; see `Extension.make`. */ extensions?: Extensions /** Resource limits enforced on each execution. Time counts active time only, across every resume of a run. */ limits?: ExecutionLimits /** * Interpreter that runs programs. `"vm"` is the experimental bytecode VM, whose runtime (`VmRuntime`) can also * suspend and resume runs; the default is `"legacy"`. */ engine?: Engine } /** The Effect environment an execution needs: what the tools and the extensions' Effect globals require. */ export type Environment, Extensions extends ReadonlyArray> = | Services | Extension.Services /** Options for one CodeMode execution. */ export type ExecuteOptions< Provided extends Record = {}, Extensions extends ReadonlyArray = [], > = Options & { /** Source for one program in the supported JavaScript subset. */ code: string /** Read-only JSON values bound in the program's outer global scope (shadowable by top-level declarations). */ bindings?: Readonly> } /** A JSON value that can cross the confined interpreter boundary. */ export type DataValue = Schema.Json /** Schema for a host tool input containing CodeMode source. */ export const Input = Schema.Struct({ code: Schema.String }) export type Input = typeof Input.Type export const DiagnosticKind = Schema.Literals([ "ParseError", "UnsupportedSyntax", "UnknownTool", "InvalidToolInput", "InvalidToolOutput", "InvalidDataValue", "ToolCallLimitExceeded", "TimeoutExceeded", "ToolFailure", "ExecutionFailure", "Truncated", ]) /** Stable categories produced by program, schema, tool, limit, and truncation diagnostics. */ export type DiagnosticKind = typeof DiagnosticKind.Type export const Diagnostic = Schema.Struct({ kind: DiagnosticKind, message: Schema.String, location: Schema.optionalKey(Schema.Struct({ line: Schema.Number, column: Schema.Number })), suggestions: Schema.optionalKey(Schema.Array(Schema.String)), }) /** A normalized program diagnostic safe to return across an agent tool boundary. */ export type Diagnostic = typeof Diagnostic.Type const ToolCallSchema = Schema.Struct({ name: Schema.String }) export const Success = Schema.Struct({ ok: Schema.Literal(true), value: Schema.Json, warnings: Schema.optionalKey(Schema.Array(Diagnostic)), logs: Schema.optionalKey(Schema.Array(Schema.String)), truncated: Schema.optionalKey(Schema.Boolean), toolCalls: Schema.Array(ToolCallSchema), }) /** Successful execution after the result has crossed the plain-data boundary. */ export type Success = typeof Success.Type export const Failure = Schema.Struct({ ok: Schema.Literal(false), error: Diagnostic, logs: Schema.optionalKey(Schema.Array(Schema.String)), truncated: Schema.optionalKey(Schema.Boolean), toolCalls: Schema.Array(ToolCallSchema), }) /** Failed execution with calls admitted before the diagnostic was produced. */ export type Failure = typeof Failure.Type /** Schema for the structured success or diagnostic returned by CodeMode execution. */ export const Result = Schema.Union([Success, Failure]) /** Result of executing a CodeMode program. Program failures are data, not Effect failures. */ export type Result = typeof Result.Type /** A run stopped by `program.suspend`: its whole state, to resume with `VmRuntime.resume`. */ export type Suspended = { readonly ok: "suspended" readonly state: Snapshot } /** How a run of a program ended: its result, or its saved state once suspended. */ export type Outcome = Result | Suspended /** Reusable confined runtime over explicit tools. */ export type Runtime = { readonly catalog: ReadonlyArray readonly execute: (code: string, bindings?: Readonly>) => Effect.Effect } /** A runtime on the bytecode VM (`engine: "vm"`, experimental): it can also suspend runs and resume them. */ export type VmRuntime = Runtime & { /** A program ready to run once; nothing runs until `program.run()`. */ readonly program: ( code: string, bindings?: Readonly>, options?: ProgramOptions, ) => Program /** * A suspended run (its `state`, or that state as bytes) as a program to continue in this runtime, which must be the * same build with the same extensions. The state is checked when it runs: `run()` fails with a `SnapshotError` when * it can't be resumed here. */ readonly resume: (state: Snapshot | Uint8Array) => Program } /** * One run of a program on the VM. `run()` runs it in the current fiber until it ends or is suspended, and owns its host * calls: interrupting `run()` interrupts them. A program runs once: running it again is a defect. */ export type Program = { readonly run: () => Effect.Effect /** * Saves the run's state and stops it, at once, from another fiber: `run()` then returns the same `Suspended`, and so * does this. It fails with a `SuspendError`, leaving the run running, while a host call (tool, `search`, `fetch`, or * extension call) is in progress, or when the state can't be saved or is over `options.maxBytes`. A run that already * ended gives its result. Before `run()` starts, this waits for it to start. */ readonly suspend: (options?: SuspendOptions) => Effect.Effect } const validateLimit = (name: keyof ExecutionLimits, value: number | undefined, minimum: number): number | undefined => { if (value !== undefined && (!Number.isSafeInteger(value) || value < minimum)) { throw new RangeError(`${name} must be a safe integer greater than or equal to ${minimum}.`) } return value } const resolveExecutionLimits = (limits?: ExecutionLimits): ResolvedExecutionLimits => ({ timeoutMs: validateLimit("timeoutMs", limits?.timeoutMs, 1), maxToolCalls: validateLimit("maxToolCalls", limits?.maxToolCalls, 0), maxOutputBytes: validateLimit("maxOutputBytes", limits?.maxOutputBytes, 0), }) /** Executes one Effect-native CodeMode program without constructing a reusable runtime. */ export const execute = < const Provided extends Record, const Extensions extends ReadonlyArray = [], >( options: ExecuteOptions, ): Effect.Effect> => make(options).execute(options.code, options.bindings) /** Creates an Effect-native runtime over explicit, schema-described tools and extensions. */ export function make< const Provided extends Record = {}, const Extensions extends ReadonlyArray = [], >(options: Options & { engine: "vm" }): VmRuntime> export function make< const Provided extends Record = {}, const Extensions extends ReadonlyArray = [], >(options?: Options): Runtime> export function make< const Provided extends Record = {}, const Extensions extends ReadonlyArray = [], >( options: Options = {}, ): Runtime> | VmRuntime> { type R = Environment const prepared = ToolRuntime.prepare((options.tools ?? {}) as Tools>) const limits = resolveExecutionLimits(options.limits) const extensions = options.extensions ?? [] // Both engines bind the same global names; test/engines.test.ts keeps them in step. const bound = new Set(globalNames) for (const extension of extensions) { for (const name of Object.keys(extension.globals)) { if (bound.has(name)) throw new TypeError(`Extension "${extension.name}" global "${name}" is already defined.`) bound.add(name) } } if (options.engine !== "vm") { return { get catalog() { return prepared.catalog }, execute: (code, bindings) => executeProgram(code, prepared, limits, options.hooks ?? {}, (ctx) => [ ...Object.entries(bindings ?? {}).map(([name, value]) => [name, fromJson(ctx, value)] as const), ...extensionGlobals(ctx, extensions), ]), } } let setup: VmEngine.Setup | undefined const getSetup = (vm: typeof VmEngine) => (setup ??= { prepared, limits, hooks: options.hooks ?? {}, globals: extensions.length === 0 ? undefined : (ctx) => vm.extensionGlobals(ctx, extensions), lossy: extensions.some((extension) => extension.snapshot === undefined), }) const data = (vm: typeof VmEngine, bindings: Readonly> | undefined) => bindings === undefined ? undefined : (ctx: Parameters[0]) => Object.entries(bindings).map(([name, value]) => [name, vm.fromJson(ctx, value)] as const) const program = (code: string, bindings?: Readonly>, programOptions?: ProgramOptions) => vmProgram((vm) => Effect.succeed(vm.startProgram(getSetup(vm), code, data(vm, bindings), programOptions?.runId))) return { get catalog() { return prepared.catalog }, execute: (code, bindings) => Effect.flatMap(loadVm(), (vm) => vm.finishExecution(program(code, bindings).run())), program, resume: (state) => vmProgram((vm) => vm.resumeProgram(getSetup(vm), state)), } satisfies VmRuntime } // A program's one run: `start` makes it once the VM is loaded, and a suspend waits for it. const vmProgram = (start: (vm: typeof VmEngine) => Effect.Effect, E>): Program => { let claimed = false const started = Deferred.makeUnsafe, SuspendError>() return { run: () => Effect.suspend(() => { if (claimed) { return Effect.die( new Error("A CodeMode program runs once; make another with runtime.program or runtime.resume."), ) } claimed = true return loadVm().pipe( Effect.flatMap(start), Effect.onExit((exit) => Exit.isSuccess(exit) ? Deferred.succeed(started, exit.value) : Deferred.fail(started, new SuspendError({ message: "The program didn't start.", running: [] })), ), Effect.flatMap((running) => running.run()), ) }), suspend: (options) => { const maxBytes = options?.maxBytes if (maxBytes !== undefined && (!Number.isSafeInteger(maxBytes) || maxBytes < 0)) { throw new RangeError("maxBytes must be a safe integer greater than or equal to 0.") } return Effect.flatMap(Deferred.await(started), (running) => running.suspend(maxBytes)) }, } } // The VM is loaded on first use so the default engine pulls in none of its code. let vmLoaded: typeof VmEngine | undefined let vmEngine: Promise | undefined const loadVm = () => vmLoaded !== undefined ? Effect.succeed(vmLoaded) : Effect.promise( () => (vmEngine ??= import("./vm/index.js").then((mod) => { vmLoaded = mod return mod })), )