import { Cause, Effect, Schema } from "effect" import { ToolError, toolError } from "./tool-error.js" import { decodeInput as decodeToolInput, decodeOutput as decodeToolOutput, identifierSegment, inputProperties, inputTypeScript, outputTypeScript, } from "./tool-schema.js" import { isDefinition as isToolDefinition, type Definition } from "./tool.js" import type { Tools } from "./tools.js" import { CodeModeDate, CodeModeMap, CodeModePromise, CodeModeRegExp, CodeModeSet, CodeModeURL, CodeModeURLSearchParams, } from "./values.js" const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4)) export type Services = ServicesOf type ServicesOf> = Depth["length"] extends 8 ? never : T extends { readonly _tag: "CodeModeTool" readonly run: (input: unknown) => Effect.Effect } ? R : T extends object ? string extends keyof T ? ServicesOf : ServicesOf : never export type ToolCall = { readonly name: string } export type ToolCallStarted = { readonly index: number readonly name: string readonly input: unknown } export type ToolCallEnded = { readonly index: number readonly name: string readonly input: unknown readonly durationMs: number readonly outcome: "success" | "failure" readonly message?: string } export type ToolCallHooks = { readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect) | undefined readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect) | undefined } export type ToolDescription = { readonly path: string readonly description: string readonly signature: string } export type SafeObject = Record const defaultCatalogBudget = 2_000 const defaultSearchLimit = 10 const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) const SearchInput = Schema.Struct({ query: Schema.optionalKey(Schema.String), namespace: Schema.optionalKey(Schema.String), limit: Schema.optionalKey(PositiveInt), offset: Schema.optionalKey(NonNegativeInt), }) const SearchItem = Schema.Struct({ path: Schema.String, description: Schema.String, signature: Schema.String, }) const SearchOutput = Schema.Struct({ items: Schema.Array(SearchItem), remaining: NonNegativeInt, next: Schema.NullOr(Schema.Struct({ offset: NonNegativeInt })), }) const toolExpression = (path: string) => "tools" + path .split(".") .map((segment) => (identifierSegment.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`)) .join("") export class ToolReference { constructor(readonly path: ReadonlyArray) {} } const MAX_VALUE_DEPTH = 32 export class ToolRuntimeError extends Error { constructor( readonly kind: | "UnknownTool" | "InvalidToolInput" | "InvalidToolOutput" | "InvalidDataValue" | "ToolCallLimitExceeded", message: string, readonly suggestions: ReadonlyArray = [], ) { super(message) this.name = "ToolRuntimeError" } } const isDefinition = (value: Definition | Tools): value is Definition => isToolDefinition(value) const runHost = (effect: Effect.Effect): Effect.Effect => effect.pipe( Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt const error = Cause.squash(cause) return Effect.fail(error instanceof ToolError ? error : toolError("Tool execution failed", error)) }), ) const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"]) export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name) // Checkpoint mode preserves CodeMode values; boundary mode JSON-normalizes them. export const copyIn = (value: unknown, label: string, preserveCodeModeValues = false): unknown => copyBounded(value, label, 0, new Set(), preserveCodeModeValues) const copyBounded = ( value: unknown, label: string, depth: number, seen: Set, preserveCodeModeValues: boolean, ): unknown => { if (depth > MAX_VALUE_DEPTH) { throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`) } if ( value === null || value === undefined || typeof value === "string" || typeof value === "boolean" || typeof value === "number" ) { return value } if (typeof value !== "object") { throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`) } if (value instanceof CodeModePromise) { 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 (preserveCodeModeValues) { if ( value instanceof CodeModeDate || value instanceof CodeModeRegExp || value instanceof CodeModeMap || value instanceof CodeModeSet || value instanceof CodeModeURL || value instanceof CodeModeURLSearchParams ) { return value } if (value instanceof Date) return new CodeModeDate(value.getTime()) if (value instanceof RegExp) return new CodeModeRegExp(value.source, value.flags) if (value instanceof Map) { const wrapped = new CodeModeMap() for (const [key, item] of value.entries()) { wrapped.map.set(copyBounded(key, label, depth + 1, seen, true), copyBounded(item, label, depth + 1, seen, true)) } return wrapped } if (value instanceof Set) { const wrapped = new CodeModeSet() for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true)) return wrapped } if (value instanceof URL) return new CodeModeURL(new URL(value.href)) if (value instanceof URLSearchParams) return new CodeModeURLSearchParams(new URLSearchParams(value)) } if (value instanceof CodeModeDate) { 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 CodeModeURL) return value.url.href if (value instanceof URL) return value.href if ( value instanceof CodeModeRegExp || value instanceof CodeModeMap || value instanceof CodeModeSet || value instanceof CodeModeURLSearchParams || value instanceof RegExp || value instanceof Map || value instanceof Set || value instanceof URLSearchParams ) { return 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)) { const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveCodeModeValues)) if (preserveCodeModeValues) { // Checkpoint copies retain array metadata that boundary copies omit. 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, copyBounded(item, label, depth + 1, seen, true)) } } 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 = 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}'.`) } copied[key] = copyBounded(item, label, depth + 1, seen, preserveCodeModeValues) } seen.delete(value) return copied } export const copyOut = (value: unknown, undefinedAsNull = false): unknown => { if (value === undefined && undefinedAsNull) return null if (typeof value === "number" && !Number.isFinite(value)) { return null } if (Array.isArray(value)) { // Array.from densifies holes so sparse arrays normalize at the boundary like JSON does. return Array.from(value, (item) => copyOut(item, undefinedAsNull)) } if (value !== null && typeof value === "object" && !(value instanceof ToolReference)) { return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, copyOut(item, undefinedAsNull)])) } return value } // Dots in tool names are namespace separators; the last definition for a canonical path wins. type ToolNode = { definition?: Definition readonly children: Map> } const toolTrie = (tools: Tools): ToolNode => { const root: ToolNode = { children: new Map() } const insert = (node: ToolNode, group: Tools): void => { for (const [name, value] of Object.entries(group)) { let current = node for (const segment of name.split(".")) { if (segment === "") throw new TypeError(`Tool name '${name}' contains an empty segment.`) const child = current.children.get(segment) ?? { children: new Map() } current.children.set(segment, child) current = child } if (isDefinition(value)) current.definition = value else insert(current, value) } } insert(root, tools) return root } const canonicalSegments = (path: ReadonlyArray): ReadonlyArray => path.flatMap((segment) => segment.split(".")) const definitions = ( node: ToolNode, path: ReadonlyArray = [], ): Array<{ path: string; definition: Definition }> => [ ...(node.definition === undefined ? [] : [{ path: path.join("."), definition: node.definition }]), ...Array.from(node.children, ([name, child]) => definitions(child, [...path, name])).flat(), ] const describeDefinition = (path: string, definition: Definition): ToolDescription => ({ path, description: definition.description, signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`, }) const visibleDefinitions = (tools: Tools) => definitions(toolTrie(tools)).map(({ path, definition }) => ({ path, definition, description: describeDefinition(path, definition), })) export type DiscoveryPlan = { readonly catalog: ReadonlyArray readonly instructions: string readonly searchIndex: ReadonlyArray } export type SearchEntry = { readonly description: ToolDescription readonly namespace: string readonly searchText: string } const tokenize = (query: string): Array => query .replace(/([a-z0-9])([A-Z])/g, "$1 $2") .toLowerCase() .split(/[^a-z0-9]+/) .filter((term) => term.length > 0 && term !== "*") const termForms = (term: string): Array => { const forms = [term] if (term.endsWith("es") && term.length > 3) forms.push(term.slice(0, -2)) if (term.endsWith("s") && term.length > 2) forms.push(term.slice(0, -1)) return forms } const makeSearchTool = (searchIndex: ReadonlyArray): Definition => ({ _tag: "CodeModeTool", description: "Search available Code Mode tools", input: SearchInput, output: SearchOutput, run: (input) => Effect.sync(() => { const request = input as typeof SearchInput.Type const query = request.query ?? "" const offset = request.offset ?? 0 const scoped = request.namespace === undefined ? searchIndex : searchIndex.filter((entry) => entry.namespace === request.namespace) const trimmed = query.trim() const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed const exact = pathQuery === "" ? undefined : scoped.find( (entry) => entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed, ) const terms = tokenize(query).map(termForms) const ranked = exact !== undefined ? [exact] : scoped .map((entry) => { const path = entry.description.path.toLowerCase() const description = entry.description.description.toLowerCase() const score = terms.reduce( (total, forms) => total + (forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) + (forms.some((form) => path.includes(form)) ? 8 : 0) + (forms.some((form) => description.includes(form)) ? 4 : 0) + (forms.some((form) => entry.searchText.includes(form)) ? 2 : 0), 0, ) return { entry, score } }) .filter(({ score }) => terms.length === 0 || score > 0) .sort( (left, right) => right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path), ) .map(({ entry }) => entry) const items = ranked.slice(offset, offset + (request.limit ?? defaultSearchLimit)).map(({ description }) => ({ ...description, path: toolExpression(description.path), })) const remaining = Math.max(0, ranked.length - offset - items.length) return { items, remaining, next: remaining > 0 ? { offset: offset + items.length } : null, } }), }) const searchSignature = (() => { const definition = makeSearchTool([]) return `search(input: ${inputTypeScript(definition, true)}): ${outputTypeScript(definition, true)}` })() const catalogLine = (tool: ToolDescription) => { const line = tool.description.split("\n", 1)[0]!.trim() const description = line.length > 120 ? line.slice(0, 119) + "..." : line return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}` } const toSearchEntry = (path: string, definition: Definition, description: ToolDescription): SearchEntry => ({ description, namespace: path.split(".", 1)[0]!, searchText: [ path, definition.description, ...inputProperties(definition).flatMap(({ name, description: property }) => property === undefined ? [name] : [name, property], ), ] .join("\n") .toLowerCase(), }) export const searchIndex = (tools: Tools): ReadonlyArray => visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description)) // Budget signatures round-robin so every namespace remains visible. export const prepare = (tools: Tools, catalogBudget = defaultCatalogBudget): DiscoveryPlan => { if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) { throw new RangeError("discovery.catalogBudget must be a non-negative safe integer") } const visible = visibleDefinitions(tools) const described = visible.map(({ description }) => description) const namespaces = new Map>() for (const tool of described) { const [namespace = tool.path] = tool.path.split(".") const group = namespaces.get(namespace) ?? [] group.push(tool) namespaces.set(namespace, group) } const ordered = [...namespaces].sort(([left], [right]) => left.localeCompare(right)) const selections = ordered.map(([namespace, group]) => ({ namespace, picked: new Set(), queue: [...group].sort( (left, right) => estimateTokens(catalogLine(left)) - estimateTokens(catalogLine(right)) || left.path.localeCompare(right.path), ), })) let used = 0 let active = selections.filter((selection) => selection.queue.length > 0) while (active.length > 0) { const stillActive: typeof active = [] for (const selection of active) { const tool = selection.queue[0]! const cost = estimateTokens(catalogLine(tool)) if (used + cost > catalogBudget) continue selection.queue.shift() selection.picked.add(tool) used += cost if (selection.queue.length > 0) stillActive.push(selection) } active = stillActive } const shown = new Map>( selections.map(({ namespace, picked }) => [namespace, picked]), ) const totalShown = selections.reduce((total, { picked }) => total + picked.size, 0) const complete = totalShown === described.length const empty = described.length === 0 const intro = [ empty ? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime." : complete ? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed below; surrounding agent tools are not available." : "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed or searchable below; surrounding agent tools are not available.", ...(empty ? [] : ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]), ] const workflow = empty ? [] : [ "", "## Workflow", "", ...(complete ? [ "1. Pick a tool from the list under `## Available tools` - each line is the exact call signature; use it as-is rather than guessing segments.", "2. Call it using the exact signature shown: `const result = await tools..(input)`; bracket notation and quotes are part of the path.", "3. Return only the fields you need from structured results; narrow unknown results before reading fields, and avoid returning large raw payloads.", ] : [ '1. If needed, discover tools with the built-in search function: `return search({ query: "" })`.', "2. In the next execution, copy a returned path exactly, call it, and return only the needed fields.", ]), ] const rules = empty ? [] : [ "", "## Rules", "", complete ? "- Only Code Mode tools listed here are available; surrounding agent tools are not implicitly exposed." : "- Only Code Mode tools listed here or returned by the built-in `search` function are available; surrounding agent tools are not implicitly exposed.", "- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.", "- A result typed `Promise` may be structured data or text. Before reading fields, check that it is a non-null object and not an array; otherwise handle the returned text or primitive directly.", '- Run independent calls in parallel: `await Promise.all(items.map((item) => tools..(item)))`, or use `tools.["tool-name"](item)` when the listed signature uses bracket notation.', "- Execution ends when the program returns; pending promises are interrupted, so await every call whose completion matters.", "- `Object.keys(tools)` lists namespaces; `Object.keys(tools.)` lists its tools; `for...in` works on both.", ...(complete ? [] : [ '- Browse one namespace: `search({ query: "", namespace: "" })`.', "- If search returns `next`, repeat the same search with `offset: next.offset`.", ]), ] const language = [ "", "## Language", "", "Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.", "Modules/imports, classes, generators, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use Code Mode tools for external operations. Use await with try/catch.", "Prefer explicit `return`; otherwise only the final top-level expression becomes the result.", "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", ] const toolSection: Array = [""] if (empty) { toolSection.push("## Available tools", "", "No tools are currently available.") } else { toolSection.push( complete ? "## Available tools (COMPLETE list - every tool is shown below with its full call signature)" : `## Available tools (PARTIAL - ${totalShown} of ${described.length} shown; find the rest with search(...))`, "", ) for (const [namespace, group] of ordered) { const picked = shown.get(namespace)! const count = `${group.length} tool${group.length === 1 ? "" : "s"}` const label = picked.size === group.length ? count : picked.size === 0 ? `${count}, none shown` : `${count}, ${picked.size} shown` toolSection.push(`- ${namespace} (${label})`) for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool)) } if (!complete) { toolSection.push("", "Search returns complete callable signatures:", `- ${searchSignature}`) } } const lines = [...intro, ...workflow, ...rules, ...language, ...toolSection] return { catalog: described, instructions: lines.join("\n"), searchIndex: visible.map(({ path, definition, description }) => toSearchEntry(path, definition, description)), } } const lookup = (root: ToolNode, segments: ReadonlyArray): ToolNode | undefined => segments.reduce | undefined>((node, segment) => node?.children.get(segment), root) const namespaceKeys = (root: ToolNode, path: ReadonlyArray): ReadonlyArray => { const segments = canonicalSegments(path) const node = lookup(root, segments) if (node === undefined) { throw new ToolRuntimeError("UnknownTool", `Unknown tool namespace '${segments.join(".")}'.`) } return Array.from(node.children.keys()) } const resolve = (root: ToolNode, path: ReadonlyArray): Definition => { const segments = canonicalSegments(path) const node = lookup(root, segments) if (node === undefined) { throw new ToolRuntimeError("UnknownTool", `Unknown tool '${segments.join(".")}'.`, [ "Use search({ query }) to find available described tools.", ]) } if (node.definition === undefined) { throw new ToolRuntimeError("UnknownTool", `Tool '${segments.join(".")}' is not callable.`) } return node.definition } export type ToolRuntime = { readonly root: ToolReference readonly calls: Array readonly invoke: (path: ReadonlyArray, args: Array) => Effect.Effect readonly search: (args: Array) => Effect.Effect readonly keys: (path: ReadonlyArray) => ReadonlyArray } export const make = ( tools: Tools, maxToolCalls: number | undefined, searchIndex: ReadonlyArray, hooks?: ToolCallHooks, ): ToolRuntime => { const calls: Array = [] const root = toolTrie(tools) const searchTool = makeSearchTool(searchIndex) // End hooks observe settled success or failure; interruption emits neither outcome. const observeEnd = (effect: Effect.Effect, call: ToolCallStarted): Effect.Effect => { const onEnd = hooks?.onToolCallEnd if (onEnd === undefined) return effect const startedAt = Date.now() return effect.pipe( Effect.tap(() => onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "success" })), Effect.tapError((error) => { const message = error instanceof ToolError || error instanceof ToolRuntimeError ? error.message : "Tool execution failed" return onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "failure", message, }) }), ) } const decodeOutput = (value: unknown, name: string) => Effect.try({ try: () => copyIn(value, `Result from tool '${name}'`), catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`), }) const recordCall = (call: ToolCall): void => { if (maxToolCalls !== undefined && calls.length >= maxToolCalls) { throw new ToolRuntimeError("ToolCallLimitExceeded", `Execution exceeded its tool-call limit of ${maxToolCalls}.`) } calls.push(call) } const recordAndObserve = (name: string, input: unknown) => Effect.sync(() => { recordCall({ name }) return calls.length - 1 }).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void)) const invokeDefinition = (name: string, tool: Definition, externalArgs: Array) => Effect.gen(function* () { if (externalArgs.length !== 1) throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`) const input = yield* Effect.try({ try: () => decodeToolInput(tool, externalArgs[0]), catch: (cause) => new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`), }) const index = yield* recordAndObserve(name, input) return yield* observeEnd( Effect.gen(function* () { const raw = yield* runHost(Effect.suspend(() => tool.run(input))) const result = yield* Effect.try({ try: () => decodeToolOutput(tool, raw), catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`), }) return yield* decodeOutput(result, name) }), { index, name, input }, ) }) return { root: new ToolReference([]), calls, keys: (path) => namespaceKeys(root, path), search: (args) => Effect.suspend(() => invokeDefinition( "search", searchTool, args.map((arg) => copyOut(copyIn(arg, "Arguments for tool 'search'"))), ), ), invoke: (path, args) => Effect.gen(function* () { const name = canonicalSegments(path).join(".") const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`))) const tool = resolve(root, path) return yield* invokeDefinition(name, tool, externalArgs) }), } } export * as ToolRuntime from "./tool-runtime.js"