mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 18:26:09 +00:00
refactor(codemode): simplify tools types (#36941)
This commit is contained in:
@@ -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 } })
|
||||
|
||||
@@ -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<R = never> = {
|
||||
readonly [name: string]: Definition<R> | ToolTree<R>
|
||||
}
|
||||
|
||||
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<Tools extends Record<string, unknown> = {}> = {
|
||||
export type ExecuteOptions<Provided extends Record<string, unknown> = {}> = {
|
||||
/** Source for one program in the supported JavaScript subset. */
|
||||
code: string
|
||||
/** Explicit tool tree exposed to the program as `tools`. */
|
||||
tools?: Tools & ToolTree<Services<Tools>>
|
||||
/** Explicit tools exposed to the program as `tools`. */
|
||||
tools?: Provided & Tools<Services<Provided>>
|
||||
/** Per-execution overrides for the default resource limits. */
|
||||
limits?: ExecutionLimits
|
||||
/** Observes decoded tool input immediately before tool execution. */
|
||||
onToolCallStart?: (call: ToolRuntime.ToolCallStarted) => Effect.Effect<void, never, Services<Tools>>
|
||||
onToolCallStart?: (call: ToolRuntime.ToolCallStarted) => Effect.Effect<void, never, Services<Provided>>
|
||||
/** Observes each admitted tool call as it settles, with outcome and duration. */
|
||||
onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect<void, never, Services<Tools>>
|
||||
onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect<void, never, Services<Provided>>
|
||||
}
|
||||
|
||||
/** 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<Tools extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Tools>, "code"> & {
|
||||
export type Options<Provided extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Provided>, "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<R = never> = {
|
||||
readonly catalog: () => ReadonlyArray<ToolDescription>
|
||||
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 = <const Tools extends Record<string, unknown>>(
|
||||
options: ExecuteOptions<Tools>,
|
||||
): Effect.Effect<Result, never, Services<Tools>> => {
|
||||
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
|
||||
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))
|
||||
}
|
||||
|
||||
/** Creates an Effect-native runtime over explicit, schema-described tools. */
|
||||
export const make = <const Tools extends Record<string, unknown> = {}>(
|
||||
options: Options<Tools> = {} as Options<Tools>,
|
||||
): Runtime<Services<Tools>> => {
|
||||
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
|
||||
export const make = <const Provided extends Record<string, unknown> = {}>(
|
||||
options: Options<Provided> = {} as Options<Provided>,
|
||||
): Runtime<Services<Provided>> => {
|
||||
const tools = (options.tools ?? {}) as Tools<Services<Provided>>
|
||||
const limits = resolveExecutionLimits(options.limits)
|
||||
const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget)
|
||||
|
||||
return {
|
||||
catalog: () => prepared.catalog,
|
||||
instructions: () => prepared.instructions,
|
||||
execute: (code) => executeWithLimits<Tools>({ ...options, code }, limits, prepared.searchIndex),
|
||||
execute: (code) => executeWithLimits<Provided>({ ...options, code }, limits, prepared.searchIndex),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = <const Tools extends Record<string, unknown>>(
|
||||
options: ExecuteOptions<Tools>,
|
||||
export const executeWithLimits = <const Provided extends Record<string, unknown>>(
|
||||
options: ExecuteOptions<Provided>,
|
||||
limits: ResolvedExecutionLimits,
|
||||
searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"],
|
||||
): Effect.Effect<Result, never, Services<Tools>> => {
|
||||
): Effect.Effect<Result, never, Services<Provided>> => {
|
||||
if (options.code.trim().length === 0) {
|
||||
return Effect.succeed({
|
||||
ok: false,
|
||||
@@ -24,7 +25,7 @@ export const executeWithLimits = <const Tools 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 HostTools<Services<Tools>>,
|
||||
(options.tools ?? {}) as Tools<Services<Provided>>,
|
||||
limits.maxToolCalls,
|
||||
searchIndex,
|
||||
{
|
||||
@@ -35,15 +36,21 @@ export const executeWithLimits = <const Tools extends Record<string, unknown>>(
|
||||
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<Tools>> } | undefined
|
||||
let returned: { value: DataValue; promises: PromiseRuntime<Services<Provided>> } | undefined
|
||||
|
||||
const base = Effect.acquireUseRelease(
|
||||
Scope.make("parallel"),
|
||||
(scope) =>
|
||||
Effect.gen(function* () {
|
||||
const program = parseProgram(options.code)
|
||||
const promises = new PromiseRuntime<Services<Tools>>(scope)
|
||||
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.search, tools.keys, promises, logs)
|
||||
const promises = new PromiseRuntime<Services<Provided>>(scope)
|
||||
const interpreter = new Interpreter<Services<Provided>>(
|
||||
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 }
|
||||
|
||||
@@ -61,7 +61,7 @@ export type Skipped = {
|
||||
export type Tools = { [name: string]: Definition<HttpClient.HttpClient> | 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<Skipped>
|
||||
}
|
||||
|
||||
@@ -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<R = never> = (...args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
export type Services<T> = ServicesOf<T, []>
|
||||
|
||||
export type HostTools<R = never> = {
|
||||
[name: string]: HostTool<R> | Definition<R> | HostTools<R>
|
||||
}
|
||||
|
||||
export type Services<Tools> = ServicesOf<Tools, []>
|
||||
|
||||
type ServicesOf<Tools, Depth extends ReadonlyArray<unknown>> = Depth["length"] extends 8
|
||||
type ServicesOf<T, Depth extends ReadonlyArray<unknown>> = Depth["length"] extends 8
|
||||
? never
|
||||
: Tools extends (...args: Array<unknown>) => Effect.Effect<unknown, unknown, infer R>
|
||||
: T extends {
|
||||
readonly _tag: "CodeModeTool"
|
||||
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
|
||||
}
|
||||
? R
|
||||
: Tools extends {
|
||||
readonly _tag: "CodeModeTool"
|
||||
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
|
||||
}
|
||||
? R
|
||||
: Tools extends object
|
||||
? string extends keyof Tools
|
||||
? ServicesOf<Tools[string], [...Depth, unknown]>
|
||||
: ServicesOf<Tools[keyof Tools], [...Depth, unknown]>
|
||||
: never
|
||||
: T extends object
|
||||
? string extends keyof T
|
||||
? ServicesOf<T[string], [...Depth, unknown]>
|
||||
: ServicesOf<T[keyof T], [...Depth, unknown]>
|
||||
: never
|
||||
|
||||
export type ToolCall = {
|
||||
readonly name: string
|
||||
@@ -125,7 +118,7 @@ export class ToolRuntimeError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const isDefinition = <R>(value: HostTool<R> | Definition<R> | HostTools<R>): value is Definition<R> =>
|
||||
const isDefinition = <R>(value: Definition<R> | Tools<R>): value is Definition<R> =>
|
||||
isToolDefinition<R>(value)
|
||||
|
||||
const runHost = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, ToolError, R> =>
|
||||
@@ -282,13 +275,13 @@ export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
|
||||
}
|
||||
|
||||
const definitions = <R>(
|
||||
tools: HostTools<R>,
|
||||
tools: Tools<R>,
|
||||
path: ReadonlyArray<string> = [],
|
||||
): Array<{ path: string; definition: Definition<R> }> =>
|
||||
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 = <R>(path: string, definition: Definition<R>): ToolDescription => ({
|
||||
@@ -297,7 +290,7 @@ const describeDefinition = <R>(path: string, definition: Definition<R>): ToolDes
|
||||
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`,
|
||||
})
|
||||
|
||||
const visibleDefinitions = <R>(tools: HostTools<R>) =>
|
||||
const visibleDefinitions = <R>(tools: Tools<R>) =>
|
||||
definitions(tools).map(({ path, definition }) => ({
|
||||
path,
|
||||
definition,
|
||||
@@ -415,11 +408,11 @@ const toSearchEntry = <R>(path: string, definition: Definition<R>, description:
|
||||
.toLowerCase(),
|
||||
})
|
||||
|
||||
export const searchIndex = <R>(tools: HostTools<R>): ReadonlyArray<SearchEntry> =>
|
||||
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> =>
|
||||
visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description))
|
||||
|
||||
// Budget signatures round-robin so every namespace remains visible.
|
||||
export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
|
||||
export const prepare = <R>(tools: Tools<R>, 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 = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
}
|
||||
}
|
||||
|
||||
const namespaceKeys = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): ReadonlyArray<string> => {
|
||||
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
|
||||
const namespaceKeys = <R>(tools: Tools<R>, path: ReadonlyArray<string>): ReadonlyArray<string> => {
|
||||
let value: Definition<R> | Tools<R> = 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<R> | Definition<R> | HostTools<R>
|
||||
value = value[segment] as Definition<R> | Tools<R>
|
||||
}
|
||||
if (typeof value === "function" || isDefinition(value)) return []
|
||||
if (isDefinition(value)) return []
|
||||
return Object.keys(value)
|
||||
}
|
||||
|
||||
const resolve = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): HostTool<R> | Definition<R> => {
|
||||
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
|
||||
const resolve = <R>(tools: Tools<R>, path: ReadonlyArray<string>): Definition<R> => {
|
||||
let value: Definition<R> | Tools<R> = 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<R> | Definition<R> | HostTools<R>
|
||||
value = value[segment] as Definition<R> | Tools<R>
|
||||
}
|
||||
|
||||
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<R = never> = {
|
||||
}
|
||||
|
||||
export const make = <R>(
|
||||
tools: HostTools<R>,
|
||||
tools: Tools<R>,
|
||||
maxToolCalls: number | undefined,
|
||||
searchIndex: ReadonlyArray<SearchEntry>,
|
||||
hooks?: ToolCallHooks<R>,
|
||||
@@ -701,14 +684,7 @@ export const make = <R>(
|
||||
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)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export type JsonSchema = {
|
||||
/** Either a validating Effect Schema or a render-only JSON Schema document. */
|
||||
export type SchemaType = Schema.Decoder<unknown> | JsonSchema
|
||||
|
||||
/** Schema-backed tool definition consumed by a CodeMode tool tree. */
|
||||
/** Schema-backed tool definition exposed through CodeMode's `tools` object. */
|
||||
export type Definition<R = never> = {
|
||||
readonly _tag: "CodeModeTool"
|
||||
readonly description: string
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Definition } from "./tool.js"
|
||||
|
||||
export type Tools<R = never> = {
|
||||
readonly [name: string]: Definition<R> | Tools<R>
|
||||
}
|
||||
@@ -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 = []
|
||||
|
||||
Reference in New Issue
Block a user