diff --git a/packages/codemode/README.md b/packages/codemode/README.md index e8447e09b2..22af0b701b 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -15,7 +15,7 @@ runs, no sandbox required. The deliberate differences: - **No ambient authority.** No `fetch`, `process`, filesystem, timers, or host globals - only the allowlisted standard - library and the `tools` tree. + library and supplied `tools`. - **No dynamic code.** No `eval`, `Function`, or module loading. - **Plain-data boundaries.** Tool arguments and program results are JSON-like data. Dates become ISO strings, RegExp, Map, and Set serialize as `{}`, and promises, functions, and runtime references cannot cross the boundary. @@ -33,8 +33,8 @@ generators, and full sparse-array parity) are tracked as unchecked items in the ## Quick Start The package is workspace-private (`"@opencode-ai/codemode": "workspace:*"`). Hosts interact with it through `effect` -and should depend on `effect` themselves. Define tools with Effect Schema, then place them in the object tree exposed -to programs as `tools`: +and should depend on `effect` themselves. Define tools with Effect Schema, then expose them to programs through +`tools`: ```ts import { CodeMode, Tool } from "@opencode-ai/codemode" @@ -94,8 +94,8 @@ Effect-returning and must not fail. ### OpenAPI tools -`OpenAPI.fromSpec` turns an OpenAPI 3.x document into a tool subtree - one tool per operation, namespaced by dotted -`operationId`: +`OpenAPI.fromSpec` turns an OpenAPI 3.x document into namespaced tools - one tool per operation, using dotted +`operationId` segments as namespaces: ```ts const api = OpenAPI.fromSpec({ spec, auth: { resolve } }) diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index c53c7b40ab..1f99c01e74 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -1,7 +1,7 @@ import { Effect, Schema } from "effect" import { executeWithLimits } from "./interpreter/execute.js" -import { type HostTools, type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js" -import type { Definition } from "./tool.js" +import { type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js" +import type { Tools } from "./tools.js" /** A tool call admitted during an execution. */ export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js" @@ -28,10 +28,6 @@ export type DiscoveryOptions = { readonly catalogBudget?: number } -type ToolTree = { - readonly [name: string]: Definition | ToolTree -} - export type ResolvedExecutionLimits = { readonly timeoutMs: number | undefined readonly maxToolCalls: number | undefined @@ -39,24 +35,24 @@ export type ResolvedExecutionLimits = { } /** Options for one CodeMode execution. */ -export type ExecuteOptions = {}> = { +export type ExecuteOptions = {}> = { /** Source for one program in the supported JavaScript subset. */ code: string - /** Explicit tool tree exposed to the program as `tools`. */ - tools?: Tools & ToolTree> + /** Explicit tools exposed to the program as `tools`. */ + tools?: Provided & Tools> /** Per-execution overrides for the default resource limits. */ limits?: ExecutionLimits /** Observes decoded tool input immediately before tool execution. */ - onToolCallStart?: (call: ToolRuntime.ToolCallStarted) => Effect.Effect> + onToolCallStart?: (call: ToolRuntime.ToolCallStarted) => Effect.Effect> /** Observes each admitted tool call as it settles, with outcome and duration. */ - onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect> + onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect> } /** 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 = {}> = Omit, "code"> & { +export type Options = {}> = Omit, "code"> & { /** Progressive-disclosure configuration for the agent-facing tool catalog. */ readonly discovery?: DiscoveryOptions } @@ -117,7 +113,7 @@ export const Result = Schema.Union([Success, Failure]) /** Result of executing a CodeMode program. Program failures are data, not Effect failures. */ export type Result = typeof Result.Type -/** Reusable confined runtime over one explicit tool tree. */ +/** Reusable confined runtime over explicit tools. */ export type Runtime = { readonly catalog: () => ReadonlyArray readonly instructions: () => string @@ -138,24 +134,24 @@ const resolveExecutionLimits = (limits?: ExecutionLimits): ResolvedExecutionLimi }) /** Executes one Effect-native CodeMode program without constructing a reusable runtime. */ -export const execute = >( - options: ExecuteOptions, -): Effect.Effect> => { - const tools = (options.tools ?? {}) as HostTools> +export const execute = >( + options: ExecuteOptions, +): Effect.Effect> => { + const tools = (options.tools ?? {}) as Tools> return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools)) } /** Creates an Effect-native runtime over explicit, schema-described tools. */ -export const make = = {}>( - options: Options = {} as Options, -): Runtime> => { - const tools = (options.tools ?? {}) as HostTools> +export const make = = {}>( + options: Options = {} as Options, +): Runtime> => { + const tools = (options.tools ?? {}) as Tools> const limits = resolveExecutionLimits(options.limits) const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget) return { catalog: () => prepared.catalog, instructions: () => prepared.instructions, - execute: (code) => executeWithLimits({ ...options, code }, limits, prepared.searchIndex), + execute: (code) => executeWithLimits({ ...options, code }, limits, prepared.searchIndex), } } diff --git a/packages/codemode/src/interpreter/execute.ts b/packages/codemode/src/interpreter/execute.ts index 1e81a0bfca..5fcd708752 100644 --- a/packages/codemode/src/interpreter/execute.ts +++ b/packages/codemode/src/interpreter/execute.ts @@ -2,17 +2,18 @@ import { parse } from "acorn" import { Cause, Effect, Scope } from "effect" import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript" import type { DataValue, Diagnostic, ExecuteOptions, ResolvedExecutionLimits, Result } from "../codemode.js" -import { copyIn, copyOut, ToolRuntime, type HostTools, type Services } from "../tool-runtime.js" +import { copyIn, copyOut, ToolRuntime, type Services } from "../tool-runtime.js" +import type { Tools } from "../tools.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 = >( - options: ExecuteOptions, +export const executeWithLimits = >( + options: ExecuteOptions, limits: ResolvedExecutionLimits, searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"], -): Effect.Effect> => { +): Effect.Effect> => { if (options.code.trim().length === 0) { return Effect.succeed({ ok: false, @@ -24,7 +25,7 @@ export const executeWithLimits = >( // Allocate execution state inside suspension so reused Effects never share it. return Effect.suspend(() => { const tools = ToolRuntime.make( - (options.tools ?? {}) as HostTools>, + (options.tools ?? {}) as Tools>, limits.maxToolCalls, searchIndex, { @@ -35,15 +36,21 @@ export const executeWithLimits = >( const logs: Array = [] 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> } | undefined + let returned: { value: DataValue; promises: PromiseRuntime> } | undefined const base = Effect.acquireUseRelease( Scope.make("parallel"), (scope) => Effect.gen(function* () { const program = parseProgram(options.code) - const promises = new PromiseRuntime>(scope) - const interpreter = new Interpreter>(tools.invoke, tools.search, tools.keys, promises, logs) + const promises = new PromiseRuntime>(scope) + const interpreter = new Interpreter>( + tools.invoke, + tools.search, + tools.keys, + promises, + logs, + ) const value = yield* interpreter.run(program) const result = copyOut(copyIn(value, "Execution result"), true) as DataValue returned = { value: result, promises } diff --git a/packages/codemode/src/openapi/types.ts b/packages/codemode/src/openapi/types.ts index 6f3eb86283..252f49d86c 100644 --- a/packages/codemode/src/openapi/types.ts +++ b/packages/codemode/src/openapi/types.ts @@ -61,7 +61,7 @@ export type Skipped = { export type Tools = { [name: string]: Definition | Tools } export type Result = { - /** Tool subtree; the host places it under a key in its `tools` tree. */ + /** Namespaced tools; the host places them under a key in its `tools` object. */ readonly tools: Tools readonly skipped: ReadonlyArray } diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 09cea8b53d..d4ed4ea221 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -9,6 +9,7 @@ import { outputTypeScript, } from "./tool-schema.js" import { isDefinition as isToolDefinition, type Definition } from "./tool.js" +import type { Tools } from "./tools.js" import { CodeModeDate, CodeModeMap, @@ -21,28 +22,20 @@ import { const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4)) -export type HostTool = (...args: Array) => Effect.Effect +export type Services = ServicesOf -export type HostTools = { - [name: string]: HostTool | Definition | HostTools -} - -export type Services = ServicesOf - -type ServicesOf> = Depth["length"] extends 8 +type ServicesOf> = Depth["length"] extends 8 ? never - : Tools extends (...args: Array) => Effect.Effect + : T extends { + readonly _tag: "CodeModeTool" + readonly run: (input: unknown) => Effect.Effect + } ? R - : Tools extends { - readonly _tag: "CodeModeTool" - readonly run: (input: unknown) => Effect.Effect - } - ? R - : Tools extends object - ? string extends keyof Tools - ? ServicesOf - : ServicesOf - : never + : T extends object + ? string extends keyof T + ? ServicesOf + : ServicesOf + : never export type ToolCall = { readonly name: string @@ -125,7 +118,7 @@ export class ToolRuntimeError extends Error { } } -const isDefinition = (value: HostTool | Definition | HostTools): value is Definition => +const isDefinition = (value: Definition | Tools): value is Definition => isToolDefinition(value) const runHost = (effect: Effect.Effect): Effect.Effect => @@ -282,13 +275,13 @@ export const copyOut = (value: unknown, undefinedAsNull = false): unknown => { } const definitions = ( - tools: HostTools, + tools: Tools, path: ReadonlyArray = [], ): Array<{ path: string; definition: Definition }> => Object.entries(tools).flatMap(([name, value]) => { const next = [...path, name] if (isDefinition(value)) return [{ path: next.join("."), definition: value }] - return typeof value === "function" ? [] : definitions(value, next) + return definitions(value, next) }) const describeDefinition = (path: string, definition: Definition): ToolDescription => ({ @@ -297,7 +290,7 @@ const describeDefinition = (path: string, definition: Definition): ToolDes signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`, }) -const visibleDefinitions = (tools: HostTools) => +const visibleDefinitions = (tools: Tools) => definitions(tools).map(({ path, definition }) => ({ path, definition, @@ -415,11 +408,11 @@ const toSearchEntry = (path: string, definition: Definition, description: .toLowerCase(), }) -export const searchIndex = (tools: HostTools): ReadonlyArray => +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: HostTools, catalogBudget = defaultCatalogBudget): DiscoveryPlan => { +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") } @@ -562,43 +555,33 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu } } -const namespaceKeys = (tools: HostTools, path: ReadonlyArray): ReadonlyArray => { - let value: HostTool | Definition | HostTools = tools +const namespaceKeys = (tools: Tools, path: ReadonlyArray): ReadonlyArray => { + let value: Definition | Tools = tools for (const segment of path) { - if ( - isBlockedMember(segment) || - typeof value === "function" || - isDefinition(value) || - !Object.hasOwn(value, segment) - ) { + if (isBlockedMember(segment) || isDefinition(value) || !Object.hasOwn(value, segment)) { throw new ToolRuntimeError("UnknownTool", `Unknown tool namespace '${path.join(".")}'.`, [ "Object.keys(tools) lists the available namespaces; search({ query }) finds described tools.", ]) } - value = value[segment] as HostTool | Definition | HostTools + value = value[segment] as Definition | Tools } - if (typeof value === "function" || isDefinition(value)) return [] + if (isDefinition(value)) return [] return Object.keys(value) } -const resolve = (tools: HostTools, path: ReadonlyArray): HostTool | Definition => { - let value: HostTool | Definition | HostTools = tools +const resolve = (tools: Tools, path: ReadonlyArray): Definition => { + let value: Definition | Tools = tools for (const segment of path) { - if ( - isBlockedMember(segment) || - typeof value === "function" || - isDefinition(value) || - !Object.hasOwn(value, segment) - ) { + if (isBlockedMember(segment) || isDefinition(value) || !Object.hasOwn(value, segment)) { throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, [ "Use search({ query }) to find available described tools.", ]) } - value = value[segment] as HostTool | Definition | HostTools + value = value[segment] as Definition | Tools } - if (typeof value !== "function" && !isDefinition(value)) { + if (!isDefinition(value)) { throw new ToolRuntimeError("UnknownTool", `Tool '${path.join(".")}' is not callable.`) } @@ -614,7 +597,7 @@ export type ToolRuntime = { } export const make = ( - tools: HostTools, + tools: Tools, maxToolCalls: number | undefined, searchIndex: ReadonlyArray, hooks?: ToolCallHooks, @@ -701,14 +684,7 @@ export const make = ( const name = path.join(".") const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`))) const tool = resolve(tools, path) - if (isDefinition(tool)) return yield* invokeDefinition(name, tool, externalArgs) - const index = yield* recordAndObserve(name, externalArgs) - return yield* observeEnd( - Effect.gen(function* () { - return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name) - }), - { index, name, input: externalArgs }, - ) + return yield* invokeDefinition(name, tool, externalArgs) }), } } diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts index 0535cc9caa..4d07a6398c 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -29,7 +29,7 @@ export type JsonSchema = { /** Either a validating Effect Schema or a render-only JSON Schema document. */ export type SchemaType = Schema.Decoder | JsonSchema -/** Schema-backed tool definition consumed by a CodeMode tool tree. */ +/** Schema-backed tool definition exposed through CodeMode's `tools` object. */ export type Definition = { readonly _tag: "CodeModeTool" readonly description: string diff --git a/packages/codemode/src/tools.ts b/packages/codemode/src/tools.ts new file mode 100644 index 0000000000..04e36dcef2 --- /dev/null +++ b/packages/codemode/src/tools.ts @@ -0,0 +1,5 @@ +import type { Definition } from "./tool.js" + +export type Tools = { + readonly [name: string]: Definition | Tools +} diff --git a/packages/codemode/test/enumeration.test.ts b/packages/codemode/test/enumeration.test.ts index ca71226a57..0cd81e9c7e 100644 --- a/packages/codemode/test/enumeration.test.ts +++ b/packages/codemode/test/enumeration.test.ts @@ -3,7 +3,7 @@ import { Effect, Schema } from "effect" import { CodeMode, Tool } from "../src/index.js" // Key enumeration: Object.keys and for...in share one surface over plain objects, arrays -// (index strings), and tool references (namespace/tool names from the host tool tree), so a +// (index strings), and tool references (namespace/tool names from the supplied tools), so a // model can discover what it may call instead of guessing names from the instructions. The // motivating transcript: `Object.keys(tools)` failed with the generic plain-objects-only // message and `for (const key in tools)` was unsupported syntax, forcing blind guesses. @@ -137,7 +137,7 @@ describe("for...in", () => { ).toBe("only") }) - test("enumerates namespaces and tools from the callable tool tree", async () => { + test("enumerates namespaces and tools from the supplied tools", async () => { expect( await value(` const names = []