Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 14 additions & 22 deletions packages/codemode/src/codemode.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Effect, Schema } from "effect"
import { executeWithLimits } from "./interpreter/execute.js"
import { executeProgram } from "./interpreter/execute.js"
import { type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js"
import type { Tools } from "./tools.js"

Expand Down Expand Up @@ -30,26 +30,23 @@ export type ResolvedExecutionLimits = {
readonly maxOutputBytes: number | undefined
}

/** Options for one CodeMode execution. */
export type ExecuteOptions<Provided extends Record<string, unknown> = {}> = {
/** Source for one program in the supported JavaScript subset. */
code: string
/** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */
export type Options<Provided extends Record<string, unknown> = {}> = ToolRuntime.ToolCallHooks<Services<Provided>> & {
/** Explicit tools exposed to the program as `tools`. */
tools?: Provided & Tools<Services<Provided>>
/** Per-execution overrides for the default resource limits. */
/** Resource limits enforced on each execution. */
limits?: ExecutionLimits
/** Observes decoded tool input immediately before tool execution. */
onToolCallStart?: (call: ToolRuntime.ToolCallStarted) => Effect.Effect<void, never, Services<Provided>>
/** Observes each admitted tool call as it succeeds, fails, or is interrupted. */
onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect<void, never, Services<Provided>>
}

/** Options for one CodeMode execution. */
export type ExecuteOptions<Provided extends Record<string, unknown> = {}> = Options<Provided> & {
/** Source for one program in the supported JavaScript subset. */
code: string
}

/** A JSON value that can cross the confined interpreter boundary. */
export type DataValue = Schema.Json

/** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */
export type Options<Provided extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Provided>, "code">

/** Schema for a host tool input containing CodeMode source. */
export const Input = Schema.Struct({ code: Schema.String })
export type Input = typeof Input.Type
Expand Down Expand Up @@ -128,21 +125,16 @@ const resolveExecutionLimits = (limits?: ExecutionLimits): ResolvedExecutionLimi
/** Executes one Effect-native CodeMode program without constructing a reusable runtime. */
export const execute = <const Provided extends Record<string, unknown>>(
options: ExecuteOptions<Provided>,
): Effect.Effect<Result, never, Services<Provided>> => {
const tools = (options.tools ?? {}) as Tools<Services<Provided>>
return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools))
}
): Effect.Effect<Result, never, Services<Provided>> => make(options).execute(options.code)

/** Creates an Effect-native runtime over explicit, schema-described tools. */
export const make = <const Provided extends Record<string, unknown> = {}>(
options: Options<Provided> = {} as Options<Provided>,
options: Options<Provided> = {},
): Runtime<Services<Provided>> => {
const tools = (options.tools ?? {}) as Tools<Services<Provided>>
const prepared = ToolRuntime.prepare((options.tools ?? {}) as Tools<Services<Provided>>)
const limits = resolveExecutionLimits(options.limits)
const prepared = ToolRuntime.prepare(tools)

return {
catalog: () => prepared.catalog,
execute: (code) => executeWithLimits<Provided>({ ...options, code }, limits, prepared.searchIndex),
execute: (code) => executeProgram(code, prepared, limits, options),
}
}
147 changes: 147 additions & 0 deletions packages/codemode/src/data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
export * as Data from "./data.js"

import type { DiagnosticKind } from "./codemode.js"
import { Values } from "./values.js"

/** A null-prototype object owned by the program. */
export type SafeObject = Record<string, unknown>

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<string> = [],
) {
super(message)
this.name = "ToolRuntimeError"
}
}

const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])

export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)

/**
* Brings a host-produced runtime value into the program: runtime values pass through, their host
* counterparts (Date, RegExp, Map, Set, URL, URLSearchParams) are wrapped, and objects become
* null-prototype copies. Arrays keep extra enumerable properties such as `index` and `groups`.
*/
export const toProgram = (value: unknown, label: string): unknown => copy(value, label, "program", 0, new Set())

/**
* Brings host data into the program: Date and URL become strings, other host collections become
* empty objects, and objects become null-prototype copies. Used for tool results and parsed JSON.
*/
export const fromData = (value: unknown, label: string): unknown => copy(value, label, "data", 0, new Set())

/**
* 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.
*/
export const toData = (value: unknown, label: string, undefinedAs: "json" | "result" = "json"): unknown =>
copy(value, label, undefinedAs, 0, new Set())

// "program" and "data" build program-owned null-prototype objects; "json" and "result" build
// ordinary objects for the host.
type Mode = "program" | "data" | "json" | "result"

