From 8c36ea23cc6c6306ca0cf7c27cf1af98caccd42b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 00:48:15 -0500 Subject: [PATCH 1/2] refactor(codemode): make the host boundary JSON.stringify --- packages/codemode/interpreter-support.md | 35 +-- packages/codemode/src/data.ts | 284 ++++-------------- packages/codemode/src/interpreter/errors.ts | 9 +- packages/codemode/src/interpreter/execute.ts | 8 +- .../codemode/src/interpreter/extensions.ts | 2 +- packages/codemode/src/interpreter/globals.ts | 3 +- .../codemode/src/interpreter/interpreter.ts | 42 +-- packages/codemode/src/interpreter/limits.ts | 2 + packages/codemode/src/stdlib/console.ts | 31 +- packages/codemode/src/stdlib/json.ts | 72 +---- packages/codemode/src/stdlib/object.ts | 6 +- packages/codemode/src/stdlib/string.ts | 7 +- packages/codemode/src/stdlib/url.ts | 47 ++- packages/codemode/src/stdlib/value.ts | 18 +- packages/codemode/src/tool-runtime.ts | 53 ++-- packages/codemode/test/codemode.test.ts | 10 +- packages/codemode/test/extensions.test.ts | 8 +- .../codemode/test/generator-test262.test.ts | 48 +-- packages/codemode/test/parity.test.ts | 42 +-- packages/codemode/test/promise.test.ts | 56 ++-- packages/codemode/test/stdlib.test.ts | 19 +- packages/codemode/test/tool-paths.test.ts | 6 +- 22 files changed, 271 insertions(+), 537 deletions(-) diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index d25ad9d5548f..d9c131607a2a 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -19,14 +19,13 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 TypeScript is transpiled first; the emitted JavaScript must still use the supported subset. - [x] Top-level `await` and `return` through the program's implicit async-function scope. - [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced. -- [x] Program results use JSON-like boundaries, with `undefined` and non-finite numbers normalized to `null`. Tool - arguments follow JSON serialization semantics before their schema applies (see the tools section). Own - `__proto__` keys are dropped wherever a host object crosses to the host, so merging tool inputs or results - cannot replace a prototype; `JSON.stringify` still emits the key, like JS, since a string cannot pollute. -- [x] Values `JSON.stringify` would flatten to `{}` cross the host boundary in a useful form instead: a Set as an - array, a RegExp as `"/source/flags"`, a URLSearchParams as its query string. A Map still crosses as `{}`. - Functions, generators, promises, extension handles, and a Uint8Array are rejected with a hint. In-program - `JSON.stringify` keeps JS behavior for all of these. +- [x] The host boundary is `JSON.stringify`. The program result and tool arguments cross as exactly what + `JSON.stringify` would serialize: `toJSON` is honored, functions and `undefined` properties vanish, + `undefined` array elements and non-finite numbers become `null`, and Map, Set, RegExp, URLSearchParams, + promises, generators, errors, and extension handles serialize as `{}`. A cyclic value throws the same + `TypeError`. A bare `undefined` result is `null`. Tool results come back the way `JSON.parse(JSON.stringify(result))` + would. The one difference from `JSON.stringify`: own `__proto__` keys are dropped when crossing to the host, + so merging tool inputs or results cannot replace a prototype; in-program `JSON.stringify` still emits the key. - [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, and Uint8Array values inside CodeMode. - [x] Tool calls through the host-provided `tools` tree only. - [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is @@ -231,8 +230,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 callables that settle the promise exactly once (they may escape the executor and settle later); an executor throw rejects unless the promise already settled, resolving with a promise or callable thenable adopts it, and resolving with the promise itself rejects with a `TypeError`. Resolver callables work anywhere callbacks are - accepted, including `.then`/`.catch` handlers and collection callbacks, but remain opaque references that cannot - cross the data boundary. + accepted, including `.then`/`.catch` handlers and collection callbacks, and vanish at the data boundary like + any function. - [x] Recursive assimilation of objects with an own callable `then` field across `Promise.resolve`, combinators, constructors, reactions, `finally`, `await`, and async returns. Thenable methods run deferred, receive first-call-wins resolve/reject functions, and ignore throws after settlement. Inherited/accessor `then` fields @@ -240,11 +239,9 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 - [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the last tool supplied for a canonical path wins. - [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys. -- [x] Outbound tool arguments follow JSON serialization semantics, like `JSON.stringify`: object properties with - `undefined` values are dropped, `undefined` array elements and non-finite numbers become `null`, and sparse - arrays densify. Tools never receive `undefined` inside their input object, though a bare `tools.t(undefined)` - argument still reaches schema decoding as `undefined`. Program results keep the stricter - normalization where every `undefined` becomes `null`. +- [x] Outbound tool arguments are what `JSON.stringify` would serialize (see the boundary rule above). Tools never + receive `undefined` inside their input object, though a bare `tools.t(undefined)` argument still reaches schema + decoding as `undefined`. - [ ] Tokenize and case-fold non-ASCII tool paths, descriptions, and queries for tool search. ## Objects and properties @@ -426,8 +423,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 ## Uint8Array -The only binary type. Bytes stay inside the program or cross to extensions; the tool boundary rejects them with a -hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`). +The only binary type. Bytes stay inside the program or cross to extensions as copies; at the tool boundary they +serialize by index like `JSON.stringify`, so encode as text first (`TextDecoder`, `toBase64`, `toHex`). - [x] `new Uint8Array(length | array | iterable | Uint8Array)`, `Uint8Array.from`, `Uint8Array.of`, `fromBase64`, and `fromHex`. Lengths are capped like arrays. @@ -459,8 +456,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls and statics (including through an exposed subclass, so `new this()` works), plus inheritance up to the nearest exposed ancestor. A global that shadows a built-in or another extension throws at `make`. - [x] Instances of exposed classes stay on the host; the program holds a handle whose only members are the class's. - The same host instance is always the same handle within a run, so identity and `instanceof` hold. Handles - cannot cross the data boundary: returning, stringifying, throwing, or passing one to a tool fails. + The same host instance is always the same handle within a run, so identity and `instanceof` hold. A handle + serializes as `{}` like any object without enumerable properties, so the host object never crosses. - [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied, `Date`, `RegExp`, `URL`, `URLSearchParams`, `Map`, `Set`, and `Uint8Array` become fresh copies with their contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out), diff --git a/packages/codemode/src/data.ts b/packages/codemode/src/data.ts index 20ae0aeb82a0..1514ae81cc09 100644 --- a/packages/codemode/src/data.ts +++ b/packages/codemode/src/data.ts @@ -1,239 +1,71 @@ export * as Data from "./data.js" -import type { DiagnosticKind } from "./codemode.js" -import type { Builtins } from "./interpreter/intrinsics.js" -import { - Callable, - define, - entries, - get, - isWrapper, - parseArrayIndex, - Arr, - Bytes, - DateObj, - ErrorObj, - GeneratorObj, - Handle, - MapObj, - Obj, - PromiseObj, - RegExpObj, - SetObj, - URLObj, - URLSearchParamsObj, -} from "./interpreter/objects.js" +import { Effect, type Schema } from "effect" +import type { Interpreter } from "./interpreter/interpreter.js" +import { MAX_VALUE_DEPTH } from "./interpreter/limits.js" +import { rangeError, typeError } from "./interpreter/model.js" +import { Arr, Callable, define, get, keys, Obj, record } from "./interpreter/objects.js" +import { typeofValue } from "./interpreter/references.js" -export const MAX_VALUE_DEPTH = 32 - -export class ToolRuntimeError extends Error { - constructor( - readonly kind: Extract< - DiagnosticKind, - "UnknownTool" | "InvalidToolInput" | "InvalidToolOutput" | "InvalidDataValue" | "ToolCallLimitExceeded" - >, - message: string, - readonly suggestions: ReadonlyArray = [], - ) { - super(message) - this.name = "ToolRuntimeError" - } -} - -/** - * Brings a host-produced value into the program: program values pass through, host Date, RegExp, - * Map, Set, URL, and URLSearchParams become their built-in wrappers, and host objects and arrays - * are copied. - */ -export const toProgram = (builtins: Builtins, value: unknown, label: string): unknown => - copy(value, label, "program", 0, new Set(), builtins) - -/** - * Brings host data into the program: Date and URL become strings, other host collections become - * empty objects, and objects become program copies. Used for tool results and parsed JSON. - */ -export const fromData = (builtins: Builtins, value: unknown, label: string): unknown => - copy(value, label, "data", 0, new Set(), builtins) +export type Json = Schema.Json /** - * Takes a program value out as plain JSON: runtime values serialize like `JSON.stringify` would, - * non-finite numbers become null, and array holes become null. `undefined` object properties are - * dropped ("json") or become null ("result", for program results where the consumer must never see - * undefined); a bare `undefined` follows the same rule. - * - * At the host boundary (tool arguments and program results) `__proto__` keys are dropped and values - * `JSON.stringify` would flatten to `{}` cross in a useful form instead: a Set as an array, a RegExp - * and URLSearchParams as their strings. `JSON.stringify` itself passes `boundary: false` to keep JS - * behavior. + * What `JSON.stringify` would serialize for a program value, as host JSON: `toJSON` is honored, functions and + * `undefined` vanish, non-finite numbers become null, and everything else is copied. Tool arguments and the + * execution result cross the boundary this way, and it is the walk behind the program's own `JSON.stringify`. */ -export const toData = ( - value: unknown, - label: string, - undefinedAs: "json" | "result" = "json", - boundary = true, -): unknown => copy(value, label, undefinedAs, 0, new Set(), undefined, boundary) - -// "program" and "data" build program objects; "json" and "result" build ordinary objects for the ctx. -type Mode = "program" | "data" | "json" | "result" - -const copy = ( +export const toJson = ( + ctx: Interpreter, value: unknown, - label: string, - mode: Mode, - depth: number, - seen: Set, - builtins?: Builtins, - boundary = true, -): unknown => { - const next = (item: unknown) => copy(item, label, mode, depth + 1, seen, builtins, boundary) - if (depth > MAX_VALUE_DEPTH) { - throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`) - } - if (value === undefined) return mode === "result" ? null : undefined - if (typeof value === "number") return (mode === "json" || mode === "result") && !Number.isFinite(value) ? null : value - if (value === null || typeof value === "string" || typeof value === "boolean") return value - if (typeof value !== "object") { - throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`) - } - if (value instanceof PromiseObj) { - throw new ToolRuntimeError( - "InvalidDataValue", - `${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`, - ) - } - if ((value instanceof Callable || value instanceof GeneratorObj) && mode !== "program") { - throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`) - } - if (value instanceof Handle && mode !== "program") { - throw new ToolRuntimeError( - "InvalidDataValue", - `${label} contains a ${value.instance.constructor.name}, which only extension functions accept.`, - ) - } - // Host-produced input never holds program objects; one arriving here would come back as a host object. - if (value instanceof Obj && mode === "data") { - throw new ToolRuntimeError("InvalidDataValue", `${label} must be host data, not a program value.`) - } - - if (builtins !== undefined && mode === "program") { - if (value instanceof Obj) return value - if (value instanceof Date) return new DateObj(builtins.Date, value.getTime()) - if (value instanceof RegExp) return new RegExpObj(builtins.RegExp, value.source, value.flags) - if (value instanceof Map) { - const wrapped = new MapObj(builtins.Map) - for (const [key, item] of value.entries()) wrapped.map.set(next(key), next(item)) - return wrapped - } - if (value instanceof Set) { - const wrapped = new SetObj(builtins.Set) - for (const item of value.values()) wrapped.set.add(next(item)) - return wrapped - } - if (value instanceof URL) return new URLObj(builtins.URL, builtins.URLSearchParams, new URL(value.href)) - if (value instanceof URLSearchParams) - return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value)) - } - - if (value instanceof DateObj) return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null - if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.toISOString() : null - if (value instanceof URLObj) return value.url.href - if (value instanceof URL) return value.href - if (value instanceof Bytes) { - if (boundary) { - throw new ToolRuntimeError( - "InvalidDataValue", - `${label} contains a Uint8Array; pass text instead, e.g. \`new TextDecoder().decode(bytes)\` or \`bytes.toBase64()\`.`, - ) - } - return Object.fromEntries(value.bytes.entries()) - } - if (boundary && builtins === undefined) { - if (value instanceof RegExpObj) return String(value.regex) - if (value instanceof URLSearchParamsObj) return value.params.toString() - if (value instanceof SetObj) { - if (seen.has(value)) throw new ToolRuntimeError("InvalidDataValue", `${label} contains a circular value.`) - seen.add(value) - const copied = Array.from(value.set, (item) => next(item) ?? null) - seen.delete(value) - return copied - } - } - // Remaining wrappers and their host counterparts serialize as empty objects, like JSON.stringify. - if ( - isWrapper(value) || - value instanceof RegExp || - value instanceof Map || - value instanceof Set || - value instanceof URLSearchParams - ) { - return builtins !== undefined ? new Obj(builtins.Object) : {} - } - - if (seen.has(value)) { - throw new ToolRuntimeError("InvalidDataValue", `${label} contains a circular value.`) - } - seen.add(value) - - if (value instanceof Arr) { - const copied = Array.from(value.items, (item) => next(item) ?? null) - seen.delete(value) - return copied - } - if (value instanceof Obj) { - const copied: Record = {} - // Errors serialize as { name, message, ...own }: both may be inherited, and neither is enumerable in JS. - if (value instanceof ErrorObj) { - defineHost(copied, "name", next(get(value, "name"))) - defineHost(copied, "message", next(get(value, "message"))) - } - for (const [key, item] of entries(value)) { - if (boundary && key === "__proto__") continue - const copiedItem = next(item) - if (copiedItem === undefined && mode === "json") continue - defineHost(copied, key, copiedItem) - } - seen.delete(value) - return copied - } - - if (Array.isArray(value)) { - if (builtins !== undefined) { - const copied = new Arr(builtins.Array, value.map(next)) - for (const [key, item] of Object.entries(value)) { - if (parseArrayIndex(key) === undefined) define(copied, key, next(item)) + replacer?: (args: Array) => Effect.Effect, +): Effect.Effect => { + const stack = new Set() + const visit = (holder: Obj, key: string, depth: number): Effect.Effect => + Effect.gen(function* () { + if (depth > MAX_VALUE_DEPTH) throw rangeError(`Value exceeds the maximum depth of ${MAX_VALUE_DEPTH}.`) + const raw = get(holder, key) + const toJSON = raw instanceof Obj ? get(raw, "toJSON") : undefined + const own = toJSON instanceof Callable ? yield* ctx.call(toJSON, raw, [key]) : raw + const value = replacer === undefined ? own : yield* replacer([key, own]) + if (value === undefined || typeofValue(value) === "function") return undefined + if (typeof value === "number") return Number.isFinite(value) ? value : null + if (value === null || typeof value === "string" || typeof value === "boolean") return value + if (!(value instanceof Obj)) return {} + if (stack.has(value)) throw typeError("Converting circular structure to JSON.") + stack.add(value) + if (value instanceof Arr) { + const items: Array = [] + for (let index = 0; index < value.items.length; index += 1) { + items.push((yield* visit(value, String(index), depth + 1)) ?? null) + } + stack.delete(value) + return items + } + const copied: Record = {} + for (const name of keys(value)) { + const item = yield* visit(value, name, depth + 1) + // Own data property regardless of the key, so "__proto__" never reaches the Object.prototype setter. + if (item !== undefined) + Object.defineProperty(copied, name, { value: item, enumerable: true, writable: true, configurable: true }) } - seen.delete(value) + stack.delete(value) return copied - } - const copied = Array.from(value, (item) => next(item) ?? null) - seen.delete(value) - return copied - } + }) + return visit(record(ctx.builtins.Object, { "": value }), "", 0) +} - const prototype = Object.getPrototypeOf(value) - if (prototype !== Object.prototype && prototype !== null) { - throw new ToolRuntimeError("InvalidDataValue", `${label} must contain plain objects only.`) - } +/** The replacer every host boundary applies: a "__proto__" key must never reach host code. */ +export const hostSafe = ([key, value]: Array) => Effect.succeed(key === "__proto__" ? undefined : value) - if (builtins !== undefined) { - const copied = new Obj(builtins.Object) - for (const [key, item] of Object.entries(value)) define(copied, key, next(item)) - seen.delete(value) - return copied - } - const copied: Record = {} - for (const [key, item] of Object.entries(value)) { - if (boundary && key === "__proto__") continue - const copiedItem = next(item) - if (copiedItem === undefined && mode === "json") continue - defineHost(copied, key, copiedItem) - } - seen.delete(value) +/** Host JSON as program values: objects and arrays are copied, primitives pass through. */ +export const fromJson = (ctx: Interpreter, value: unknown): unknown => { + if (value === null || typeof value !== "object") return value + if (Array.isArray(value)) + return new Arr( + ctx.builtins.Array, + value.map((item) => fromJson(ctx, item)), + ) + const copied = new Obj(ctx.builtins.Object) + for (const [key, item] of Object.entries(value)) define(copied, key, fromJson(ctx, item)) return copied } - -// Own data property regardless of the target's prototype, so a "__proto__" key on a host object -// never reaches the Object.prototype setter. -const defineHost = (target: object, key: string, value: unknown): void => { - Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true }) -} diff --git a/packages/codemode/src/interpreter/errors.ts b/packages/codemode/src/interpreter/errors.ts index 23c35f5a2ff6..98911816af84 100644 --- a/packages/codemode/src/interpreter/errors.ts +++ b/packages/codemode/src/interpreter/errors.ts @@ -1,13 +1,14 @@ import { Effect } from "effect" import type { Diagnostic } from "../codemode.js" import { ToolError } from "../tool-error.js" -import { toData, ToolRuntimeError } from "../data.js" +import { ToolRuntimeError } from "../tool-runtime.js" import { type AstNode, formatLocation, PendingThrow, Throw, sourceLocation, typeError } from "./model.js" import { containsRuntimeReference } from "./references.js" import { createErrorValue, type ErrorType, isErrorType } from "./intrinsics.js" import { constructor, methods, prototypeFrom, receiver } from "./native.js" import { type Callable, define, get, hidden, type Native, Arr, ErrorObj, Obj } from "./objects.js" import type { Interpreter } from "./interpreter.js" +import { formatValue } from "../stdlib/console.js" import { coerceToString } from "../stdlib/value.js" export const normalizeError = (error: unknown): Diagnostic => { @@ -44,11 +45,7 @@ export const normalizeError = (error: unknown): Diagnostic => { } else if (typeof value === "string") { message = value } else { - try { - message = JSON.stringify(toData(value, "Thrown value")) ?? String(value) - } catch { - message = String(value) - } + message = formatValue(value) } return { kind: "ExecutionFailure", message: `Uncaught: ${message}` } } diff --git a/packages/codemode/src/interpreter/execute.ts b/packages/codemode/src/interpreter/execute.ts index 23e1ea0c662a..1c3039620bcd 100644 --- a/packages/codemode/src/interpreter/execute.ts +++ b/packages/codemode/src/interpreter/execute.ts @@ -4,7 +4,7 @@ import { Cause, Effect, Scope } from "effect" // pass-through on workerd (the compiler is ~11 MiB and can't init there). import { transpile } from "#transpile" import type { DataValue, Diagnostic, ResolvedExecutionLimits, Result } from "../codemode.js" -import { toData } from "../data.js" +import { hostSafe, toJson } from "../data.js" import { ToolRuntime } from "../tool-runtime.js" import { normalizeError } from "./errors.js" import { createBuiltins } from "./intrinsics.js" @@ -30,7 +30,7 @@ export const executeProgram = ( // Allocate execution state inside suspension so reused Effects never share it. return Effect.suspend(() => { const builtins = createBuiltins() - const tools = ToolRuntime.make(prepared, builtins, limits.maxToolCalls, hooks) + const tools = ToolRuntime.make(prepared, limits.maxToolCalls, hooks) const logs: Array = [] const logged = () => (logs.length > 0 ? { logs: [...logs] } : {}) // Set only after copy-out so timeouts cannot report invalid values as completed. @@ -42,8 +42,8 @@ export const executeProgram = ( Effect.gen(function* () { const program = parseProgram(code) const pending = new Pending(scope, builtins.Promise) - const value = yield* new Interpreter({ tools, pending, builtins, logs, globals }).run(program) - const result = toData(value, "Execution result", "result") as DataValue + const ctx = new Interpreter({ tools, pending, builtins, logs, globals }) + const result = (yield* toJson(ctx, yield* ctx.run(program), hostSafe)) ?? null returned = { value: result, pending } const warnings = yield* pending.interrupt() return { diff --git a/packages/codemode/src/interpreter/extensions.ts b/packages/codemode/src/interpreter/extensions.ts index 496750b96dbf..89a9c6c18974 100644 --- a/packages/codemode/src/interpreter/extensions.ts +++ b/packages/codemode/src/interpreter/extensions.ts @@ -1,9 +1,9 @@ import { Effect } from "effect" -import { MAX_VALUE_DEPTH } from "../data.js" import type { Extension } from "../extension.js" import { coerceToString } from "../stdlib/value.js" import type { Interpreter } from "./interpreter.js" import { createErrorValue, isErrorType } from "./intrinsics.js" +import { MAX_VALUE_DEPTH } from "./limits.js" import { Throw, typeError } from "./model.js" import { constructor, fn } from "./native.js" import { diff --git a/packages/codemode/src/interpreter/globals.ts b/packages/codemode/src/interpreter/globals.ts index 4c1f0c132d3f..a5d59a89132c 100644 --- a/packages/codemode/src/interpreter/globals.ts +++ b/packages/codemode/src/interpreter/globals.ts @@ -54,7 +54,8 @@ type Factory = (ctx: Interpreter) => unknown // A table rather than a list so the names are known before any runtime exists. const table: Record = { tools: () => new ToolReference([]), - search: (ctx) => native(ctx.builtins, { name: "search", call: (_, args) => ctx.tools.search(args), callback: false }), + search: (ctx) => + native(ctx.builtins, { name: "search", call: (_, args) => ctx.tool(ctx.tools.search, args), callback: false }), undefined: () => undefined, NaN: () => NaN, Infinity: () => Infinity, diff --git a/packages/codemode/src/interpreter/interpreter.ts b/packages/codemode/src/interpreter/interpreter.ts index d40accdb1f7a..aac5c0600af6 100644 --- a/packages/codemode/src/interpreter/interpreter.ts +++ b/packages/codemode/src/interpreter/interpreter.ts @@ -42,7 +42,7 @@ import type { YieldExpression, } from "acorn" import { Cause, Deferred, Effect, Exit } from "effect" -import { toProgram } from "../data.js" +import { fromJson, hostSafe, type Json, toJson } from "../data.js" import { ToolReference, type ToolRuntime } from "../tool-runtime.js" import { type AstNode, @@ -291,6 +291,18 @@ export class Interpreter { iterate(value: unknown) { return this.root.iterate(value) } + + /** Runs one host tool: arguments cross as JSON and the result comes back as program values. */ + tool( + run: (args: Array) => Effect.Effect, + args: Array, + ): Effect.Effect { + const ctx = this + return Effect.gen(function* () { + const json = yield* Effect.forEach(args, (arg) => toJson(ctx, arg, hostSafe)) + return fromJson(ctx, yield* run(json)) + }) + } } const MAX_CALL_DEPTH = 10_000 @@ -344,7 +356,7 @@ class Frame { path: ReadonlyArray, args: Array, ): Effect.Effect { - return this.ctx.pending.create(Effect.suspend(() => this.ctx.tools.execute(path, args))) + return this.ctx.pending.create(this.ctx.tool((json) => this.ctx.tools.execute(path, json), args)) } // Fiber exits make settlement idempotent; yielding prevents inline continuation. @@ -1216,7 +1228,8 @@ class Frame { case "Literal": { const regex = node.regex if (regex) return Effect.sync(() => constructRegExp(this.ctx.builtins, [regex.pattern, regex.flags])) - return Effect.sync(() => toProgram(this.ctx.builtins, node.value, "Literal")) + if (typeof node.value === "bigint") throw typeError("BigInt literals are not supported.", node) + return Effect.succeed(node.value) } case "Identifier": return Effect.sync(() => this.scopes.get(node.name, node)) @@ -1305,11 +1318,7 @@ class Frame { const lhs = yield* self.evaluateExpression(left) const rhs = yield* self.evaluateExpression(node.right) if (operator === "instanceof") return instanceofValue(lhs, rhs, node) - return toProgram( - self.ctx.builtins, - self.applyBinaryOperator(operator, lhs, rhs, node), - "Binary expression result", - ) + return self.applyBinaryOperator(operator, lhs, rhs, node) }) } @@ -1429,7 +1438,7 @@ class Frame { default: throw typeError(`Unsupported unary operator '${operator}'.`, node) } - return toProgram(this.ctx.builtins, result, "Unary expression result") + return result }) } @@ -1451,12 +1460,7 @@ class Frame { if (operator !== "=") { const current = self.scopes.get(name, left) const rightValue = yield* self.evaluateExpression(node.right) - const next = toProgram( - self.ctx.builtins, - self.applyCompoundAssignment(operator, current, rightValue, node), - "Assignment result", - ) - return self.scopes.set(name, next, left) + return self.scopes.set(name, self.applyCompoundAssignment(operator, current, rightValue, node), left) } const rightValue = yield* self.evaluateNamed(node.right, name) return self.scopes.set(name, rightValue, left) @@ -1465,11 +1469,7 @@ class Frame { return yield* self.modifyMember(left, (current) => Effect.map(self.evaluateExpression(node.right), (rightValue) => { if (operator === "=") return { write: true, next: rightValue, result: rightValue } - const next = toProgram( - self.ctx.builtins, - self.applyCompoundAssignment(operator, current, rightValue, node), - "Assignment result", - ) + const next = self.applyCompoundAssignment(operator, current, rightValue, node) return { write: true, next, result: next } }), ) @@ -2010,7 +2010,7 @@ class Frame { if (index < expressions.length) { const raw = yield* self.evaluateExpression(expressions[index]) - output += coerceToString(toProgram(self.ctx.builtins, raw, "Template interpolation")) + output += coerceToString(raw) checkStringLength(output.length) } } diff --git a/packages/codemode/src/interpreter/limits.ts b/packages/codemode/src/interpreter/limits.ts index e1c9f2faaf56..18bada5f3012 100644 --- a/packages/codemode/src/interpreter/limits.ts +++ b/packages/codemode/src/interpreter/limits.ts @@ -9,6 +9,8 @@ export const MAX_STRING_LENGTH = 1 << 24 export const MAX_ARRAY_LENGTH = 10_000_000 /** Most promises that may be pending at once. */ export const MAX_PENDING_PROMISES = 10_000 +/** Deepest nesting a value may have when it crosses to or from the host. */ +export const MAX_VALUE_DEPTH = 32 export const checkStringLength = (length: number): void => { if (length > MAX_STRING_LENGTH) throw rangeError("Invalid string length") diff --git a/packages/codemode/src/stdlib/console.ts b/packages/codemode/src/stdlib/console.ts index c6da2fcfe4a5..a20360af9c0a 100644 --- a/packages/codemode/src/stdlib/console.ts +++ b/packages/codemode/src/stdlib/console.ts @@ -1,5 +1,3 @@ -import { toData, toProgram } from "../data.js" -import type { Builtins } from "../interpreter/intrinsics.js" import { type Method, methods } from "../interpreter/native.js" import { entries, @@ -15,7 +13,7 @@ import { URLObj, URLSearchParamsObj, } from "../interpreter/objects.js" -import { containsOpaqueReference, containsRuntimeReference, isRuntimeReference } from "../interpreter/references.js" +import { containsOpaqueReference, isRuntimeReference } from "../interpreter/references.js" import type { Interpreter } from "../interpreter/interpreter.js" import { coerceToString } from "./value.js" @@ -33,7 +31,7 @@ export const consoleGlobal = (ctx: Interpreter) => { name, 0, (_, args) => { - ctx.logs.push(formatConsoleMessage(builtins, name, args)) + ctx.logs.push(formatConsoleMessage(name, args)) return undefined }, ], @@ -44,14 +42,15 @@ export const consoleGlobal = (ctx: Interpreter) => { const MAX_CONSOLE_DEPTH = 32 -const formatConsoleMessage = (builtins: Builtins, name: string, args: Array): string => { - if (name === "dir") return args.length === 0 ? "undefined" : formatConsoleArgument(args[0]) - if (name === "table") return formatConsoleTable(builtins, args[0], args[1]) +const formatConsoleMessage = (name: string, args: Array): string => { + if (name === "dir") return args.length === 0 ? "undefined" : formatValue(args[0]) + if (name === "table") return formatConsoleTable(args[0], args[1]) const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : "" - return `${prefix}${args.map((arg) => formatConsoleArgument(arg)).join(" ")}` + return `${prefix}${args.map((arg) => formatValue(arg)).join(" ")}` } -const formatConsoleArgument = (value: unknown): string => { +/** One value as `console.log` shows it. */ +export const formatValue = (value: unknown): string => { if (value === undefined) return "undefined" if (typeof value === "string") return value return formatConsoleValue(value, new Set(), 0) @@ -103,12 +102,11 @@ const formatConsoleValue = (value: unknown, seen: Set, depth: number): s const formatItems = (items: Array, seen: Set, depth: number): string => items.map((item) => formatConsoleValue(item, seen, depth)).join(",") -const formatConsoleTable = (builtins: Builtins, value: unknown, columnsArgument: unknown): string => { +const formatConsoleTable = (value: unknown, columnsArgument: unknown): string => { if (value === undefined) return "undefined" if (containsOpaqueReference(value)) return "[opaque reference]" - const data = toProgram(builtins, value, "console.table argument") - const columns = consoleTableColumns(columnsArgument) - const rows = consoleTableRows(data, columns) + const columns = columnsArgument instanceof Arr ? columnsArgument.items.map(String) : undefined + const rows = consoleTableRows(value, columns) const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values)))) const header = ["(index)", ...keys].join("\t") return [ @@ -117,13 +115,6 @@ const formatConsoleTable = (builtins: Builtins, value: unknown, columnsArgument: ].join("\n") } -const consoleTableColumns = (value: unknown): ReadonlyArray | undefined => { - if (value === undefined) return undefined - if (containsRuntimeReference(value)) return undefined - const columns = toData(value, "console.table columns", "result") - return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined -} - const consoleTableRows = ( data: unknown, columns: ReadonlyArray | undefined, diff --git a/packages/codemode/src/stdlib/json.ts b/packages/codemode/src/stdlib/json.ts index 17091e18e382..6f0daf92dd1b 100644 --- a/packages/codemode/src/stdlib/json.ts +++ b/packages/codemode/src/stdlib/json.ts @@ -5,8 +5,8 @@ import type { Interpreter } from "../interpreter/interpreter.js" import { checkStringLength } from "../interpreter/limits.js" import { syntaxError, typeError } from "../interpreter/model.js" import { typeofValue } from "../interpreter/references.js" -import { fromData, toData, toProgram } from "../data.js" -import { Callable, get, keys, Arr, Obj, record, remove, set } from "../interpreter/objects.js" +import { fromJson, toJson } from "../data.js" +import { get, keys, Arr, Obj, record, remove, set } from "../interpreter/objects.js" export const jsonGlobal = (ctx: Interpreter) => { const json = new Obj(ctx.builtins.Object) @@ -23,7 +23,7 @@ const parse = (ctx: Interpreter, args: Array): Effect.Effect { try { - return fromData(ctx.builtins, JSON.parse(text), "JSON.parse result") + return fromJson(ctx, JSON.parse(text)) } catch (error) { throw syntaxError(`JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`) } @@ -50,59 +50,17 @@ const stringify = (ctx: Interpreter, args: Array): Effect.Effect< const space = args[2] const indent = typeof space === "number" || typeof space === "string" ? space : undefined const replacer = args[1] - - if (typeofValue(replacer) !== "function") { - const properties = - replacer instanceof Arr - ? replacer.items - .filter((item): item is string | number => typeof item === "string" || typeof item === "number") - .map(String) - : null - // Not a host boundary: __proto__ stays and Set/RegExp/URLSearchParams serialize as {}, like JS. - const text = JSON.stringify(toData(args[0], "JSON.stringify value", "json", false), properties, indent) + const properties = + replacer instanceof Arr + ? replacer.items + .filter((item): item is string | number => typeof item === "string" || typeof item === "number") + .map(String) + : null + const callback = + typeofValue(replacer) === "function" ? applyCollectionCallback(ctx, replacer, "JSON.stringify") : undefined + return Effect.map(toJson(ctx, args[0], callback), (value) => { + const text = JSON.stringify(value, properties, indent) if (text !== undefined) checkStringLength(text.length) - return Effect.succeed(text) - } - - // Validate up front; the replacer walk below reads the original value. - toProgram(ctx.builtins, args[0], "JSON.stringify value") - const apply = applyCollectionCallback(ctx, replacer, "JSON.stringify") - const stack = new Set() - const visit = (holder: Obj, key: string): Effect.Effect => - Effect.gen(function* () { - const value = yield* apply([key, yield* toJSONValue(ctx, get(holder, key), key)]) - if (value === undefined || typeofValue(value) === "function") return undefined - toProgram(ctx.builtins, value, "JSON.stringify replacer result") - if (typeof value === "number") return Number.isFinite(value) ? value : null - if (value === null || typeof value === "string" || typeof value === "boolean") return value - if (!(value instanceof Obj)) return {} - if (stack.has(value)) throw typeError("Converting circular structure to JSON.") - stack.add(value) - if (value instanceof Arr) { - const result: Array = [] - for (let index = 0; index < value.items.length; index += 1) { - result.push((yield* visit(value, String(index))) ?? null) - } - stack.delete(value) - return result - } - const result: Record = Object.create(null) - for (const name of keys(value)) { - const item = yield* visit(value, name) - if (item !== undefined) result[name] = item - } - stack.delete(value) - return result - }) - - return Effect.map(visit(record(ctx.builtins.Object, { "": args[0] }), ""), (value) => - JSON.stringify(value, null, indent), - ) -} - -// SerializeJSONProperty step 2: a callable `toJSON` decides the value, as Date and URL define. -const toJSONValue = (ctx: Interpreter, value: unknown, key: string) => { - if (!(value instanceof Obj)) return Effect.succeed(value) - const toJSON = get(value, "toJSON") - return toJSON instanceof Callable ? ctx.call(toJSON, value, [key]) : Effect.succeed(value) + return text + }) } diff --git a/packages/codemode/src/stdlib/object.ts b/packages/codemode/src/stdlib/object.ts index 0a6777673fa8..4965bbbd66ee 100644 --- a/packages/codemode/src/stdlib/object.ts +++ b/packages/codemode/src/stdlib/object.ts @@ -1,5 +1,4 @@ import { Effect } from "effect" -import { toProgram } from "../data.js" import { constructor, methods, receiver } from "../interpreter/native.js" import { type AstNode, @@ -138,12 +137,11 @@ export const objectGlobal = (ctx: Interpreter) => { "keys", 1, (_, args) => - toProgram( - builtins, + new Arr( + builtins.Array, args[0] instanceof ToolReference ? [...ctx.tools.keys(args[0].path)] : keys(enumerableSource(ctx, "Object.keys(...)", args[0])), - "Object.keys result", ), ], [ diff --git a/packages/codemode/src/stdlib/string.ts b/packages/codemode/src/stdlib/string.ts index cb2e3d22b8f1..00efc0a95b22 100644 --- a/packages/codemode/src/stdlib/string.ts +++ b/packages/codemode/src/stdlib/string.ts @@ -1,5 +1,4 @@ import { Effect } from "effect" -import { toProgram } from "../data.js" import { constructor, type Method, methods } from "../interpreter/native.js" import { checkArrayLength, checkStringLength } from "../interpreter/limits.js" import { invalidData, rangeError, typeError } from "../interpreter/model.js" @@ -66,9 +65,7 @@ const replaceWithCallback = ( const replacement = yield* apply(match.args) output.push( value.slice(end, match.offset), - replacement instanceof PromiseObj - ? "[object Promise]" - : coerceToString(toProgram(builtins, replacement, `String.${name} replacer result`)), + replacement instanceof PromiseObj ? "[object Promise]" : coerceToString(replacement), ) end = match.offset + match.match.length } @@ -209,7 +206,7 @@ export const stringGlobal = (ctx: Interpreter) => { const matched = value.match(pattern) if (matched === null) return null // Preserve the own `index` and `groups` properties on non-global matches. - if (pattern.global) return toProgram(builtins, matched, "String.match result") + if (pattern.global) return new Arr(builtins.Array, [...matched]) return matchToValue(builtins, matched) }), simple("matchAll", 1, (value, args) => { diff --git a/packages/codemode/src/stdlib/url.ts b/packages/codemode/src/stdlib/url.ts index 37dcc18da468..3c1c3b611830 100644 --- a/packages/codemode/src/stdlib/url.ts +++ b/packages/codemode/src/stdlib/url.ts @@ -1,6 +1,4 @@ import { Effect } from "effect" -import { toProgram, ToolRuntimeError } from "../data.js" -import type { Builtins } from "../interpreter/intrinsics.js" import { constructor, fn, type Method, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js" import { PendingThrow, typeError, uriError } from "../interpreter/model.js" import { defineAccessor, entries, isWrapper, Arr, Obj, URLObj, URLSearchParamsObj } from "../interpreter/objects.js" @@ -23,9 +21,6 @@ const urlProperties = [ "hash", ] as const -export const uriArgument = (builtins: Builtins, value: unknown, label: string): string => - coerceToString(toProgram(builtins, value, label)) - type UriFunction = "encodeURI" | "encodeURIComponent" | "decodeURI" | "decodeURIComponent" const uriFunctions: Record string> = { @@ -37,7 +32,7 @@ const uriFunctions: Record string> = { export const uriGlobal = (ctx: Interpreter, name: UriFunction) => fn(ctx.builtins, name, 1, (_, args) => { - const value = uriArgument(ctx.builtins, args[0], `${name} input`) + const value = coerceToString(args[0]) try { return uriFunctions[name](value) } catch (error) { @@ -45,8 +40,7 @@ export const uriGlobal = (ctx: Interpreter, name: UriFunction) => } }) -const urlArgument = (builtins: Builtins, value: unknown, label: string): string => - value instanceof URLObj ? value.url.href : uriArgument(builtins, value, label) +const urlArgument = (value: unknown): string => (value instanceof URLObj ? value.url.href : coerceToString(value)) export const urlGlobal = (ctx: Interpreter) => { const builtins = ctx.builtins @@ -55,8 +49,8 @@ export const urlGlobal = (ctx: Interpreter) => { if (args.length === 0) { throw typeError("new URL(...) requires a URL string and an optional base URL.") } - const input = urlArgument(builtins, args[0], "new URL input") - const base = args[1] === undefined ? undefined : urlArgument(builtins, args[1], "new URL base") + const input = urlArgument(args[0]) + const base = args[1] === undefined ? undefined : urlArgument(args[1]) try { return new URLObj(into, builtins.URLSearchParams, new URL(input, base)) } catch { @@ -74,8 +68,8 @@ export const urlGlobal = (ctx: Interpreter) => { 1, (_, args) => { if (args.length === 0) throw typeError(`URL.${name} requires a URL argument.`) - const input = urlArgument(builtins, args[0], `URL.${name} input`) - const base = args[1] === undefined ? undefined : urlArgument(builtins, args[1], `URL.${name} base`) + const input = urlArgument(args[0]) + const base = args[1] === undefined ? undefined : urlArgument(args[1]) try { const parsed = new URL(input, base) return name === "canParse" ? true : new URLObj(proto, builtins.URLSearchParams, parsed) @@ -97,13 +91,9 @@ export const urlGlobal = (ctx: Interpreter) => { : (thisValue, value) => { const target = self(thisValue, name) try { - ;(target.url as unknown as Record)[name] = uriArgument( - builtins, - value, - `URL.${name} value`, - ) + ;(target.url as unknown as Record)[name] = coerceToString(value) } catch (error) { - if (error instanceof PendingThrow || error instanceof ToolRuntimeError) throw error + if (error instanceof PendingThrow) throw error throw typeError(`URL.${name} received an invalid value.`) } }, @@ -130,7 +120,7 @@ const readPair = (ctx: Interpreter, value: unknown): Effect.Effect uriArgument(ctx.builtins, step.value, "URLSearchParams pair value")), + Effect.sync(() => coerceToString(step.value)), ), ) } @@ -189,8 +179,7 @@ export const urlSearchParamsGlobal = (ctx: Interpreter) => { const self = (thisValue: unknown, name: string) => receiver(URLSearchParamsObj, thisValue, `URLSearchParams.prototype.${name}`) const wrap = (items: Array) => new Arr(builtins.Array, items) - const arg = (name: string, args: Array, index: number): string => - uriArgument(builtins, args[index], `URLSearchParams.${name} argument ${index + 1}`) + const arg = (args: Array, index: number): string => coerceToString(args[index]) const requireArgs = (name: string, args: Array, count: number): void => { if (args.length < count) { throw typeError(`URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`) @@ -203,7 +192,7 @@ export const urlSearchParamsGlobal = (ctx: Interpreter) => { 2, (thisValue, args) => { requireArgs("append", args, 2) - self(thisValue, "append").params.append(arg("append", args, 0), arg("append", args, 1)) + self(thisValue, "append").params.append(arg(args, 0), arg(args, 1)) return undefined }, ], @@ -213,8 +202,8 @@ export const urlSearchParamsGlobal = (ctx: Interpreter) => { (thisValue, args) => { requireArgs("delete", args, 1) const params = self(thisValue, "delete").params - if (args[1] !== undefined) params.delete(arg("delete", args, 0), arg("delete", args, 1)) - else params.delete(arg("delete", args, 0)) + if (args[1] !== undefined) params.delete(arg(args, 0), arg(args, 1)) + else params.delete(arg(args, 0)) return undefined }, ], @@ -223,7 +212,7 @@ export const urlSearchParamsGlobal = (ctx: Interpreter) => { 1, (thisValue, args) => { requireArgs("get", args, 1) - return self(thisValue, "get").params.get(arg("get", args, 0)) + return self(thisValue, "get").params.get(arg(args, 0)) }, ], [ @@ -231,7 +220,7 @@ export const urlSearchParamsGlobal = (ctx: Interpreter) => { 1, (thisValue, args) => { requireArgs("getAll", args, 1) - return wrap(self(thisValue, "getAll").params.getAll(arg("getAll", args, 0))) + return wrap(self(thisValue, "getAll").params.getAll(arg(args, 0))) }, ], [ @@ -240,9 +229,7 @@ export const urlSearchParamsGlobal = (ctx: Interpreter) => { (thisValue, args) => { requireArgs("has", args, 1) const params = self(thisValue, "has").params - return args[1] !== undefined - ? params.has(arg("has", args, 0), arg("has", args, 1)) - : params.has(arg("has", args, 0)) + return args[1] !== undefined ? params.has(arg(args, 0), arg(args, 1)) : params.has(arg(args, 0)) }, ], [ @@ -250,7 +237,7 @@ export const urlSearchParamsGlobal = (ctx: Interpreter) => { 2, (thisValue, args) => { requireArgs("set", args, 2) - self(thisValue, "set").params.set(arg("set", args, 0), arg("set", args, 1)) + self(thisValue, "set").params.set(arg(args, 0), arg(args, 1)) return undefined }, ], diff --git a/packages/codemode/src/stdlib/value.ts b/packages/codemode/src/stdlib/value.ts index 2edfc1d3f340..759699b0b8dc 100644 --- a/packages/codemode/src/stdlib/value.ts +++ b/packages/codemode/src/stdlib/value.ts @@ -1,4 +1,3 @@ -import { toProgram } from "../data.js" import { fn } from "../interpreter/native.js" import { typeError } from "../interpreter/model.js" import { @@ -74,22 +73,21 @@ const coerce = (ctx: Interpreter, name: Coercion, args: Array): u if (name === "parseInt") return parseInt(coerceToString(raw)) return parseFloat(coerceToString(raw)) } - const value = toProgram(ctx.builtins, raw, `${name} input`) - if (name === "Number") return coerceToNumber(value) - if (name === "Boolean") return Boolean(value) - if (name === "isFinite") return Number.isFinite(coerceToNumber(value)) - if (name === "isNaN") return Number.isNaN(coerceToNumber(value)) + if (name === "Number") return coerceToNumber(raw) + if (name === "Boolean") return Boolean(raw) + if (name === "isFinite") return Number.isFinite(coerceToNumber(raw)) + if (name === "isNaN") return Number.isNaN(coerceToNumber(raw)) if (name === "parseInt") { const radix = args[1] if (radix !== undefined && typeof radix !== "number") { throw typeError("parseInt expects a numeric radix.") } - return parseInt(coerceToString(value), radix) + return parseInt(coerceToString(raw), radix) } - if (name === "parseFloat") return parseFloat(coerceToString(value)) - return coerceToString(value) + if (name === "parseFloat") return parseFloat(coerceToString(raw)) + return coerceToString(raw) } /** A global coercion function such as `Number` or `parseInt`. */ export const coercion = (ctx: Interpreter, name: Coercion, length = 1): Native => - fn(ctx.builtins, name, length, (_, args) => toProgram(ctx.builtins, coerce(ctx, name, args), `${name} result`)) + fn(ctx.builtins, name, length, (_, args) => coerce(ctx, name, args)) diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 7fa42fa67761..5c39703bee0b 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -1,6 +1,6 @@ import { Cause, Effect, Exit, Formatter, Schema } from "effect" -import { fromData, toData, ToolRuntimeError } from "./data.js" -import type { Builtins } from "./interpreter/intrinsics.js" +import type { Json } from "./data.js" +import type { DiagnosticKind } from "./codemode.js" import { toolError } from "./tool-error.js" import { decodeInput as decodeToolInput, @@ -299,17 +299,34 @@ const resolve = (root: ToolNode, path: ReadonlyArray): Tool => return node.tool } +export class ToolRuntimeError extends Error { + constructor( + readonly kind: Extract< + DiagnosticKind, + "UnknownTool" | "InvalidToolInput" | "InvalidToolOutput" | "ToolCallLimitExceeded" + >, + message: string, + readonly suggestions: ReadonlyArray = [], + ) { + super(message) + this.name = "ToolRuntimeError" + } +} + +/** The tool bridge of one execution. Arguments arrive and results leave as JSON; program values never enter. */ export type ToolRuntime = { readonly calls: Array - readonly execute: (path: ReadonlyArray, args: Array) => Effect.Effect - readonly search: (args: Array) => Effect.Effect + readonly execute: ( + path: ReadonlyArray, + args: Array, + ) => Effect.Effect + readonly search: (args: Array) => Effect.Effect readonly keys: (path: ReadonlyArray) => ReadonlyArray } /** Per-execution call state over tools prepared once for the runtime. */ export const make = ( prepared: Prepared, - builtins: Builtins, maxToolCalls: number | undefined, hooks?: ToolCallHooks, ): ToolRuntime => { @@ -340,9 +357,9 @@ export const make = ( calls.push(call) } - const executeTool = (name: string, tool: Tool, externalArgs: Array) => + const executeTool = (name: string, tool: Tool, args: Array) => Effect.gen(function* () { - const normalized = externalArgs.length === 0 ? [{}] : externalArgs + const normalized = args.length === 0 ? [{}] : args if (normalized.length !== 1) throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects at most one input object.`) const input = yield* Effect.try({ @@ -374,8 +391,12 @@ export const make = ( ) }), ) + // The same round trip a tool result would take through text: exact JSON.stringify semantics. return yield* Effect.try({ - try: () => fromData(builtins, decodeToolOutput(tool, raw), `Result from tool '${name}'`), + try: (): Json | undefined => { + const text = JSON.stringify(decodeToolOutput(tool, raw)) + return text === undefined ? undefined : JSON.parse(text) + }, catch: (cause) => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}': ${cause}`), }) }), @@ -386,21 +407,9 @@ export const make = ( return { calls, keys: (path) => namespaceKeys(root, path), - search: (args) => - Effect.suspend(() => - executeTool( - "search", - searchTool, - args.map((arg) => toData(arg, "Arguments for tool 'search'")), - ), - ), + search: (args) => Effect.suspend(() => executeTool("search", searchTool, args)), execute: (path, args) => - Effect.gen(function* () { - const name = canonicalSegments(path).join(".") - const externalArgs = args.map((arg) => toData(arg, `Arguments for tool '${name}'`)) - const tool = resolve(root, path) - return yield* executeTool(name, tool, externalArgs) - }), + Effect.suspend(() => executeTool(canonicalSegments(path).join("."), resolve(root, path), args)), } } diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 2a34962a5069..0fb371e4f88d 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -863,10 +863,9 @@ describe("CodeMode public contract", () => { const shadowed = await Effect.runPromise(runtime.execute(`const search = () => "local"; return search()`)) expect(shadowed.ok).toBe(true) if (shadowed.ok) expect(shadowed.value).toBe("local") - // The reference itself cannot cross the data boundary. + // The reference itself vanishes at the data boundary, as functions do in JSON.stringify. const escaped = await Effect.runPromise(runtime.execute(`return { search }`)) - expect(escaped.ok).toBe(false) - if (!escaped.ok) expect(escaped.error.kind).toBe("InvalidDataValue") + expect(escaped).toMatchObject({ ok: true, value: {} }) }) test("search defaults to 10 results and resolves exact tool paths", async () => { @@ -1104,7 +1103,7 @@ describe("CodeMode public contract", () => { expect(observed).toStrictEqual([{ value: 21 }, 21]) }) - test("returns JSON-safe data and normalizes undefined to null", async () => { + test("returns JSON-safe data: undefined vanishes as in JSON.stringify, and a bare undefined is null", async () => { const result = await Effect.runPromise( CodeMode.execute({ code: `return { top: undefined, nested: [1, undefined] }`, @@ -1112,9 +1111,10 @@ describe("CodeMode public contract", () => { ) expect(result).toStrictEqual({ ok: true, - value: { top: null, nested: [1, null] }, + value: { nested: [1, null] }, toolCalls: [], }) + expect(await Effect.runPromise(CodeMode.execute({ code: `return undefined` }))).toMatchObject({ value: null }) expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result) }) diff --git a/packages/codemode/test/extensions.test.ts b/packages/codemode/test/extensions.test.ts index 1f3298f45727..0e84e2933c43 100644 --- a/packages/codemode/test/extensions.test.ts +++ b/packages/codemode/test/extensions.test.ts @@ -244,9 +244,9 @@ describe("the host object behind a handle is unreachable", () => { ).toEqual([[], [], "[object Object]"]) }) - test("a handle cannot be returned, stringified, or handed to a tool", async () => { - expect(await failure(`return new Bag()`)).toMatchObject({ kind: "InvalidDataValue" }) - expect((await failure(`return JSON.stringify(new Bag())`)).message).toContain("contains a Bag") + test("a handle serializes as {} when returned, stringified, or handed to a tool", async () => { + expect(await value(`return new Bag()`)).toEqual({}) + expect(await value(`return JSON.stringify(new Bag())`)).toBe("{}") const tools = CodeMode.make({ extensions: [extension], tools: { @@ -258,7 +258,7 @@ describe("the host object behind a handle is unreachable", () => { }), }, }) - expect((await failure(`return await tools.echo({ v: new Bag() })`, tools)).message).toContain("contains a Bag") + expect(await value(`return await tools.echo({ v: new Bag() })`, tools)).toEqual({}) }) test("a method only runs on a handle of its own class", async () => { diff --git a/packages/codemode/test/generator-test262.test.ts b/packages/codemode/test/generator-test262.test.ts index 523e73714a3a..c79274f94d56 100644 --- a/packages/codemode/test/generator-test262.test.ts +++ b/packages/codemode/test/generator-test262.test.ts @@ -47,7 +47,7 @@ describe("confined generators", () => { { value: 2, done: false }, { value: 4, done: false }, { value: 7, done: true }, - { value: null, done: true }, + { done: true }, ]) }) @@ -84,7 +84,7 @@ describe("confined generators", () => { try { iterator.throw("second") } catch (error) { exhausted = error } return [suspended, exhausted, iterator.next()] `), - ).toEqual(["first", "second", { value: null, done: true }]) + ).toEqual(["first", "second", { done: true }]) }) test("rejects synchronous generator reentry", async () => { @@ -236,11 +236,7 @@ describe("confined generators", () => { `), ).toEqual([ [true, true, true], - [ - { value: 1, done: false }, - { value: 3, done: true }, - { value: null, done: true }, - ], + [{ value: 1, done: false }, { value: 3, done: true }, { done: true }], ["start", "received 2"], ]) }) @@ -339,7 +335,7 @@ describe("confined generators", () => { { value: 1, done: false }, { value: "recovered", done: false }, ["caught bad"], - { value: null, done: true }, + { done: true }, ["caught bad", "finally"], ]) }) @@ -391,11 +387,8 @@ describe("confined generators", () => { ).toEqual([[1, 2], "TypeError"]) }) - test("keeps generator references opaque at the data boundary", async () => { - const result = await execute(`function* generate() { yield 1 } return generate()`) - expect(result.ok).toBe(false) - if (result.ok) return - expect(result.error.kind).toBe("InvalidDataValue") + test("a returned generator serializes as {} like JSON.stringify", async () => { + expect(await value(`function* generate() { yield 1 } return generate()`)).toEqual({}) }) // test/built-ins/GeneratorPrototype/return/from-state-suspended-start.js @@ -421,14 +414,7 @@ describe("confined generators", () => { return [startReturn, afterReturn, startThrow, afterThrow, completedThrow, events] `), - ).toEqual([ - { value: 7, done: true }, - { value: null, done: true }, - "start", - { value: null, done: true }, - "completed", - [], - ]) + ).toEqual([{ value: 7, done: true }, { done: true }, "start", { done: true }, "completed", []]) }) // test/built-ins/AsyncGeneratorPrototype/return/return-suspendedStart-promise.js @@ -454,14 +440,7 @@ describe("confined generators", () => { return [startReturn, afterReturn, startThrow, afterThrow, completedThrow, events] `), - ).toEqual([ - { value: 7, done: true }, - { value: null, done: true }, - "start", - { value: null, done: true }, - "completed", - [], - ]) + ).toEqual([{ value: 7, done: true }, { done: true }, "start", { done: true }, "completed", []]) }) // test/built-ins/AsyncGeneratorPrototype/return/return-suspendedYield-try-finally.js @@ -512,7 +491,7 @@ describe("confined generators", () => { } return results `), - ).toEqual(["direct", { value: null, done: true }, "delegated", { value: null, done: true }]) + ).toEqual(["direct", { done: true }, "delegated", { done: true }]) }) // test/built-ins/AsyncFromSyncIteratorPrototype/next/for-await-iterator-next-rejected-promise-close.js @@ -1033,7 +1012,7 @@ describe("confined generators", () => { ).toBe("a=1") }) - test("converts URLSearchParams pair elements before requesting the next", async () => { + test("coerces URLSearchParams pair elements like JS and closes both generators", async () => { expect( await value(` const events = [] @@ -1048,11 +1027,10 @@ describe("confined generators", () => { function* entries() { try { yield pair() } finally { events.push("outer close") } } - let name - try { new URLSearchParams(entries()) } catch (error) { name = error.name } - return [events, name] + const params = new URLSearchParams(entries()) + return [events, params.toString()] `), - ).toEqual([["first", "pair close", "outer close"], "Error"]) + ).toEqual([["first", "second", "pair close", "outer close"], "%5Bobject+Object%5D=2"]) }) test("validates URLSearchParams pair lengths after converting the outer sequence", async () => { diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index 0fca774185fa..1fa4712ca7fa 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { CodeMode } from "../src/index.js" -import { Data } from "../src/data.js" // Runs a CodeMode program with no host tools and returns the CodeMode.Result. These tests pin the // JS-parity behaviors for the "99% of ordinary defensive JavaScript just works" goal: cases where @@ -301,25 +300,22 @@ describe("H1: NaN/Infinity flow as intermediates and normalize to null at the bo expect(await value(`return JSON.stringify({ x: Number("z") })`)).toBe('{"x":null}') }) - test("copyOut normalizes non-finite numbers to null (the shared return + tool-arg boundary)", () => { - // Tool-call arguments funnel through copyOut too, so this one function pins both boundaries. - expect(Data.toData(NaN, "value")).toBeNull() - expect(Data.toData(Infinity, "value")).toBeNull() - expect(Data.toData(-Infinity, "value", "result")).toBeNull() - expect(Data.toData(42, "value")).toBe(42) - expect(Data.toData({ a: NaN, b: [Infinity, 1] }, "value")).toEqual({ a: null, b: [null, 1] }) + test("the boundary normalizes non-finite numbers to null like JSON.stringify", async () => { + expect(await value(`return { a: Number("z"), b: [Infinity, -Infinity, 1] }`)).toEqual({ + a: null, + b: [null, null, 1], + }) }) }) -describe("copyOut undefined handling per boundary mode", () => { - test("json mode mirrors JSON.stringify for undefined", () => { - expect(Data.toData({ q: undefined, keep: 1 }, "value")).toStrictEqual({ keep: 1 }) - expect(Data.toData([1, undefined, 2], "value")).toStrictEqual([1, null, 2]) - expect(Data.toData({ nested: { a: undefined, b: [undefined] } }, "value")).toStrictEqual({ +describe("undefined at the boundary", () => { + test("vanishes like JSON.stringify", async () => { + expect(await value(`return { q: undefined, keep: 1, nested: { a: undefined, b: [undefined] } }`)).toStrictEqual({ + keep: 1, nested: { b: [null] }, }) - expect(Data.toData(undefined, "value")).toBeUndefined() - expect(Data.toData({ a: undefined }, "value", "result")).toStrictEqual({ a: null }) + expect(await value(`return [1, undefined, 2]`)).toStrictEqual([1, null, 2]) + expect(await value(`return undefined`)).toBeNull() }) }) @@ -447,11 +443,19 @@ describe("Error values and instanceof", () => { ]) }) - test("errors serialize as { name, message } by brand; neither is enumerable", async () => { - expect(await value(`return new Error("m")`)).toEqual({ name: "Error", message: "m" }) - expect(await value(`return JSON.stringify(new Error("m"))`)).toBe('{"name":"Error","message":"m"}') + test("errors serialize as {} like JSON.stringify; name and message are not enumerable", async () => { + expect(await value(`return new Error("m")`)).toEqual({}) + expect(await value(`return JSON.stringify(new Error("m"))`)).toBe("{}") + expect( + await value(`try { throw new Error("m") } catch (e) { return { message: e.message, text: String(e) } }`), + ).toEqual({ + message: "m", + text: "Error: m", + }) expect( - await value(`try { throw new Error("m") } catch (e) { return [Object.keys(e), e.name, e.hasOwnProperty("message")] }`), + await value( + `try { throw new Error("m") } catch (e) { return [Object.keys(e), e.name, e.hasOwnProperty("message")] }`, + ), ).toEqual([[], "Error", true]) expect(await value(`return new Error().hasOwnProperty("message")`)).toBe(false) }) diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index 2735c9bea96d..278dda3e3b34 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -475,20 +475,16 @@ describe("first-class promise values", () => { }) describe("promises at data boundaries", () => { - test("returning an un-awaited promise inside data is a clear await-hinting diagnostic", async () => { - const diagnostic = await error(`return { result: tools.host.echo({ id: 1 }) }`) - expect(diagnostic.kind).toBe("InvalidDataValue") - expect(diagnostic.message).toContain("un-awaited Promise") - expect(diagnostic.message).toContain("await tools.ns.tool(...)") - }) - - test("collection helpers do not let un-awaited promises cross the result boundary", async () => { - const diagnostic = await error(`return Array.from([Promise.resolve(1)])`) - expect(diagnostic.kind).toBe("InvalidDataValue") - expect(diagnostic.message).toContain("un-awaited Promise") + test("an un-awaited promise serializes as {} like JSON.stringify, in results, arguments, and JSON.stringify", async () => { + expect(await value(`return { result: tools.host.echo({ id: 1 }) }`)).toEqual({ result: {} }) + expect(await value(`return Array.from([Promise.resolve(1)])`)).toEqual([{}]) + expect((await error(`return await tools.host.echo({ id: tools.host.echo({ id: 1 }) })`)).kind).toBe( + "InvalidToolInput", + ) + expect(await value(`return JSON.stringify(Promise.resolve(1))`)).toBe("{}") }) - test("invalid returned data cancels pending work", async () => { + test("returning with pending work still running interrupts it", async () => { const trace = makeTrace() const result = await run( ` @@ -497,25 +493,13 @@ describe("promises at data boundaries", () => { `, { trace, limits: { timeoutMs: 100 } }, ) - expect(result.ok).toBe(false) - if (result.ok) return - expect(result.error.kind).toBe("InvalidDataValue") + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toEqual({ pending: {} }) expect(trace.completed).toBe(0) expect(trace.interrupted).toBe(1) }) - test("passing an un-awaited promise as a tool argument is a clear diagnostic", async () => { - const diagnostic = await error(`return await tools.host.echo({ id: tools.host.echo({ id: 1 }) })`) - expect(diagnostic.kind).toBe("InvalidDataValue") - expect(diagnostic.message).toContain("un-awaited Promise") - }) - - test("JSON.stringify of a promise is a diagnostic, not '{}'", async () => { - const diagnostic = await error(`return JSON.stringify(Promise.resolve(1))`) - expect(diagnostic.kind).toBe("InvalidDataValue") - expect(diagnostic.message).toContain("un-awaited Promise") - }) - test("operators reject promise operands", async () => { const diagnostic = await error(`return Promise.resolve(1) + 1`) expect(diagnostic.kind).toBe("InvalidDataValue") @@ -753,18 +737,19 @@ describe("Promise.allSettled", () => { test("reports fulfilled and rejected outcomes with catch-normalized reasons", async () => { expect( await value(` - return await Promise.allSettled([ + const settled = await Promise.allSettled([ tools.host.echo({ id: 5 }), tools.host.fail({}), "plain", Promise.reject(new Error("boom")), ]) + return settled.map((s) => s.status === "rejected" ? { status: s.status, reason: String(s.reason) } : s) `), ).toEqual([ { status: "fulfilled", value: 5 }, - { status: "rejected", reason: { name: "Error", message: "Lookup refused" } }, + { status: "rejected", reason: "Error: Lookup refused" }, { status: "fulfilled", value: "plain" }, - { status: "rejected", reason: { name: "Error", message: "boom" } }, + { status: "rejected", reason: "Error: boom" }, ]) }) @@ -1303,12 +1288,13 @@ describe("promise construction", () => { expect(result.warnings?.[0].message).toContain("dropped") }) - test("resolver functions cannot cross the data boundary", async () => { - const diagnostic = await error(` + test("resolver functions vanish at the data boundary like JSON.stringify", async () => { + expect( + await value(` let escaped new Promise((resolve) => { escaped = resolve }) - return { escaped } - `) - expect(diagnostic.kind).toBe("InvalidDataValue") + return { escaped, kind: typeof escaped } + `), + ).toEqual({ kind: "function" }) }) }) diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index 8a002d2dc3ed..23c7b8f73f94 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -394,8 +394,8 @@ describe("RegExp", () => { }) }) - test("regexes cross the boundary as their literal form; JSON.stringify keeps {} like JS", async () => { - expect(await value(`return [/a/, { r: /b/gi }]`)).toEqual(["/a/", { r: "/b/gi" }]) + test("regexes serialize as {} like JSON.stringify, at the boundary and inside the program", async () => { + expect(await value(`return [/a/, { r: /b/gi }]`)).toEqual([{}, { r: {} }]) expect(await value(`return JSON.stringify({ r: /a/g })`)).toBe('{"r":{}}') }) @@ -521,7 +521,7 @@ describe("URL and URI helpers", () => { cannotParse: false, parsed: "https://example.test/users", invalidIsTypeError: true, - boundary: ["https://example.test/a", "q=one"], + boundary: ["https://example.test/a", {}], json: '{"url":"https://example.test/a","params":{}}', }) }) @@ -715,8 +715,9 @@ describe("Set", () => { ).toBe(6) }) - test("sets cross the boundary as arrays; JSON.stringify keeps {} like JS", async () => { - expect(await value(`return { s: new Set([1, "a", { n: 1 }, undefined]) }`)).toEqual({ s: [1, "a", { n: 1 }, null] }) + test("sets serialize as {} like JSON.stringify; spread to cross as an array", async () => { + expect(await value(`return { s: new Set([1, "a"]) }`)).toEqual({ s: {} }) + expect(await value(`return [...new Set([1, "a", { n: 1 }])]`)).toEqual([1, "a", { n: 1 }]) expect(await value(`return JSON.stringify(new Set([1]))`)).toBe("{}") }) }) @@ -837,11 +838,9 @@ describe("Uint8Array", () => { ]) }) - test("cannot cross the tool boundary; the error says how to encode it", async () => { - expect((await error(`return new Uint8Array(1)`)).message).toContain( - "Execution result contains a Uint8Array; pass text instead", - ) - expect((await error(`return { deep: [new Uint8Array(1)] }`)).message).toContain("bytes.toBase64()") + test("serializes by index like JSON.stringify; encode to cross as text", async () => { + expect(await value(`return new Uint8Array([7, 8])`)).toEqual({ "0": 7, "1": 8 }) + expect(await value(`return new Uint8Array([7, 8]).toBase64()`)).toBe("Bwg=") }) }) diff --git a/packages/codemode/test/tool-paths.test.ts b/packages/codemode/test/tool-paths.test.ts index db9cab3a5899..c7cb03ed33d9 100644 --- a/packages/codemode/test/tool-paths.test.ts +++ b/packages/codemode/test/tool-paths.test.ts @@ -313,8 +313,8 @@ describe("tool argument prototype safety", () => { }) }) -describe("tool arguments cross in a useful form where JSON.stringify would give {}", () => { - test("Set, RegExp, and URLSearchParams; Map stays {} like JSON", async () => { +describe("tool arguments cross exactly as JSON.stringify would serialize them", () => { + test("Set, RegExp, URLSearchParams, and Map all become {}", async () => { let seen: unknown const runtime = CodeMode.make({ tools: { @@ -334,6 +334,6 @@ describe("tool arguments cross in a useful form where JSON.stringify would give runtime, `return await tools.inspect({ v: { s: new Set([1, 2]), r: /x/g, p: new URLSearchParams("a=1&b=2"), m: new Map([["k", 1]]) } })`, ) - expect(seen).toEqual({ s: [1, 2], r: "/x/g", p: "a=1&b=2", m: {} }) + expect(seen).toEqual({ s: {}, r: {}, p: {}, m: {} }) }) }) From 45f806be62bf45a4a953bca97a91c05e53d08787 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 01:13:52 -0500 Subject: [PATCH 2/2] refactor(codemode): keep the boundary table where a program's intent is clear --- packages/codemode/interpreter-support.md | 21 +++-- packages/codemode/src/data.ts | 86 ++++++++++++++----- packages/codemode/src/interpreter/execute.ts | 4 +- .../codemode/src/interpreter/interpreter.ts | 4 +- packages/codemode/test/openapi.test.ts | 4 +- packages/codemode/test/parity.test.ts | 12 +-- packages/codemode/test/promise.test.ts | 37 +++++--- packages/codemode/test/stdlib.test.ts | 12 +-- packages/codemode/test/tool-paths.test.ts | 6 +- 9 files changed, 117 insertions(+), 69 deletions(-) diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index d9c131607a2a..4482f535f969 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -19,13 +19,16 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 TypeScript is transpiled first; the emitted JavaScript must still use the supported subset. - [x] Top-level `await` and `return` through the program's implicit async-function scope. - [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced. -- [x] The host boundary is `JSON.stringify`. The program result and tool arguments cross as exactly what - `JSON.stringify` would serialize: `toJSON` is honored, functions and `undefined` properties vanish, - `undefined` array elements and non-finite numbers become `null`, and Map, Set, RegExp, URLSearchParams, - promises, generators, errors, and extension handles serialize as `{}`. A cyclic value throws the same - `TypeError`. A bare `undefined` result is `null`. Tool results come back the way `JSON.parse(JSON.stringify(result))` - would. The one difference from `JSON.stringify`: own `__proto__` keys are dropped when crossing to the host, - so merging tool inputs or results cannot replace a prototype; in-program `JSON.stringify` still emits the key. +- [x] The host boundary is `JSON.stringify` plus a short table. The program result and tool arguments cross as + what `JSON.stringify` would serialize: `toJSON` is honored, functions and `undefined` properties vanish, + `undefined` array elements and non-finite numbers become `null`, a cyclic value throws the same `TypeError`, + and Map, RegExp, generators, and extension handles serialize as `{}`. A bare `undefined` result is `null`. + Tool results come back the way `JSON.parse(JSON.stringify(result))` would. The table, where a value cannot + be JSON but what the program meant is clear: a promise is awaited (a rejection fails the program), a Set + crosses as an array, a URLSearchParams as its query string, an Error as `{ name, message, ...own }`, a + Uint8Array is rejected with a hint to encode as text, and own `__proto__` keys are dropped so merging tool + inputs or results cannot replace a prototype. In-program `JSON.stringify` keeps JS behavior except for the + Error form and a promise, which is a `TypeError` with an await hint rather than a silent `{}`. - [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, and Uint8Array values inside CodeMode. - [x] Tool calls through the host-provided `tools` tree only. - [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is @@ -423,8 +426,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 ## Uint8Array -The only binary type. Bytes stay inside the program or cross to extensions as copies; at the tool boundary they -serialize by index like `JSON.stringify`, so encode as text first (`TextDecoder`, `toBase64`, `toHex`). +The only binary type. Bytes stay inside the program or cross to extensions as copies; the tool boundary rejects them +with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`). - [x] `new Uint8Array(length | array | iterable | Uint8Array)`, `Uint8Array.from`, `Uint8Array.of`, `fromBase64`, and `fromHex`. Lengths are capped like arrays. diff --git a/packages/codemode/src/data.ts b/packages/codemode/src/data.ts index 1514ae81cc09..b28adb6cd2c2 100644 --- a/packages/codemode/src/data.ts +++ b/packages/codemode/src/data.ts @@ -3,60 +3,104 @@ export * as Data from "./data.js" import { Effect, type Schema } from "effect" import type { Interpreter } from "./interpreter/interpreter.js" import { MAX_VALUE_DEPTH } from "./interpreter/limits.js" -import { rangeError, typeError } from "./interpreter/model.js" -import { Arr, Callable, define, get, keys, Obj, record } from "./interpreter/objects.js" +import { invalidData, rangeError, typeError } from "./interpreter/model.js" +import { + Arr, + Bytes, + Callable, + define, + ErrorObj, + get, + keys, + Obj, + PromiseObj, + record, + SetObj, + URLSearchParamsObj, +} from "./interpreter/objects.js" import { typeofValue } from "./interpreter/references.js" export type Json = Schema.Json +type Replacer = (args: Array) => Effect.Effect + /** * What `JSON.stringify` would serialize for a program value, as host JSON: `toJSON` is honored, functions and - * `undefined` vanish, non-finite numbers become null, and everything else is copied. Tool arguments and the - * execution result cross the boundary this way, and it is the walk behind the program's own `JSON.stringify`. + * `undefined` vanish, non-finite numbers become null, and everything else is copied. Two departures from JS + * so a mistake is not a silent `{}`: an Error serializes as `{ name, message, ...own }`, and a promise throws. */ -export const toJson = ( +export const toJson = (ctx: Interpreter, value: unknown, replacer?: Replacer) => + walk(ctx, value, replacer, false) + +/** + * The host boundary: `toJson`, plus what a program most likely meant when a value cannot be JSON. A promise is + * awaited, a Set crosses as an array, a URLSearchParams as its query string, a Uint8Array asks to be encoded as + * text first, and a `__proto__` key is dropped so host code can never receive one. + */ +export const toBoundary = (ctx: Interpreter, value: unknown) => walk(ctx, value, undefined, true) + +const walk = ( ctx: Interpreter, value: unknown, - replacer?: (args: Array) => Effect.Effect, + replacer: Replacer | undefined, + boundary: boolean, ): Effect.Effect => { const stack = new Set() const visit = (holder: Obj, key: string, depth: number): Effect.Effect => Effect.gen(function* () { if (depth > MAX_VALUE_DEPTH) throw rangeError(`Value exceeds the maximum depth of ${MAX_VALUE_DEPTH}.`) const raw = get(holder, key) - const toJSON = raw instanceof Obj ? get(raw, "toJSON") : undefined - const own = toJSON instanceof Callable ? yield* ctx.call(toJSON, raw, [key]) : raw + if (raw instanceof PromiseObj && !boundary) { + throw invalidData( + "JSON.stringify received an un-awaited Promise; await it first - e.g. `const result = await tools.ns.tool(...)`.", + ) + } + const settled = raw instanceof PromiseObj ? yield* ctx.await(raw) : raw + const toJSON = settled instanceof Obj ? get(settled, "toJSON") : undefined + const own = toJSON instanceof Callable ? yield* ctx.call(toJSON, settled, [key]) : settled const value = replacer === undefined ? own : yield* replacer([key, own]) if (value === undefined || typeofValue(value) === "function") return undefined if (typeof value === "number") return Number.isFinite(value) ? value : null if (value === null || typeof value === "string" || typeof value === "boolean") return value if (!(value instanceof Obj)) return {} - if (stack.has(value)) throw typeError("Converting circular structure to JSON.") - stack.add(value) - if (value instanceof Arr) { + if (boundary && value instanceof Bytes) { + throw invalidData( + "A Uint8Array cannot cross to the host; pass text instead, e.g. `new TextDecoder().decode(bytes)` or `bytes.toBase64()`.", + ) + } + if (boundary && value instanceof URLSearchParamsObj) return value.params.toString() + const target = boundary && value instanceof SetObj ? new Arr(ctx.builtins.Array, [...value.set]) : value + if (stack.has(target)) throw typeError("Converting circular structure to JSON.") + stack.add(target) + if (target instanceof Arr) { const items: Array = [] - for (let index = 0; index < value.items.length; index += 1) { - items.push((yield* visit(value, String(index), depth + 1)) ?? null) + for (let index = 0; index < target.items.length; index += 1) { + items.push((yield* visit(target, String(index), depth + 1)) ?? null) } - stack.delete(value) + stack.delete(target) return items } const copied: Record = {} - for (const name of keys(value)) { - const item = yield* visit(value, name, depth + 1) - // Own data property regardless of the key, so "__proto__" never reaches the Object.prototype setter. + // Own data property regardless of the key, so "__proto__" never reaches the Object.prototype setter. + const put = (name: string, item: Json | undefined) => { if (item !== undefined) Object.defineProperty(copied, name, { value: item, enumerable: true, writable: true, configurable: true }) } - stack.delete(value) + // Errors serialize as { name, message, ...own }: both may be inherited, and neither is enumerable in JS. + if (target instanceof ErrorObj) { + put("name", yield* visit(target, "name", depth + 1)) + put("message", yield* visit(target, "message", depth + 1)) + } + for (const name of keys(target)) { + if (boundary && name === "__proto__") continue + put(name, yield* visit(target, name, depth + 1)) + } + stack.delete(target) return copied }) return visit(record(ctx.builtins.Object, { "": value }), "", 0) } -/** The replacer every host boundary applies: a "__proto__" key must never reach host code. */ -export const hostSafe = ([key, value]: Array) => Effect.succeed(key === "__proto__" ? undefined : value) - /** Host JSON as program values: objects and arrays are copied, primitives pass through. */ export const fromJson = (ctx: Interpreter, value: unknown): unknown => { if (value === null || typeof value !== "object") return value diff --git a/packages/codemode/src/interpreter/execute.ts b/packages/codemode/src/interpreter/execute.ts index 1c3039620bcd..974ac463ad5f 100644 --- a/packages/codemode/src/interpreter/execute.ts +++ b/packages/codemode/src/interpreter/execute.ts @@ -4,7 +4,7 @@ import { Cause, Effect, Scope } from "effect" // pass-through on workerd (the compiler is ~11 MiB and can't init there). import { transpile } from "#transpile" import type { DataValue, Diagnostic, ResolvedExecutionLimits, Result } from "../codemode.js" -import { hostSafe, toJson } from "../data.js" +import { toBoundary } from "../data.js" import { ToolRuntime } from "../tool-runtime.js" import { normalizeError } from "./errors.js" import { createBuiltins } from "./intrinsics.js" @@ -43,7 +43,7 @@ export const executeProgram = ( const program = parseProgram(code) const pending = new Pending(scope, builtins.Promise) const ctx = new Interpreter({ tools, pending, builtins, logs, globals }) - const result = (yield* toJson(ctx, yield* ctx.run(program), hostSafe)) ?? null + const result = (yield* toBoundary(ctx, yield* ctx.run(program))) ?? null returned = { value: result, pending } const warnings = yield* pending.interrupt() return { diff --git a/packages/codemode/src/interpreter/interpreter.ts b/packages/codemode/src/interpreter/interpreter.ts index aac5c0600af6..d7d81be0141f 100644 --- a/packages/codemode/src/interpreter/interpreter.ts +++ b/packages/codemode/src/interpreter/interpreter.ts @@ -42,7 +42,7 @@ import type { YieldExpression, } from "acorn" import { Cause, Deferred, Effect, Exit } from "effect" -import { fromJson, hostSafe, type Json, toJson } from "../data.js" +import { fromJson, type Json, toBoundary } from "../data.js" import { ToolReference, type ToolRuntime } from "../tool-runtime.js" import { type AstNode, @@ -299,7 +299,7 @@ export class Interpreter { ): Effect.Effect { const ctx = this return Effect.gen(function* () { - const json = yield* Effect.forEach(args, (arg) => toJson(ctx, arg, hostSafe)) + const json = yield* Effect.forEach(args, (arg) => toBoundary(ctx, arg)) return fromJson(ctx, yield* run(json)) }) } diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index e092747f2749..17a4236045f0 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -1050,9 +1050,7 @@ describe("OpenAPI.fromSpec", () => { const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "location.get") if (!Tool.isTool(location)) throw new Error("location.get was not generated") - await Effect.runPromise( - location.execute({ location: { directory: "/tmp" } }).pipe(Effect.provide(client.layer)), - ) + await Effect.runPromise(location.execute({ location: { directory: "/tmp" } }).pipe(Effect.provide(client.layer))) const url = new URL(client.requests[0]!.url) expect(url.searchParams.get("location[directory]")).toBe("/tmp") diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index 1fa4712ca7fa..f7a076caee75 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -443,15 +443,9 @@ describe("Error values and instanceof", () => { ]) }) - test("errors serialize as {} like JSON.stringify; name and message are not enumerable", async () => { - expect(await value(`return new Error("m")`)).toEqual({}) - expect(await value(`return JSON.stringify(new Error("m"))`)).toBe("{}") - expect( - await value(`try { throw new Error("m") } catch (e) { return { message: e.message, text: String(e) } }`), - ).toEqual({ - message: "m", - text: "Error: m", - }) + test("errors serialize as { name, message } by brand; neither is enumerable", async () => { + expect(await value(`return new Error("m")`)).toEqual({ name: "Error", message: "m" }) + expect(await value(`return JSON.stringify(new Error("m"))`)).toBe('{"name":"Error","message":"m"}') expect( await value( `try { throw new Error("m") } catch (e) { return [Object.keys(e), e.name, e.hasOwnProperty("message")] }`, diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index 278dda3e3b34..74852d751ef5 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -475,16 +475,25 @@ describe("first-class promise values", () => { }) describe("promises at data boundaries", () => { - test("an un-awaited promise serializes as {} like JSON.stringify, in results, arguments, and JSON.stringify", async () => { - expect(await value(`return { result: tools.host.echo({ id: 1 }) }`)).toEqual({ result: {} }) - expect(await value(`return Array.from([Promise.resolve(1)])`)).toEqual([{}]) - expect((await error(`return await tools.host.echo({ id: tools.host.echo({ id: 1 }) })`)).kind).toBe( - "InvalidToolInput", - ) - expect(await value(`return JSON.stringify(Promise.resolve(1))`)).toBe("{}") + test("an un-awaited promise inside a result or tool argument is awaited", async () => { + expect(await value(`return { result: tools.host.echo({ id: 1 }) }`)).toEqual({ result: 1 }) + expect(await value(`return Array.from([Promise.resolve(1)])`)).toEqual([1]) + expect(await value(`return await tools.host.echo({ id: tools.host.echo({ id: 1 }) })`)).toBe(1) + }) + + test("a rejected promise inside a result fails the program with its reason", async () => { + const diagnostic = await error(`return { result: tools.host.fail({}) }`) + expect(diagnostic.kind).toBe("ToolFailure") + expect(diagnostic.message).toContain("Lookup refused") + }) + + test("JSON.stringify of a promise is a diagnostic, not '{}'", async () => { + const diagnostic = await error(`return JSON.stringify(Promise.resolve(1))`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("un-awaited Promise") }) - test("returning with pending work still running interrupts it", async () => { + test("returning a never-settling promise inside data waits until the timeout", async () => { const trace = makeTrace() const result = await run( ` @@ -493,9 +502,9 @@ describe("promises at data boundaries", () => { `, { trace, limits: { timeoutMs: 100 } }, ) - expect(result.ok).toBe(true) - if (!result.ok) return - expect(result.value).toEqual({ pending: {} }) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.kind).toBe("TimeoutExceeded") expect(trace.completed).toBe(0) expect(trace.interrupted).toBe(1) }) @@ -743,13 +752,13 @@ describe("Promise.allSettled", () => { "plain", Promise.reject(new Error("boom")), ]) - return settled.map((s) => s.status === "rejected" ? { status: s.status, reason: String(s.reason) } : s) + return settled `), ).toEqual([ { status: "fulfilled", value: 5 }, - { status: "rejected", reason: "Error: Lookup refused" }, + { status: "rejected", reason: { name: "Error", message: "Lookup refused" } }, { status: "fulfilled", value: "plain" }, - { status: "rejected", reason: "Error: boom" }, + { status: "rejected", reason: { name: "Error", message: "boom" } }, ]) }) diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index 23c7b8f73f94..8bc291461771 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -521,7 +521,7 @@ describe("URL and URI helpers", () => { cannotParse: false, parsed: "https://example.test/users", invalidIsTypeError: true, - boundary: ["https://example.test/a", {}], + boundary: ["https://example.test/a", "q=one"], json: '{"url":"https://example.test/a","params":{}}', }) }) @@ -715,9 +715,8 @@ describe("Set", () => { ).toBe(6) }) - test("sets serialize as {} like JSON.stringify; spread to cross as an array", async () => { - expect(await value(`return { s: new Set([1, "a"]) }`)).toEqual({ s: {} }) - expect(await value(`return [...new Set([1, "a", { n: 1 }])]`)).toEqual([1, "a", { n: 1 }]) + test("sets cross the boundary as arrays; JSON.stringify keeps {} like JS", async () => { + expect(await value(`return { s: new Set([1, "a", { n: 1 }, undefined]) }`)).toEqual({ s: [1, "a", { n: 1 }, null] }) expect(await value(`return JSON.stringify(new Set([1]))`)).toBe("{}") }) }) @@ -838,8 +837,9 @@ describe("Uint8Array", () => { ]) }) - test("serializes by index like JSON.stringify; encode to cross as text", async () => { - expect(await value(`return new Uint8Array([7, 8])`)).toEqual({ "0": 7, "1": 8 }) + test("cannot cross the tool boundary; the error says how to encode it", async () => { + expect((await error(`return new Uint8Array(1)`)).message).toContain("pass text instead") + expect((await error(`return { deep: [new Uint8Array(1)] }`)).message).toContain("bytes.toBase64()") expect(await value(`return new Uint8Array([7, 8]).toBase64()`)).toBe("Bwg=") }) }) diff --git a/packages/codemode/test/tool-paths.test.ts b/packages/codemode/test/tool-paths.test.ts index c7cb03ed33d9..3e384f4e0389 100644 --- a/packages/codemode/test/tool-paths.test.ts +++ b/packages/codemode/test/tool-paths.test.ts @@ -313,8 +313,8 @@ describe("tool argument prototype safety", () => { }) }) -describe("tool arguments cross exactly as JSON.stringify would serialize them", () => { - test("Set, RegExp, URLSearchParams, and Map all become {}", async () => { +describe("tool arguments cross in a useful form where JSON.stringify would give {}", () => { + test("Set and URLSearchParams; RegExp and Map stay {} like JSON", async () => { let seen: unknown const runtime = CodeMode.make({ tools: { @@ -334,6 +334,6 @@ describe("tool arguments cross exactly as JSON.stringify would serialize them", runtime, `return await tools.inspect({ v: { s: new Set([1, 2]), r: /x/g, p: new URLSearchParams("a=1&b=2"), m: new Map([["k", 1]]) } })`, ) - expect(seen).toEqual({ s: {}, r: {}, p: {}, m: {} }) + expect(seen).toEqual({ s: [1, 2], r: {}, p: "a=1&b=2", m: {} }) }) })