const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Set<object>): unknown => {
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 Values.Promise) {
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 (mode === "program") {
if (Values.isValue(value)) return value
if (value instanceof Date) return new Values.Date(value.getTime())
if (value instanceof RegExp) return new Values.RegExp(value.source, value.flags)
if (value instanceof Map) {
const wrapped = new Values.Map()
for (const [key, item] of value.entries()) {
wrapped.map.set(copy(key, label, mode, depth + 1, seen), copy(item, label, mode, depth + 1, seen))
}
return wrapped
}
if (value instanceof Set) {
const wrapped = new Values.Set()
for (const item of value.values()) wrapped.set.add(copy(item, label, mode, depth + 1, seen))
return wrapped
}
if (value instanceof URL) return new Values.URL(new URL(value.href))
if (value instanceof URLSearchParams) return new Values.URLSearchParams(new URLSearchParams(value))
}

const plain = mode === "program" || mode === "data"
if (value instanceof Values.Date) 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 Values.URL) return value.url.href
if (value instanceof URL) return value.href
// Remaining runtime values and their host counterparts serialize as empty objects, like JSON.stringify.
if (
Values.isValue(value) ||
value instanceof RegExp ||
value instanceof Map ||
value instanceof Set ||
value instanceof URLSearchParams
) {
return plain ? (Object.create(null) as SafeObject) : {}
}

if (seen.has(value)) {
throw new ToolRuntimeError("InvalidDataValue", `${label} contains a circular value.`)
}
seen.add(value)

if (Array.isArray(value)) {
// Host output densifies holes to null like JSON; program copies keep them.
const copied = plain
? value.map((item) => copy(item, label, mode, depth + 1, seen))
: Array.from(value, (item) => copy(item, label, mode, depth + 1, seen) ?? null)
if (mode === "program") {
for (const [key, item] of Object.entries(value)) {
if (Object.hasOwn(copied, key)) continue
if (isBlockedMember(key)) {
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
}
Reflect.set(copied, key, copy(item, label, mode, depth + 1, seen))
}
}
seen.delete(value)
return copied
}

const prototype = Object.getPrototypeOf(value)
if (prototype !== Object.prototype && prototype !== null) {
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain plain objects only.`)
}

const copied: SafeObject = plain ? (Object.create(null) as SafeObject) : {}
for (const [key, item] of Object.entries(value)) {
if (isBlockedMember(key)) {
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
}
const next = copy(item, label, mode, depth + 1, seen)
if (next === undefined && mode === "json") continue
copied[key] = next
}
seen.delete(value)
return copied
}
4 changes: 2 additions & 2 deletions packages/codemode/src/interpreter/errors.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { Diagnostic } from "../codemode.js"
import { ToolError } from "../tool-error.js"
import { copyOut, ToolRuntimeError, type SafeObject } from "../tool-runtime.js"
import { type SafeObject, toData, ToolRuntimeError } from "../data.js"
import { type AstNode, formatLocation, InterpreterRuntimeError, ProgramThrow, sourceLocation } from "./model.js"
import { containsRuntimeReference } from "./references.js"
import { type SyncIteratorRunner } from "./iterator.js"
Expand Down Expand Up @@ -45,7 +45,7 @@ export const normalizeError = (error: unknown): Diagnostic => {
message = (value as { message: string }).message
} else {
try {
message = JSON.stringify(copyOut(value, "json")) ?? String(value)
message = JSON.stringify(toData(value, "Thrown value")) ?? String(value)
} catch {
message = String(value)
}
Expand Down
43 changes: 15 additions & 28 deletions packages/codemode/src/interpreter/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,21 @@ import { Cause, Effect, Scope } from "effect"
// #transpile: conditional import — full typescript on node/bun, an identity
// pass-through on workerd (the compiler is ~11 MiB and can't init there).
import { transpile } from "#transpile"
import type { DataValue, Diagnostic, ExecuteOptions, ResolvedExecutionLimits, Result } from "../codemode.js"
import { copyIn, copyOut, ToolRuntime, type Services } from "../tool-runtime.js"
import type { Tools } from "../tools.js"
import type { DataValue, Diagnostic, ResolvedExecutionLimits, Result } from "../codemode.js"
import { toData } from "../data.js"
import { ToolRuntime } from "../tool-runtime.js"
import { normalizeError } from "./errors.js"
import { InterpreterRuntimeError, isRecord, type ProgramNode } from "./model.js"
import { PromiseRuntime } from "./promises.js"
import { Interpreter } from "./runtime.js"

export const executeWithLimits = <const Provided extends Record<string, unknown>>(
options: ExecuteOptions<Provided>,
export const executeProgram = <R>(
code: string,
prepared: ToolRuntime.Prepared<R>,
limits: ResolvedExecutionLimits,
searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"],
): Effect.Effect<Result, never, Services<Provided>> => {
if (options.code.trim().length === 0) {
hooks: ToolRuntime.ToolCallHooks<R>,
): Effect.Effect<Result, never, R> => {
if (code.trim().length === 0) {
return Effect.succeed({
ok: false,
error: { kind: "ParseError", message: "Code cannot be empty." },
Expand All @@ -26,35 +27,21 @@ export const executeWithLimits = <const Provided extends Record<string, unknown>

// Allocate execution state inside suspension so reused Effects never share it.
return Effect.suspend(() => {
const tools = ToolRuntime.make(
(options.tools ?? {}) as Tools<Services<Provided>>,
limits.maxToolCalls,
searchIndex,
{
onToolCallStart: options.onToolCallStart,
onToolCallEnd: options.onToolCallEnd,
},
)
const tools = ToolRuntime.make(prepared, limits.maxToolCalls, hooks)
const logs: Array<string> = []
const logged = () => (logs.length > 0 ? { logs: [...logs] } : {})
// Set only after copy-out so timeouts cannot report invalid values as completed.
let returned: { value: DataValue; promises: PromiseRuntime<Services<Provided>> } | undefined
let returned: { value: DataValue; promises: PromiseRuntime<R> } | undefined

const base = Effect.acquireUseRelease(
Scope.make("parallel"),
(scope) =>
Effect.gen(function* () {
const program = parseProgram(options.code)
const promises = new PromiseRuntime<Services<Provided>>(scope)
const interpreter = new Interpreter<Services<Provided>>(
tools.execute,
tools.search,
tools.keys,
promises,
logs,
)
const program = parseProgram(code)
const promises = new PromiseRuntime<R>(scope)
const interpreter = new Interpreter<R>(tools.execute, tools.search, tools.keys, promises, logs)
const value = yield* interpreter.run(program)
const result = copyOut(copyIn(value, "Execution result"), "nullify") as DataValue
const result = toData(value, "Execution result", "result") as DataValue
returned = { value: result, promises }
const warnings = yield* promises.interrupt()
return {
Expand Down
17 changes: 9 additions & 8 deletions packages/codemode/src/interpreter/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import {
UriFunction,
} from "./model.js"
import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, typeofValue } from "./references.js"
import { compareText, isBlockedMember, type SafeObject } from "../tool-runtime.js"
import { isBlockedMember, type SafeObject, toProgram } from "../data.js"
import { compareText } from "../tool-runtime.js"
import { Values } from "../values.js"
import { dateSetterArgumentCount, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
import { invokeMathMethod } from "../stdlib/math.js"
Expand All @@ -24,7 +25,7 @@ import { invokeObjectMethod } from "../stdlib/object.js"
import { invokeRegExpMethod, invokeRegExpStatic, matchToValue, toHostRegex } from "../stdlib/regexp.js"
import { invokeStringStatic } from "../stdlib/string.js"
import { invokeURLMethod, invokeURLStatic, uriArgument } from "../stdlib/url.js"
import { boundedData, coerceToNumber, coerceToString, errorBrandName } from "../stdlib/value.js"
import { coerceToNumber, coerceToString, errorBrandName } from "../stdlib/value.js"
import { preserveConsumerError, type SyncIteratorRunner } from "./iterator.js"

export type CallbackRunner<R> = {
Expand Down Expand Up @@ -292,7 +293,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
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 boundedData(matched, "String.match result")
if (pattern.global) return toProgram(matched, "String.match result")
return matchToValue(matched)
}
case "matchAll": {
Expand Down Expand Up @@ -347,7 +348,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
default:
throw new InterpreterRuntimeError(`String method '${name}' is not available.`, node)
}
return boundedData(result, `String.${name} result`)
return toProgram(result, `String.${name} result`)
}

export const arrayStatics = new Set(["isArray", "of", "from"])
Expand Down Expand Up @@ -540,19 +541,19 @@ const invokeStringReplacer = <R>(
let end = 0
for (const match of matches) {
const replacement = yield* apply(match.args)
// Error values are branded plain objects; boundedData would strip the brand before coercion.
// Error values are branded plain objects; toProgram would strip the brand before coercion.
output.push(
value.slice(end, match.offset),
replacement instanceof Values.Promise
? "[object Promise]"
: errorBrandName(replacement)
? coerceToString(replacement)
: coerceToString(boundedData(replacement, `String.${name} replacer result`)),
: coerceToString(toProgram(replacement, `String.${name} replacer result`)),
)
end = match.offset + match.match.length
}
output.push(value.slice(end))
return boundedData(output.join(""), `String.${name} result`)
return toProgram(output.join(""), `String.${name} result`)
})
}

Expand Down Expand Up @@ -876,7 +877,7 @@ const invokeArrayMethod = <R>(
if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) {
throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node)
}
const input = boundedData(target, "Array.join input") as Array<unknown>
const input = toProgram(target, "Array.join input") as Array<unknown>
return Effect.succeed(
input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)),
)
Expand Down
2 changes: 1 addition & 1 deletion packages/codemode/src/interpreter/model.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Effect } from "effect"
import type { DiagnosticKind } from "../codemode.js"
import type { SafeObject } from "../tool-runtime.js"
import type { SafeObject } from "../data.js"
import type { Values } from "../values.js"

export type SourcePosition = {
Expand Down
2 changes: 1 addition & 1 deletion packages/codemode/src/interpreter/promises.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect"
import type { Diagnostic } from "../codemode.js"
import type { SafeObject } from "../tool-runtime.js"
import type { SafeObject } from "../data.js"
import {
type AstNode,
CodeModeFunction,
Expand Down
Loading
Loading