Compare commits

...
Author SHA1 Message Date
Aiden Cline 068ab339b6 feat(codemode): add named Tool | Namespace entries
Replace record-keyed tools and the separate namespace-description map with an OpenAI-style array of named tools and namespaces. Namespace descriptions are optional, namespaces may nest, and duplicate or dotted names are rejected.
2026-08-31 13:55:14 -05:00
Aiden Cline 4bf7e45288 feat(codemode): add namespace descriptions 2026-08-31 10:39:28 -05:00
37 changed files with 646 additions and 323 deletions
+23 -8
View File
@@ -26,10 +26,11 @@ Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source locat
## Quick Start
```ts
import { CodeMode, Tool } from "@opencode-ai/codemode"
import { CodeMode, Namespace, Tool } from "@opencode-ai/codemode"
import { Effect, Schema } from "effect"
const lookupOrder = Tool.make({
name: "lookup",
description: "Look up an order by ID",
input: Schema.Struct({ id: Schema.String }),
output: Schema.Struct({ id: Schema.String, status: Schema.String }),
@@ -37,7 +38,13 @@ const lookupOrder = Tool.make({
})
const runtime = CodeMode.make({
tools: { orders: { lookup: lookupOrder } },
tools: [
Namespace.make({
name: "orders",
description: "Purchases, fulfillment, and shipment tracking",
tools: [lookupOrder],
}),
],
})
const result = await Effect.runPromise(
@@ -52,7 +59,7 @@ const result = await Effect.runPromise(
## API
### `Tool.make`
### `Tool.make` and `Namespace.make`
`input` and `output` accept either an Effect Schema or a render-only JSON Schema document. Effect Schema input is
decoded before `execute`; Effect Schema output is decoded and safely copied before the program sees it. JSON Schemas
@@ -60,9 +67,9 @@ only shape the model-visible signature. Without `output`, the signature uses `Pr
Descriptions and schemas are model-visible contracts. Authorization belongs in `execute`.
Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: { list: tool } }` both expose
`tools.issues.list(...)`. Other characters use bracket notation, such as
`tools.context7["resolve-library-id"](...)`.
A `tools` array is a `Tool | Namespace` union. Each entry owns its `name`. Namespaces may nest and may omit
`description`. Duplicate names at the same level and names containing `.` throw `TypeError`. Other characters use
bracket notation, such as `tools.context7["resolve-library-id"](...)`.
### `CodeMode.execute` and `CodeMode.make`
@@ -72,6 +79,7 @@ Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: {
const runtime = CodeMode.make({ tools, limits: { timeoutMs: 30_000 } })
runtime.catalog() // structured tool descriptions
runtime.namespaces() // namespace paths, with descriptions when provided
runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
```
@@ -85,7 +93,9 @@ create namespaces:
```ts
const api = OpenAPI.fromSpec({ spec, auth: { resolve } })
const runtime = CodeMode.make({ tools: { opencode: api.tools } })
const runtime = CodeMode.make({
tools: [Namespace.make({ name: "opencode", tools: api.tools })],
})
```
The synchronous result is `{ tools, skipped }`. Operations with unsupported parameter encodings, request bodies
@@ -148,9 +158,14 @@ copying error. Interruption propagates without becoming an error diagnostic.
every visible tool. Hosts render their own model-facing instructions from these descriptors; `CodeMode.searchSignature`
and `CodeMode.toolExpression(path)` supply the exact callable forms.
`runtime.namespaces()` returns a `ReadonlyArray<CodeMode.NamespaceDescription>` (`{ path, description? }`), sorted
by path. It includes namespaces that have at least one descendant tool. Empty groups and leaf tools are omitted.
The synchronous `search(...)` built-in is always available. It supports exact-path lookup, namespace-scoped search,
empty-query browsing, and pagination, and returns callable paths with full signatures. Search counts toward
`maxToolCalls`.
`maxToolCalls`. Query matching includes ancestor namespace descriptions alongside tool paths, descriptions, and
input properties. Searching a collection description returns its descendant tools, with their original descriptions
and signatures; namespace descriptions are not separate search results.
## Execution Limits
+5 -2
View File
@@ -187,9 +187,12 @@ ultimate source of truth.
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
and a JavaScript `this` receiver remain outside the supported object/function model.
- [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] Tools and namespaces are named array entries. Namespaces may nest; a name at one level is either a tool or a
namespace, not both. Duplicate names and names containing `.` throw `TypeError`.
- [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys.
- [x] Namespace descriptions are optional and contribute to search matching for descendant tools, including nested
ancestors. Search results keep the original tool descriptions and signatures. Host-side `runtime.namespaces()`
lists namespaces with descendant tools, sorted by path.
- [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)`
+12 -8
View File
@@ -1,10 +1,12 @@
import { Effect, Schema } from "effect"
import { executeWithLimits } from "./interpreter/execute.js"
import { type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js"
import { type NamespaceDescription, 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"
/** Discovered namespace paths, including optional descriptions. */
export type { NamespaceDescription } from "./tool-runtime.js"
/** Signature-construction helpers for host-owned catalog instructions. */
export { searchSignature, toolExpression } from "./tool-runtime.js"
@@ -31,10 +33,10 @@ export type ResolvedExecutionLimits = {
}
/** Options for one CodeMode execution. */
export type ExecuteOptions<Provided extends Record<string, unknown> = {}> = {
export type ExecuteOptions<Provided extends ReadonlyArray<unknown> = readonly []> = {
/** Source for one program in the supported JavaScript subset. */
code: string
/** Explicit tools exposed to the program as `tools`. */
/** Explicit tools and namespaces exposed to the program as `tools`. */
tools?: Provided & Tools<Services<Provided>>
/** Per-execution overrides for the default resource limits. */
limits?: ExecutionLimits
@@ -48,7 +50,7 @@ export type ExecuteOptions<Provided extends Record<string, unknown> = {}> = {
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">
export type Options<Provided extends ReadonlyArray<unknown> = readonly []> = Omit<ExecuteOptions<Provided>, "code">
/** Schema for a host tool input containing CodeMode source. */
export const Input = Schema.Struct({ code: Schema.String })
@@ -109,6 +111,7 @@ export type Result = typeof Result.Type
/** Reusable confined runtime over explicit tools. */
export type Runtime<R = never> = {
readonly catalog: () => ReadonlyArray<ToolDescription>
readonly namespaces: () => ReadonlyArray<NamespaceDescription>
readonly execute: (code: string) => Effect.Effect<Result, never, R>
}
@@ -126,23 +129,24 @@ 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>>(
export const execute = <const Provided extends ReadonlyArray<unknown>>(
options: ExecuteOptions<Provided>,
): Effect.Effect<Result, never, Services<Provided>> => {
const tools = (options.tools ?? {}) as Tools<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 Provided extends Record<string, unknown> = {}>(
export const make = <const Provided extends ReadonlyArray<unknown> = readonly []>(
options: Options<Provided> = {} as Options<Provided>,
): Runtime<Services<Provided>> => {
const tools = (options.tools ?? {}) as Tools<Services<Provided>>
const tools = (options.tools ?? []) as Tools<Services<Provided>>
const limits = resolveExecutionLimits(options.limits)
const prepared = ToolRuntime.prepare(tools)
return {
catalog: () => prepared.catalog,
namespaces: () => prepared.namespaces,
execute: (code) => executeWithLimits<Provided>({ ...options, code }, limits, prepared.searchIndex),
}
}
+1
View File
@@ -1,5 +1,6 @@
export * as CodeMode from "./codemode.js"
export * as Tool from "./tool.js"
export * as Namespace from "./namespace.js"
export * as OpenAPI from "./openapi/index.js"
export { searchSignature, toolExpression } from "./codemode.js"
export { ToolError, toolError } from "./tool-error.js"
+2 -2
View File
@@ -11,7 +11,7 @@ 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>>(
export const executeWithLimits = <const Provided extends ReadonlyArray<unknown>>(
options: ExecuteOptions<Provided>,
limits: ResolvedExecutionLimits,
searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"],
@@ -27,7 +27,7 @@ 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>>,
(options.tools ?? []) as Tools<Services<Provided>>,
limits.maxToolCalls,
searchIndex,
{
+36
View File
@@ -0,0 +1,36 @@
import type { Tools } from "./tools.js"
/** A named group of tools or nested namespaces exposed through CodeMode's `tools` object. */
export type Namespace<R = never> = {
readonly _tag: "CodeModeNamespace"
readonly name: string
readonly description?: string
readonly tools: Tools<R>
}
/** Options for declaring one CodeMode namespace. */
export type Options<R = never> = {
readonly name: string
readonly description?: string
readonly tools: Tools<R>
}
export const isNamespace = <R = never>(value: unknown): value is Namespace<R> =>
typeof value === "object" &&
value !== null &&
"_tag" in value &&
Object.hasOwn(value, "_tag") &&
value._tag === "CodeModeNamespace"
/**
* Declares one optionally described group of tools available through `tools.<name>.*`.
*
* Names belong to the namespace. The host registers the value in a `tools` array; the
* runtime does not take the name from object keys.
*/
export const make = <R = never>(options: Options<R>): Namespace<R> => ({
_tag: "CodeModeNamespace",
name: options.name,
...(options.description === undefined ? {} : { description: options.description }),
tools: options.tools,
})
+22 -10
View File
@@ -47,7 +47,7 @@ export const fromSpec = (options: Options): Result => {
const used = new Set<string>()
const namespaces = new Set<string>()
const skipped: Array<Skipped> = []
const tools = Object.create(null) as Tools
const root: OpenApiNode = { children: new Map() }
for (const [path, pathValue] of Object.entries(paths)) {
if (!isRecord(pathValue)) continue
@@ -101,10 +101,13 @@ export const fromSpec = (options: Options): Result => {
}
used.add(segments.join("."))
for (const index of segments.slice(0, -1).keys()) namespaces.add(segments.slice(0, index + 1).join("."))
const name = segments[segments.length - 1]
if (name === undefined) continue
setTool(
tools,
root,
segments,
make({
name,
description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
input: inputSchema(input.fields, requestDefinitions),
output: output.value,
@@ -114,19 +117,28 @@ export const fromSpec = (options: Options): Result => {
}
}
return { tools, skipped }
return { tools: toCatalog(root), skipped }
}
const setTool = (tools: Tools, path: ReadonlyArray<string>, tool: Tool<HttpClient.HttpClient>): void => {
type OpenApiNode = {
tool?: Tool<HttpClient.HttpClient>
readonly children: Map<string, OpenApiNode>
}
const setTool = (node: OpenApiNode, path: ReadonlyArray<string>, tool: Tool<HttpClient.HttpClient>): void => {
const [head, ...rest] = path
if (head === undefined) return
const child = node.children.get(head) ?? { children: new Map<string, OpenApiNode>() }
node.children.set(head, child)
if (rest.length === 0) {
tools[head] = tool
child.tool = tool
return
}
const child = tools[head]
if (child === undefined || !isRecord(child) || child._tag === "CodeModeTool") {
tools[head] = Object.create(null) as Tools
}
setTool(tools[head] as Tools, rest, tool)
setTool(child, rest, tool)
}
const toCatalog = (node: OpenApiNode): Tools =>
Array.from(node.children, ([name, child]) => {
if (child.tool !== undefined) return child.tool
return { _tag: "CodeModeNamespace", name, tools: toCatalog(child) }
})
+3 -2
View File
@@ -1,5 +1,6 @@
import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
import type { Namespace } from "../namespace.js"
import type { Tool, JsonSchema } from "../tool.js"
/** A parsed OpenAPI 3.x document. YAML must be parsed by the host. */
@@ -58,10 +59,10 @@ export type Skipped = {
readonly reason: string
}
export type Tools = { [name: string]: Tool<HttpClient.HttpClient> | Tools }
export type Tools = ReadonlyArray<Tool<HttpClient.HttpClient> | Namespace<HttpClient.HttpClient>>
export type Result = {
/** Namespaced tools; the host places them under a key in its `tools` object. */
/** Named tools and namespaces; the host may wrap them in another namespace. */
readonly tools: Tools
readonly skipped: ReadonlyArray<Skipped>
}
+77 -40
View File
@@ -26,16 +26,18 @@ export type Services<T> = ServicesOf<T, []>
type ServicesOf<T, Depth extends ReadonlyArray<unknown>> = Depth["length"] extends 8
? never
: T extends {
readonly _tag: "CodeModeTool"
readonly execute: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
}
? R
: T extends object
? string extends keyof T
? ServicesOf<T[string], [...Depth, unknown]>
: ServicesOf<T[keyof T], [...Depth, unknown]>
: T extends ReadonlyArray<infer Entry>
? Entry extends infer Item
? Item extends {
readonly _tag: "CodeModeTool"
readonly execute: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
}
? R
: Item extends { readonly _tag: "CodeModeNamespace"; readonly tools: infer Nested }
? ServicesOf<Nested, [...Depth, unknown]>
: never
: never
: never
export type ToolCall = {
readonly name: string
@@ -67,6 +69,11 @@ export type ToolDescription = {
readonly signature: string
}
export type NamespaceDescription = {
readonly path: string
readonly description?: string
}
export type SafeObject = Record<string, unknown>
const defaultSearchLimit = 10
@@ -274,34 +281,41 @@ export const copyOut = (value: unknown, mode: CopyOutMode): unknown => {
return value
}
// Dots in tool names are namespace separators; the last tool for a canonical path wins.
// Each registered name is one path segment. Dots are not separators.
type ToolNode<R> = {
tool?: Tool<R>
description?: string
readonly children: Map<string, ToolNode<R>>
}
const requireName = (name: string): void => {
if (name === "") throw new TypeError("Name cannot be empty.")
if (name.includes(".")) throw new TypeError(`Name '${name}' cannot contain '.'.`)
}
const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
const root: ToolNode<R> = { children: new Map() }
const insert = (node: ToolNode<R>, group: Tools<R>): 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
const insert = (node: ToolNode<R>, entries: Tools<R>, path: ReadonlyArray<string>): void => {
for (const entry of entries) {
requireName(entry.name)
const next = [...path, entry.name]
if (node.children.has(entry.name)) {
throw new TypeError(`Duplicate tool path '${next.join(".")}'.`)
}
if (isTool<R>(value)) current.tool = value
else insert(current, value)
const child: ToolNode<R> = { children: new Map() }
node.children.set(entry.name, child)
if (isTool<R>(entry)) {
child.tool = entry
continue
}
if (entry.description !== undefined) child.description = entry.description
insert(child, entry.tools, next)
}
}
insert(root, tools)
insert(root, tools, [])
return root
}
const canonicalSegments = (path: ReadonlyArray<string>): ReadonlyArray<string> =>
path.flatMap((segment) => segment.split("."))
const flattenTools = <R>(
node: ToolNode<R>,
path: ReadonlyArray<string> = [],
@@ -310,15 +324,28 @@ const flattenTools = <R>(
...Array.from(node.children, ([name, child]) => flattenTools(child, [...path, name])).flat(),
]
const flattenNamespaces = <R>(node: ToolNode<R>, path: ReadonlyArray<string> = []): Array<NamespaceDescription> => {
const nested = Array.from(node.children, ([name, child]) => flattenNamespaces(child, [...path, name])).flat()
if (path.length === 0 || node.children.size === 0) return nested
if (flattenTools(node, path).length === 0) return nested
return [
{
path: path.join("."),
...(node.description === undefined ? {} : { description: node.description }),
},
...nested,
]
}
const describeTool = <R>(path: string, tool: Tool<R>): ToolDescription => ({
path,
description: tool.description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(tool, true)}): Promise<${outputTypeScript(tool, true)}>`,
})
// Discovery bytes are durable instructions, so order only after canonical-path collisions settle.
const visibleTools = <R>(tools: Tools<R>) =>
flattenTools(toolTrie(tools))
// Discovery bytes are durable instructions, so order only after the trie is built.
const visibleTools = <R>(root: ToolNode<R>) =>
flattenTools(root)
.sort((left, right) => compareText(left.path, right.path))
.map(({ path, tool }) => ({
path,
@@ -328,6 +355,7 @@ const visibleTools = <R>(tools: Tools<R>) =>
export type DiscoveryPlan = {
readonly catalog: ReadonlyArray<ToolDescription>
readonly namespaces: ReadonlyArray<NamespaceDescription>
readonly searchIndex: ReadonlyArray<SearchEntry>
}
@@ -352,6 +380,7 @@ const termForms = (term: string): Array<string> => {
const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
_tag: "CodeModeTool",
name: "search",
description: "Search available tools",
input: SearchInput,
output: SearchOutput,
@@ -420,11 +449,19 @@ export const searchSignature = (() => {
return `search(input: ${inputTypeScript(tool, true)}): ${outputTypeScript(tool, true)}`
})()
const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
const toSearchEntry = <R>(
path: string,
tool: Tool<R>,
description: ToolDescription,
namespaces: ReadonlyArray<NamespaceDescription>,
): SearchEntry => ({
description,
searchText: [
path,
tool.description,
...namespaces
.filter((namespace) => path.startsWith(`${namespace.path}.`) && namespace.description !== undefined)
.map((namespace) => namespace.description),
...inputProperties(tool).flatMap(({ name, description: property }) =>
property === undefined ? [name] : [name, property],
),
@@ -433,14 +470,16 @@ const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescript
.toLowerCase(),
})
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> =>
visibleTools(tools).map(({ path, tool, description }) => toSearchEntry(path, tool, description))
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> => prepare(tools).searchIndex
export const prepare = <R>(tools: Tools<R>): DiscoveryPlan => {
const visible = visibleTools(tools)
const root = toolTrie(tools)
const visible = visibleTools(root)
const descriptions = flattenNamespaces(root).sort((left, right) => compareText(left.path, right.path))
return {
catalog: visible.map(({ description }) => description),
searchIndex: visible.map(({ path, tool, description }) => toSearchEntry(path, tool, description)),
namespaces: descriptions,
searchIndex: visible.map(({ path, tool, description }) => toSearchEntry(path, tool, description, descriptions)),
}
}
@@ -448,24 +487,22 @@ const lookup = <R>(root: ToolNode<R>, segments: ReadonlyArray<string>): ToolNode
segments.reduce<ToolNode<R> | undefined>((node, segment) => node?.children.get(segment), root)
const namespaceKeys = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): ReadonlyArray<string> => {
const segments = canonicalSegments(path)
const node = lookup(root, segments)
const node = lookup(root, path)
if (node === undefined) {
throw new ToolRuntimeError("UnknownTool", `Unknown tool namespace '${segments.join(".")}'.`)
throw new ToolRuntimeError("UnknownTool", `Unknown tool namespace '${path.join(".")}'.`)
}
return Array.from(node.children.keys())
}
const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Tool<R> => {
const segments = canonicalSegments(path)
const node = lookup(root, segments)
const node = lookup(root, path)
if (node === undefined) {
throw new ToolRuntimeError("UnknownTool", `Unknown tool '${segments.join(".")}'.`, [
throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, [
"The tool may have been removed or renamed. Use search to find available tools.",
])
}
if (node.tool === undefined) {
throw new ToolRuntimeError("UnknownTool", `Tool '${segments.join(".")}' is not callable.`)
throw new ToolRuntimeError("UnknownTool", `Tool '${path.join(".")}' is not callable.`)
}
return node.tool
}
@@ -567,7 +604,7 @@ export const make = <R>(
),
execute: (path, args) =>
Effect.gen(function* () {
const name = canonicalSegments(path).join(".")
const name = path.join(".")
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`), "json"))
const tool = resolve(root, path)
return yield* executeTool(name, tool, externalArgs)
+3
View File
@@ -32,6 +32,7 @@ export type SchemaType = Schema.Decoder<unknown> | JsonSchema
/** Executable tool exposed through CodeMode's `tools` object. */
export type Tool<R = never> = {
readonly _tag: "CodeModeTool"
readonly name: string
readonly description: string
readonly input: SchemaType
readonly output: SchemaType | undefined
@@ -44,6 +45,7 @@ type ResultType<S> = S extends undefined ? void : S extends Schema.Decoder<unkno
/** Options for declaring one CodeMode tool. */
export type Options<I extends SchemaType, O extends SchemaType | undefined, R = never> = {
readonly name: string
readonly description: string
readonly input: I
readonly output?: O
@@ -69,6 +71,7 @@ export const make = <I extends SchemaType, const O extends SchemaType | undefine
options: Options<I, O, R>,
): Tool<R> => ({
_tag: "CodeModeTool",
name: options.name,
description: options.description,
input: options.input,
output: options.output,
+2 -3
View File
@@ -1,5 +1,4 @@
import type { Namespace } from "./namespace.js"
import type { Tool } from "./tool.js"
export type Tools<R = never> = {
readonly [name: string]: Tool<R> | Tools<R>
}
export type Tools<R = never> = ReadonlyArray<Tool<R> | Namespace<R>>
@@ -59,7 +59,7 @@ import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const result = await Effect.runPromise(CodeMode.execute({ code }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
@@ -72,7 +72,7 @@ import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const result = await Effect.runPromise(CodeMode.execute({ code }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
+5 -3
View File
@@ -1,12 +1,12 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
import { CodeMode, Namespace, Tool } from "../src/index.js"
// Callback acceptance is one gate shared by array methods, sort, string replacers,
// Array.from mappers, Map/Set/URLSearchParams forEach, and promise reactions:
// interpreter functions, coercion/URI builtins, resolver capabilities, and built-in
// method references are callable; tools and other opaque callables get a wrap hint.
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code }))
const value = async (code: string) => {
const result = await run(code)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
@@ -24,12 +24,14 @@ const logsOf = async (code: string) => {
}
const echo = Tool.make({
name: "echo",
description: "Echo the input",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Number,
execute: (input: { id: number }) => Effect.succeed(input.id),
})
const withTool = (code: string) => Effect.runPromise(CodeMode.make({ tools: { host: { echo } } }).execute(code))
const withTool = (code: string) =>
Effect.runPromise(CodeMode.make({ tools: [Namespace.make({ name: "host", tools: [echo] })] }).execute(code))
const toolError = async (code: string) => {
const result = await withTool(code)
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
+118 -61
View File
@@ -1,9 +1,13 @@
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Schema } from "effect"
import { CodeMode, Tool, toolError } from "../src/index.js"
import { CodeMode, Namespace, Tool, toolError } from "../src/index.js"
const run = (tool: Tool.Tool<never>) =>
Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
Effect.runPromise(
CodeMode.make({
tools: [Namespace.make({ name: "host", tools: [{ ...tool, name: "call" }] })],
}).execute("return await tools.host.call({})"),
)
class HostError extends Schema.TaggedError<HostError>()("HostError", {
message: Schema.String,
@@ -13,6 +17,7 @@ describe("CodeMode host failure boundary", () => {
test("preserves explicit tool failures", async () => {
const result = await run(
Tool.make({
name: "call",
description: "Fail",
input: Schema.Struct({}),
output: Schema.String,
@@ -29,6 +34,7 @@ describe("CodeMode host failure boundary", () => {
test("does not rewrite explicit tool failures", async () => {
const result = await run(
Tool.make({
name: "call",
description: "Fail",
input: Schema.Struct({}),
output: Schema.String,
@@ -54,6 +60,7 @@ describe("CodeMode host failure boundary", () => {
]) {
const result = await run(
Tool.make({
name: "call",
description: "Fail internally",
input: Schema.Struct({}),
output: Schema.String,
@@ -71,6 +78,7 @@ describe("CodeMode host failure boundary", () => {
test("reports invalid host output", async () => {
const result = await run(
Tool.make({
name: "call",
description: "Return invalid output",
input: Schema.Struct({}),
output: Schema.Struct({ value: Schema.String }),
@@ -87,6 +95,7 @@ describe("CodeMode host failure boundary", () => {
test("reports host output copying errors", async () => {
const result = await run(
Tool.make({
name: "call",
description: "Return hostile output",
input: Schema.Struct({}),
output: Schema.Unknown,
@@ -113,16 +122,20 @@ describe("CodeMode host failure boundary", () => {
test("caught tool failures are Error values in-program", async () => {
const result = await Effect.runPromise(
CodeMode.make({
tools: {
host: {
call: Tool.make({
description: "Refuse",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.fail(toolError("Refused")),
}),
},
},
tools: [
Namespace.make({
name: "host",
tools: [
Tool.make({
name: "call",
description: "Refuse",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.fail(toolError("Refused")),
}),
],
}),
],
}).execute(`
try {
await tools.host.call({})
@@ -140,16 +153,20 @@ describe("CodeMode host failure boundary", () => {
test("propagates host interruption instead of returning a diagnostic", async () => {
const exit = await Effect.runPromiseExit(
CodeMode.make({
tools: {
host: {
call: Tool.make({
description: "Interrupt",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.interrupt,
}),
},
},
tools: [
Namespace.make({
name: "host",
tools: [
Tool.make({
name: "call",
description: "Interrupt",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.interrupt,
}),
],
}),
],
}).execute("return await tools.host.call({})"),
)
@@ -164,6 +181,7 @@ describe("CodeMode tool-call observation", () => {
test("reports the tools actually invoked with decoded input", async () => {
const calls: Array<unknown> = []
const lookup = Tool.make({
name: "lookup",
description: "Look up a value",
input: Schema.Struct({ query: Schema.String }),
output: Schema.String,
@@ -172,7 +190,7 @@ describe("CodeMode tool-call observation", () => {
const result = await Effect.runPromise(
CodeMode.make({
tools: { context: { lookup } },
tools: [Namespace.make({ name: "context", tools: [lookup] })],
onToolCallStart: (call) => Effect.sync(() => calls.push(call)),
}).execute(`
if (false) await tools.context.lookup({ query: "not called" })
@@ -187,6 +205,7 @@ describe("CodeMode tool-call observation", () => {
test("observes settled calls with outcome and duration", async () => {
const events: Array<{ phase: string; index: number; name: string; outcome?: string; message?: string }> = []
const lookup = Tool.make({
name: "lookup",
description: "Look up a value",
input: Schema.Struct({ query: Schema.String }),
output: Schema.String,
@@ -199,7 +218,7 @@ describe("CodeMode tool-call observation", () => {
})
const runtime = CodeMode.make({
tools: { context: { lookup } },
tools: [Namespace.make({ name: "context", tools: [lookup] })],
onToolCallStart: (call) =>
Effect.sync(() => {
events.push({ phase: "start", index: call.index, name: call.name })
@@ -237,6 +256,7 @@ describe("CodeMode tool-call observation", () => {
test("observes interrupted calls", async () => {
const events: Array<string> = []
const call = Tool.make({
name: "call",
description: "Interrupt",
input: Schema.Struct({}),
output: Schema.String,
@@ -244,7 +264,7 @@ describe("CodeMode tool-call observation", () => {
})
const exit = await Effect.runPromiseExit(
CodeMode.make({
tools: { host: { call } },
tools: [Namespace.make({ name: "host", tools: [call] })],
onToolCallStart: () => Effect.sync(() => events.push("start")),
onToolCallEnd: (call) => Effect.sync(() => events.push(`end:${call.outcome}`)),
}).execute("return await tools.host.call({})"),
@@ -257,6 +277,7 @@ describe("CodeMode tool-call observation", () => {
test("observes running calls interrupted during completion", async () => {
const events: Array<string> = []
const call = Tool.make({
name: "call",
description: "Pending",
input: Schema.Struct({}),
output: Schema.String,
@@ -264,7 +285,7 @@ describe("CodeMode tool-call observation", () => {
})
const result = await Effect.runPromise(
CodeMode.make({
tools: { host: { call } },
tools: [Namespace.make({ name: "host", tools: [call] })],
onToolCallStart: () => Effect.sync(() => events.push("start")),
onToolCallEnd: (call) => Effect.sync(() => events.push(`end:${call.outcome}`)),
}).execute('tools.host.call({}); return "done"'),
@@ -277,6 +298,7 @@ describe("CodeMode tool-call observation", () => {
test("ends calls interrupted during start observation", async () => {
const events: Array<string> = []
const call = Tool.make({
name: "call",
description: "Unused",
input: Schema.Struct({}),
output: Schema.String,
@@ -284,7 +306,7 @@ describe("CodeMode tool-call observation", () => {
})
const exit = await Effect.runPromiseExit(
CodeMode.make({
tools: { host: { call } },
tools: [Namespace.make({ name: "host", tools: [call] })],
onToolCallStart: () => Effect.interrupt,
onToolCallEnd: (call) => Effect.sync(() => events.push(call.outcome)),
}).execute("return await tools.host.call({})"),
@@ -297,6 +319,7 @@ describe("CodeMode tool-call observation", () => {
test("observes calls interrupted by the execution timeout", async () => {
const outcomes: Array<string> = []
const call = Tool.make({
name: "call",
description: "Pending",
input: Schema.Struct({}),
output: Schema.String,
@@ -304,7 +327,7 @@ describe("CodeMode tool-call observation", () => {
})
const result = await Effect.runPromise(
CodeMode.make({
tools: { host: { call } },
tools: [Namespace.make({ name: "host", tools: [call] })],
limits: { timeoutMs: 10 },
onToolCallEnd: (call) => Effect.sync(() => outcomes.push(call.outcome)),
}).execute("return await tools.host.call({})"),
@@ -514,6 +537,7 @@ describe("CodeMode schema flexibility", () => {
test("accepts render-only JSON Schema input and omitted output", async () => {
const observed: Array<unknown> = []
const call = Tool.make({
name: "call",
description: "Call an adapter-described tool",
input: {
type: "object",
@@ -526,7 +550,7 @@ describe("CodeMode schema flexibility", () => {
return { echoed: input }
}),
})
const runtime = CodeMode.make({ tools: { adapter: { call } } })
const runtime = CodeMode.make({ tools: [Namespace.make({ name: "adapter", tools: [call] })] })
expect(runtime.catalog()).toStrictEqual([
{
@@ -546,6 +570,7 @@ describe("CodeMode schema flexibility", () => {
test("outbound tool arguments follow JSON serialization semantics", async () => {
const observed: Array<unknown> = []
const call = Tool.make({
name: "call",
description: "Observe raw input",
input: { type: "object" },
execute: (input) =>
@@ -554,7 +579,7 @@ describe("CodeMode schema flexibility", () => {
return "ok"
}),
})
const runtime = CodeMode.make({ tools: { adapter: { call } } })
const runtime = CodeMode.make({ tools: [Namespace.make({ name: "adapter", tools: [call] })] })
const result = await Effect.runPromise(
runtime.execute(
@@ -571,6 +596,7 @@ describe("CodeMode schema flexibility", () => {
test("dropping undefined values lets optionalKey schemas accept conditional arguments", async () => {
const observed: Array<unknown> = []
const find = Tool.make({
name: "find",
description: "Find things",
input: Schema.Struct({ query: Schema.optionalKey(Schema.String), limit: Schema.optionalKey(Schema.Number) }),
execute: (input) =>
@@ -579,7 +605,7 @@ describe("CodeMode schema flexibility", () => {
return "ok"
}),
})
const runtime = CodeMode.make({ tools: { things: { find } } })
const runtime = CodeMode.make({ tools: [Namespace.make({ name: "things", tools: [find] })] })
// The `cond ? value : undefined` idiom: optionalKey rejects a present undefined, so the
// JSON boundary must drop the key before the schema decodes.
@@ -595,6 +621,7 @@ describe("CodeMode schema flexibility", () => {
test("renders JSON Schema outputs and $defs references", async () => {
const lookup = Tool.make({
name: "lookup",
description: "Look up a user",
input: { type: "object", properties: { login: { type: "string" } }, required: ["login"] },
output: {
@@ -609,7 +636,7 @@ describe("CodeMode schema flexibility", () => {
},
execute: () => Effect.succeed({ login: "kit", id: 7 }),
})
const runtime = CodeMode.make({ tools: { users: { lookup } } })
const runtime = CodeMode.make({ tools: [Namespace.make({ name: "users", tools: [lookup] })] })
expect(runtime.catalog()).toStrictEqual([
{
@@ -626,11 +653,12 @@ describe("CodeMode schema flexibility", () => {
test("Effect Schema output without an input transform renders void when omitted", async () => {
const ping = Tool.make({
name: "ping",
description: "Ping",
input: Schema.Struct({ host: Schema.String }),
execute: () => Effect.succeed("pong"),
})
const runtime = CodeMode.make({ tools: { net: { ping } } })
const runtime = CodeMode.make({ tools: [Namespace.make({ name: "net", tools: [ping] })] })
expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<void>")
const result = await Effect.runPromise(runtime.execute(`return await tools.net.ping({ host: "example.test" })`))
@@ -641,12 +669,13 @@ describe("CodeMode schema flexibility", () => {
describe("CodeMode public contract", () => {
const lookup = Tool.make({
name: "lookup",
description: "Look up an order by ID",
input: Schema.Struct({ id: Schema.String }),
output: Schema.Struct({ id: Schema.String, status: Schema.String }),
execute: ({ id }) => Effect.succeed({ id, status: "open" }),
})
const tools = { orders: { lookup } }
const tools = [Namespace.make({ name: "orders", tools: [lookup] })]
const source = `return await tools.orders.lookup({ id: "order_42" })`
test("keeps one-shot and reusable execution equivalent", async () => {
@@ -664,13 +693,14 @@ describe("CodeMode public contract", () => {
test("a reused execution Effect starts from a clean slate", async () => {
const echo = Tool.make({
name: "echo",
description: "echo",
input: Schema.Struct({}),
output: Schema.Number,
execute: () => Effect.succeed(1),
})
const effect = CodeMode.execute({
tools: { host: { echo } },
tools: [Namespace.make({ name: "host", tools: [echo] })],
code: `console.log("hi"); return await tools.host.echo({})`,
limits: { maxToolCalls: 1 },
})
@@ -712,19 +742,31 @@ describe("CodeMode public contract", () => {
test("renders equivalent catalogs identically regardless of tool insertion order", () => {
const alpha = Tool.make({
name: "alpha",
description: "Alpha tool",
input: Schema.Struct({}),
output: Schema.Void,
execute: () => Effect.void,
})
const zeta = Tool.make({
name: "zeta",
description: "Zeta tool",
input: Schema.Struct({}),
output: Schema.Void,
execute: () => Effect.void,
})
const first = CodeMode.make({ tools: { zeta: { zeta, alpha }, alpha: { zeta, alpha } } })
const second = CodeMode.make({ tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } } })
const first = CodeMode.make({
tools: [
Namespace.make({ name: "zeta", tools: [zeta, alpha] }),
Namespace.make({ name: "alpha", tools: [zeta, alpha] }),
],
})
const second = CodeMode.make({
tools: [
Namespace.make({ name: "alpha", tools: [alpha, zeta] }),
Namespace.make({ name: "zeta", tools: [alpha, zeta] }),
],
})
expect(first.catalog()).toStrictEqual(second.catalog())
expect(first.catalog().map((tool) => tool.path)).toEqual(["alpha.alpha", "alpha.zeta", "zeta.alpha", "zeta.zeta"])
@@ -732,12 +774,13 @@ describe("CodeMode public contract", () => {
test("renders bracket notation for tool names that are not JavaScript identifiers", async () => {
const resolveLibrary = Tool.make({
name: "resolve-library-id",
description: "Resolve a library ID",
input: Schema.Struct({ libraryName: Schema.String }),
output: Schema.String,
execute: ({ libraryName }) => Effect.succeed(`/resolved/${libraryName}`),
})
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
const runtime = CodeMode.make({ tools: [Namespace.make({ name: "context7", tools: [resolveLibrary] })] })
expect(runtime.catalog()).toStrictEqual([
{
@@ -778,19 +821,24 @@ describe("CodeMode public contract", () => {
test("uses one ranked search returning complete tools for large catalogs", async () => {
const upload = Tool.make({
name: "uploadFile",
description: "Upload one readable local file to the current Discord thread",
input: Schema.Struct({ path: Schema.String }),
output: Schema.Struct({ sent: Schema.Boolean }),
execute: () => Effect.succeed({ sent: true }),
})
const generate = Tool.make({
name: "generateImage",
description: "Generate an image and upload it to the current Discord thread",
input: Schema.Struct({ prompt: Schema.String }),
output: Schema.Struct({ sent: Schema.Boolean }),
execute: () => Effect.succeed({ sent: true }),
})
const runtime = CodeMode.make({
tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } },
tools: [
Namespace.make({ name: "thread", tools: [upload, generate] }),
Namespace.make({ name: "orders", tools: [lookup] }),
],
})
const result = await Effect.runPromise(
@@ -872,15 +920,14 @@ describe("CodeMode public contract", () => {
test("search defaults to 10 results and resolves exact tool paths", async () => {
const tool = (index: number) =>
Tool.make({
name: `tool${index}`,
description: `Numbered tool ${index}`,
input: Schema.Struct({ id: Schema.String }),
output: Schema.String,
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({
tools: {
many: Object.fromEntries(Array.from({ length: 14 }, (_, index) => [`tool${index}`, tool(index)])),
},
tools: [Namespace.make({ name: "many", tools: Array.from({ length: 14 }, (_, index) => tool(index)) })],
})
const browse = await Effect.runPromise(runtime.execute(`return search({})`))
@@ -916,18 +963,22 @@ describe("CodeMode public contract", () => {
})
test("scopes search to one namespace and browses it alphabetically", async () => {
const simple = (description: string) =>
const simple = (name: string, description: string) =>
Tool.make({
name,
description,
input: Schema.Struct({ id: Schema.String }),
output: Schema.String,
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({
tools: {
github: { list_issues: simple("List issues"), create_issue: simple("Create an issue") },
linear: { list_issues: simple("List Linear issues") },
},
tools: [
Namespace.make({
name: "github",
tools: [simple("list_issues", "List issues"), simple("create_issue", "Create an issue")],
}),
Namespace.make({ name: "linear", tools: [simple("list_issues", "List Linear issues")] }),
],
})
// Empty query + namespace browses just that namespace, alphabetical by path.
@@ -958,6 +1009,7 @@ describe("CodeMode public contract", () => {
test("matches input parameter names and partial-word substrings", async () => {
const upload = Tool.make({
name: "upload",
description: "Send a document to the workspace",
input: {
type: "object",
@@ -967,12 +1019,13 @@ describe("CodeMode public contract", () => {
execute: () => Effect.succeed("ok"),
})
const other = Tool.make({
name: "other",
description: "Rename the workspace",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({ tools: { files: { upload, other } } })
const runtime = CodeMode.make({ tools: [Namespace.make({ name: "files", tools: [upload, other] })] })
// "attachment" appears in neither path nor description - only in the input schema's
// property names, which the searchable text includes.
@@ -995,20 +1048,21 @@ describe("CodeMode public contract", () => {
})
test("a plural query term matches singular-only tool text", async () => {
const simple = (description: string) =>
const simple = (name: string, description: string) =>
Tool.make({
name,
description,
input: Schema.Struct({ id: Schema.String }),
output: Schema.String,
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({
tools: {
tools: [
// Neither path nor description contains "issues" - only the singular "issue".
tracker: { fetch_all: simple("Fetch every open issue in the project") },
github: { list_issues: simple("List issues") },
misc: { rename: simple("Rename the workspace") },
},
Namespace.make({ name: "tracker", tools: [simple("fetch_all", "Fetch every open issue in the project")] }),
Namespace.make({ name: "github", tools: [simple("list_issues", "List issues")] }),
Namespace.make({ name: "misc", tools: [simple("rename", "Rename the workspace")] }),
],
})
// "issues" still finds the singular-only tool (term OR singular(term) per field)...
@@ -1034,8 +1088,9 @@ describe("CodeMode public contract", () => {
})
test("empty query lists everything alphabetically by path", async () => {
const simple = (description: string) =>
const simple = (name: string, description: string) =>
Tool.make({
name,
description,
input: Schema.Struct({}),
output: Schema.String,
@@ -1043,10 +1098,10 @@ describe("CodeMode public contract", () => {
})
// Deliberately declared out of alphabetical order.
const runtime = CodeMode.make({
tools: {
zeta: { last: simple("Last") },
alpha: { beta: simple("Middle"), aardvark: simple("First") },
},
tools: [
Namespace.make({ name: "zeta", tools: [simple("last", "Last")] }),
Namespace.make({ name: "alpha", tools: [simple("beta", "Middle"), simple("aardvark", "First")] }),
],
})
const browse = await Effect.runPromise(runtime.execute(`return search({})`))
expect(browse.ok).toBe(true)
@@ -1079,6 +1134,7 @@ describe("CodeMode public contract", () => {
test("decodes tool input and output before exposing either side", async () => {
const observed: Array<unknown> = []
const transformed = Tool.make({
name: "double",
description: "Double a number",
input: Schema.Struct({ value: Schema.NumberFromString }),
output: Schema.NumberFromString,
@@ -1089,7 +1145,7 @@ describe("CodeMode public contract", () => {
}),
})
const runtime = CodeMode.make({
tools: { math: { double: transformed } },
tools: [Namespace.make({ name: "math", tools: [transformed] })],
onToolCallStart: (call) => Effect.sync(() => observed.push(call.input)),
})
@@ -1170,6 +1226,7 @@ describe("CodeMode public contract", () => {
// 150 tool calls would have exceeded the old default cap of 100; with no limits
// provided, there is no cap and no timeout - budgets are host policy.
const counter = Tool.make({
name: "count",
description: "Count invocations",
input: Schema.Struct({}),
output: Schema.Number,
@@ -1177,7 +1234,7 @@ describe("CodeMode public contract", () => {
})
const result = await Effect.runPromise(
CodeMode.execute({
tools: { host: { count: counter } },
tools: [Namespace.make({ name: "host", tools: [counter] })],
code: `
let total = 0
for (let i = 0; i < 150; i += 1) total += await tools.host.count({})
+1 -1
View File
@@ -47,7 +47,7 @@ import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const result = await Effect.runPromise(CodeMode.execute({ code }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
+11 -7
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
import { CodeMode, Namespace, 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 supplied tools), so a
@@ -8,19 +8,23 @@ import { CodeMode, Tool } from "../src/index.js"
// 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.
const echo = (description: string) =>
const echo = (name: string, description: string) =>
Tool.make({
name,
description,
input: Schema.Struct({ value: Schema.String }),
output: Schema.String,
execute: ({ value }) => Effect.succeed(value),
})
const tools = {
github: { list_issues: echo("List issues"), get_issue: echo("Get one issue") },
memory: { search: echo("Search memory") },
playwright: { navigate: echo("Navigate somewhere") },
}
const tools = [
Namespace.make({
name: "github",
tools: [echo("list_issues", "List issues"), echo("get_issue", "Get one issue")],
}),
Namespace.make({ name: "memory", tools: [echo("search", "Search memory")] }),
Namespace.make({ name: "playwright", tools: [echo("navigate", "Navigate somewhere")] }),
]
const run = (code: string) => Effect.runPromise(CodeMode.execute({ tools, code }))
const value = async (code: string) => {
@@ -16,7 +16,7 @@ import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code }))
const value = async (code: string) => {
const result = await execute(code)
@@ -14,7 +14,7 @@ import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code }))
const value = async (code: string) => {
const result = await execute(code)
@@ -32,7 +32,7 @@ import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code }))
const value = async (code: string) => {
const result = await execute(code)
@@ -32,7 +32,7 @@ import { CodeMode } from "../src/index.js"
import { invokeJsonMethod } from "../src/stdlib/json.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const result = await Effect.runPromise(CodeMode.execute({ code }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
@@ -213,7 +213,7 @@ describe("Test262 JSON.stringify replacer adaptations", () => {
describe("CodeMode JSON callback boundaries", () => {
test("this remains unsupported rather than exposing callback holders", async () => {
const result = await Effect.runPromise(
CodeMode.execute({ code: `return JSON.parse("1", function (key, item) { return this })`, tools: {} }),
CodeMode.execute({ code: `return JSON.parse("1", function (key, item) { return this })` }),
)
expect(result).toMatchObject({ ok: false, error: { kind: "UnsupportedSyntax" } })
})
@@ -14,7 +14,7 @@ import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code }))
const value = async (code: string) => {
const result = await execute(code)
@@ -29,7 +29,7 @@ import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const result = await Effect.runPromise(CodeMode.execute({ code }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
+30 -15
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { CodeMode, OpenAPI, Tool } from "../src/index.js"
import { CodeMode, Namespace, OpenAPI, Tool } from "../src/index.js"
import { inputTypeScript, outputTypeScript } from "../src/tool-schema.js"
const baseUrl = "http://localhost:4096"
@@ -25,8 +25,17 @@ const happyPathSpec = async (): Promise<Document> => {
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
const toolAt = (tools: unknown, name: string) =>
name.split(".").reduce<unknown>((current, segment) => (isRecord(current) ? current[segment] : undefined), tools)
const toolAt = (tools: OpenAPI.Tools, path: string) => {
let current: OpenAPI.Tools | undefined = tools
let found: OpenAPI.Tools[number] | undefined
for (const segment of path.split(".")) {
if (current === undefined) return undefined
found = current.find((entry) => entry.name === segment)
if (found === undefined) return undefined
current = found._tag === "CodeModeNamespace" ? found.tools : undefined
}
return found
}
const recordingClient = (respond: (request: HttpClientRequest.HttpClientRequest) => Response) => {
const requests: Array<Recorded> = []
@@ -158,7 +167,7 @@ describe("OpenAPI.fromSpec", () => {
expect(outputTypeScript(remove)).toBe("null")
const result = await Effect.runPromise(
CodeMode.make({ tools: { api: api.tools } })
CodeMode.make({ tools: [Namespace.make({ name: "api", tools: api.tools })] })
.execute(
`
const user = await tools.api.users.get({
@@ -948,7 +957,7 @@ describe("OpenAPI.fromSpec", () => {
expect(spec.security).toStrictEqual([])
expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([])
const health = toolAt(result.tools, "v2.health.get")
const healthInput = isRecord(health) ? health.input : undefined
const healthInput = Tool.isTool(health) && isRecord(health.input) ? health.input : undefined
expect(healthInput).toMatchObject({ type: "object", properties: {} })
const input = isRecord(healthInput) ? healthInput : {}
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([])
@@ -957,7 +966,9 @@ describe("OpenAPI.fromSpec", () => {
test("exposes real opencode operations through CodeMode discovery", async () => {
const { layer } = recordingClient(() => json({}))
const runtime = CodeMode.make({
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
tools: [
Namespace.make({ name: "opencode", tools: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools }),
],
})
const result = await Effect.runPromise(
runtime
@@ -988,7 +999,9 @@ describe("OpenAPI.fromSpec", () => {
return json({ id: "ses_456" })
})
const runtime = CodeMode.make({
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
tools: [
Namespace.make({ name: "opencode", tools: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools }),
],
})
const result = await Effect.runPromise(
@@ -1160,7 +1173,7 @@ describe("OpenAPI.fromSpec", () => {
},
})
expect(result.tools).toEqual({})
expect(result.tools).toEqual([])
expect(result.skipped.map((item) => item.reason)).toEqual([
"cookie parameter 'session' is not supported",
"parameter 'query' uses unsupported allowReserved encoding",
@@ -1175,7 +1188,7 @@ describe("OpenAPI.fromSpec", () => {
spec: singleOperation({ security: [JSON.parse('{"__proto__":[]}')] }),
})
expect(result.tools).toEqual({})
expect(result.tools).toEqual([])
expect(result.skipped[0]?.reason).toBe("security requirement references missing or malformed scheme: __proto__")
})
@@ -1255,7 +1268,7 @@ describe("OpenAPI.fromSpec", () => {
)
const cookie = authenticated([{ key: [] }], { key: { type: "apiKey", in: "cookie", name: "session" } })
expect(cookie.tools).toEqual({})
expect(cookie.tools).toEqual([])
expect(cookie.skipped[0]?.reason).toBe("cookie authentication 'key' is not supported")
const alternative = OpenAPI.fromSpec({
@@ -1293,11 +1306,11 @@ describe("OpenAPI.fromSpec", () => {
expect(client.requests[0]?.url).toBe("https://operation.example/v1/test")
const invalid = OpenAPI.fromSpec({ spec, baseUrl: "https://example.com/api?tenant=one" })
expect(invalid.tools).toEqual({})
expect(invalid.tools).toEqual([])
expect(invalid.skipped[0]?.reason).toContain("unsupported query string or fragment")
const malformed = OpenAPI.fromSpec({ spec, baseUrl: "https:/example.com" })
expect(malformed.tools).toEqual({})
expect(malformed.tools).toEqual([])
expect(malformed.skipped[0]?.reason).toContain("not an absolute HTTP(S) URL")
})
@@ -1315,7 +1328,7 @@ describe("OpenAPI.fromSpec", () => {
},
})
expect(result.tools).toEqual({})
expect(result.tools).toEqual([])
expect(result.skipped[0]?.reason).toBe("SSE operations are not supported")
})
@@ -1334,7 +1347,7 @@ describe("OpenAPI.fromSpec", () => {
},
})
expect(result.tools).toEqual({})
expect(result.tools).toEqual([])
expect(result.skipped[0]?.reason).toBe("binary responses are not supported")
})
@@ -1435,7 +1448,9 @@ describe("OpenAPI.fromSpec", () => {
test("fails missing required parameters before auth and network", async () => {
const { requests, layer } = recordingClient(() => json({}))
const runtime = CodeMode.make({
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
tools: [
Namespace.make({ name: "opencode", tools: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools }),
],
})
const result = await Effect.runPromise(
+1 -1
View File
@@ -10,7 +10,7 @@ import { ToolRuntime } from "../src/tool-runtime.js"
// Note on the result boundary: this package normalizes a bare `undefined` result to `null` when
// it crosses out of CodeMode (results are JSON data), so tests asserting an in-CodeMode
// `undefined` read check `=== undefined` inside the program and `null` at the boundary.
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code }))
const value = async (code: string) => {
const result = await run(code)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
@@ -16,7 +16,7 @@ import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {}, limits: { timeoutMs: 1_000 } }))
const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code, limits: { timeoutMs: 1_000 } }))
const value = async (code: string) => {
const result = await execute(code)
+24 -13
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Deferred, Effect, Schema } from "effect"
import { CodeMode, Tool, toolError } from "../src/index.js"
import { CodeMode, Namespace, Tool, toolError } from "../src/index.js"
// Wave 5 acceptance suite: first-class promise values. Un-awaited tool calls start eagerly on
// supervised fibers, `await` settles them, Promise.all/allSettled/race/resolve/reject are
@@ -30,6 +30,7 @@ const makeTrace = (): Trace => ({ starts: [], active: 0, maxActive: 0, completed
*/
const echoTool = (trace: Trace) =>
Tool.make({
name: "echo",
description: "Echo an id immediately",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Number,
@@ -43,6 +44,7 @@ const echoTool = (trace: Trace) =>
const gatedTool = (trace: Trace, gate: (id: number) => Deferred.Deferred<void>) =>
Tool.make({
name: "gated",
description: "Echo an id once its gate opens",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Number,
@@ -67,6 +69,7 @@ const gatedTool = (trace: Trace, gate: (id: number) => Deferred.Deferred<void>)
const openTool = (gate: (id: number) => Deferred.Deferred<void>) =>
Tool.make({
name: "open",
description: "Open the gate for an id",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Boolean,
@@ -75,6 +78,7 @@ const openTool = (gate: (id: number) => Deferred.Deferred<void>) =>
const pendingTool = (trace: Trace) =>
Tool.make({
name: "pending",
description: "Never settle",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Number,
@@ -95,6 +99,7 @@ const pendingTool = (trace: Trace) =>
})
const failingTool = Tool.make({
name: "fail",
description: "Always refuse",
input: Schema.Struct({}),
output: Schema.String,
@@ -102,6 +107,7 @@ const failingTool = Tool.make({
})
const interruptedTool = Tool.make({
name: "interrupt",
description: "Interrupt this call",
input: Schema.Struct({}),
output: Schema.String,
@@ -110,6 +116,7 @@ const interruptedTool = Tool.make({
const completedTool = (trace: Trace) =>
Tool.make({
name: "completed",
description: "Return the number of completed calls",
input: Schema.Struct({}),
output: Schema.Number,
@@ -119,6 +126,7 @@ const completedTool = (trace: Trace) =>
/** Never settles, and holds interruption cleanup for `cleanupMs` so completion cleanup can outlast a timeout. */
const stubbornTool = (trace: Trace) =>
Tool.make({
name: "stubborn",
description: "Never settle; clean up slowly when interrupted",
input: Schema.Struct({ cleanupMs: Schema.Number }),
output: Schema.Number,
@@ -150,18 +158,21 @@ const run = (
}
return Effect.runPromise(
CodeMode.execute({
tools: {
host: {
echo: echoTool(trace),
gated: gatedTool(trace, gate),
open: openTool(gate),
pending: pendingTool(trace),
fail: failingTool,
interrupt: interruptedTool,
completed: completedTool(trace),
stubborn: stubbornTool(trace),
},
},
tools: [
Namespace.make({
name: "host",
tools: [
echoTool(trace),
gatedTool(trace, gate),
openTool(gate),
pendingTool(trace),
failingTool,
interruptedTool,
completedTool(trace),
stubbornTool(trace),
],
}),
],
code,
...(options.limits ? { limits: options.limits } : {}),
}),
@@ -44,7 +44,7 @@ import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const result = await Effect.runPromise(CodeMode.execute({ code }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
@@ -74,7 +74,7 @@ import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const result = await Effect.runPromise(CodeMode.execute({ code }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
+14 -3
View File
@@ -1,11 +1,12 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
import { CodeMode, Namespace, Tool } from "../src/index.js"
import { inputTypeScript, jsonSchemaToTypeScript, outputTypeScript } from "../src/tool-schema.js"
// A raw JSON Schema tool in the shape an MCP adapter produces: render-only input schema
// whose property descriptions and constraints must surface as JSDoc in pretty signatures.
const listIssues = Tool.make({
name: "list_issues",
description: "List issues in a repository",
input: {
type: "object",
@@ -24,6 +25,7 @@ const listIssues = Tool.make({
// An Effect Schema tool whose field annotations must flow through the emitted JSON Schema.
const lookupOrder = Tool.make({
name: "lookup",
description: "Look up an order",
input: Schema.Struct({
id: Schema.String.annotate({ description: "Order identifier" }),
@@ -255,6 +257,7 @@ describe("non-identifier property names render as quoted keys", () => {
test("JSON Schema input and output signatures of a tool both quote", () => {
const tool = Tool.make({
name: "adapter",
description: "Adapter tool with awkward field names",
input: rawSchema,
output: {
@@ -271,6 +274,7 @@ describe("non-identifier property names render as quoted keys", () => {
test("Effect Schema structs with non-identifier field names quote too", () => {
const tool = Tool.make({
name: "schema",
description: "Schema tool with awkward field names",
input: Schema.Struct({ "foo-bar": Schema.String, plain: Schema.optionalKey(Schema.Number) }),
execute: () => Effect.succeed(null),
@@ -301,6 +305,7 @@ describe("union schemas render every alternative", () => {
test("tool input and output signatures preserve numeric unions", () => {
const tool = Tool.make({
name: "unions",
description: "Tool with numeric unions",
input: {
type: "object",
@@ -342,7 +347,12 @@ describe("union schemas render every alternative", () => {
})
describe("JSDoc signatures in catalogs and search results", () => {
const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } })
const runtime = CodeMode.make({
tools: [
Namespace.make({ name: "github", tools: [listIssues] }),
Namespace.make({ name: "orders", tools: [lookupOrder] }),
],
})
const search = async (query: string) => {
const result = await Effect.runPromise(runtime.execute(`return search({ query: ${JSON.stringify(query)} })`))
@@ -411,6 +421,7 @@ describe("JSDoc signatures in catalogs and search results", () => {
describe("non-identifier tool paths", () => {
const resolveLibrary = Tool.make({
name: "resolve-library-id",
description: "Resolve a Context7 library ID",
input: {
type: "object",
@@ -423,7 +434,7 @@ describe("non-identifier tool paths", () => {
output: {},
execute: () => Effect.succeed("/reactjs/react.dev"),
})
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
const runtime = CodeMode.make({ tools: [Namespace.make({ name: "context7", tools: [resolveLibrary] })] })
test("catalog signatures use bracket notation for dashed tool names", () => {
expect(runtime.catalog()[0]?.signature).toBe(
+7 -5
View File
@@ -16,14 +16,14 @@
*/
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
import { CodeMode, Namespace, Tool } from "../src/index.js"
// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
// intra-CodeMode checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
// values, while at the host boundary (final result, tool arguments, JSON.stringify) they
// serialize exactly as JSON.stringify would: Date -> ISO string (invalid -> null),
// URL -> href, and RegExp/Map/Set/URLSearchParams -> {}.
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code }))
const value = async (code: string) => {
const result = await run(code)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
@@ -326,6 +326,7 @@ describe("RegExp", () => {
test("promise-returning string replacers are coerced synchronously", async () => {
const decorate = Tool.make({
name: "decorate",
description: "Decorate a string",
input: Schema.String,
output: Schema.String,
@@ -333,7 +334,7 @@ describe("RegExp", () => {
})
const result = await Effect.runPromise(
CodeMode.execute({
tools: { host: { decorate } },
tools: [Namespace.make({ name: "host", tools: [decorate] })],
code: `return "a1b22".replace(/\\d+/g, async (match) => await tools.host.decorate(match))`,
}),
)
@@ -341,7 +342,7 @@ describe("RegExp", () => {
const missingAwait = await Effect.runPromise(
CodeMode.execute({
tools: { host: { decorate } },
tools: [Namespace.make({ name: "host", tools: [decorate] })],
code: `return "a1".replace(/\\d/, (match) => tools.host.decorate(match))`,
}),
)
@@ -1026,6 +1027,7 @@ describe("CodeMode values at intra-CodeMode checkpoints", () => {
const observed: Array<unknown> = []
const capture = Tool.make({
name: "capture",
description: "Capture the exact input the host receives",
input: { type: "object" },
execute: (input) =>
@@ -1036,7 +1038,7 @@ describe("CodeMode values at intra-CodeMode checkpoints", () => {
})
const result = await Effect.runPromise(
CodeMode.execute({
tools: { host: { capture } },
tools: [Namespace.make({ name: "host", tools: [capture] })],
code: `return await tools.host.capture({ when: new Date(0), tags: new Map([["a", 1]]) })`,
}),
)
@@ -170,7 +170,7 @@ type Vector = {
}
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const result = await Effect.runPromise(CodeMode.execute({ code }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
@@ -91,7 +91,7 @@ type Vector = {
}
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const result = await Effect.runPromise(CodeMode.execute({ code }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
@@ -105,7 +105,7 @@ import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const result = await Effect.runPromise(CodeMode.execute({ code }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
+191 -93
View File
@@ -1,9 +1,10 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
import { CodeMode, Namespace, Tool } from "../src/index.js"
const echo = (description: string, result: string) =>
const echo = (name: string, description: string, result: string) =>
Tool.make({
name,
description,
input: Schema.Struct({}),
output: Schema.String,
@@ -22,87 +23,82 @@ const failure = async (runtime: CodeMode.Runtime, code: string) => {
return result.error
}
describe("dotted tool names", () => {
const runtime = CodeMode.make({ tools: { api: { "issues.list": echo("List issues", "listed") } } })
describe("nested namespaces", () => {
const runtime = CodeMode.make({
tools: [
Namespace.make({
name: "api",
tools: [
Namespace.make({
name: "issues",
tools: [echo("list", "List issues", "listed")],
}),
],
}),
],
})
test("a dotted name becomes nested namespaces in the catalog", () => {
test("nested namespaces appear in the catalog", () => {
const catalog = runtime.catalog()
expect(catalog).toHaveLength(1)
expect(catalog[0]?.path).toBe("api.issues.list")
expect(catalog[0]?.signature).toStartWith("tools.api.issues.list(input:")
})
test("the advertised dotted path is executable", async () => {
test("the advertised path is executable", async () => {
expect(await value(runtime, `return await tools.api.issues.list({})`)).toBe("listed")
})
test("bracket access with a dotted segment spells the same canonical path", async () => {
expect(await value(runtime, `return await tools.api["issues.list"]({})`)).toBe("listed")
expect(await value(runtime, `return await tools["api.issues"].list({})`)).toBe("listed")
})
test("intermediate segments enumerate like ordinary namespaces", async () => {
expect(await value(runtime, `return [Object.keys(tools.api), Object.keys(tools.api.issues)]`)).toEqual([
["issues"],
["list"],
])
expect(await value(runtime, `return Object.keys(tools["api.issues"])`)).toEqual(["list"])
})
test("a top-level dotted name nests from the root", async () => {
const flat = CodeMode.make({ tools: { "issues.list": echo("List issues", "flat") } })
expect(flat.catalog()[0]?.path).toBe("issues.list")
expect(await value(flat, `return await tools.issues.list({})`)).toBe("flat")
})
test("search scopes to a nested namespace subtree", async () => {
const nested = CodeMode.make({
tools: {
slack: {
admin: echo("Admin", "admin"),
"admin.invite": echo("Invite", "invite"),
"admin.users.list": echo("List users", "users"),
"administrator.list": echo("List administrators", "administrators"),
read: echo("Read Slack", "read"),
},
},
tools: [
Namespace.make({
name: "slack",
tools: [
echo("admin", "Admin", "admin"),
echo("read", "Read Slack", "read"),
Namespace.make({
name: "administrator",
tools: [echo("list", "List administrators", "administrators")],
}),
],
}),
],
})
const result = await value(nested, `return search({ query: "", namespace: "slack.admin" })`)
const result = await value(nested, `return search({ query: "", namespace: "slack" })`)
expect((result as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
"tools.slack.admin",
"tools.slack.admin.invite",
"tools.slack.admin.users.list",
"tools.slack.administrator.list",
"tools.slack.read",
])
})
})
describe("callable namespaces", () => {
describe("namespaces are not callable", () => {
const runtime = CodeMode.make({
tools: { issues: echo("All issues", "all"), "issues.list": echo("List issues", "list") },
tools: [Namespace.make({ name: "issues", tools: [echo("list", "List issues", "list")] })],
})
test("a path can hold a tool and child tools at once", async () => {
expect(await value(runtime, `return await tools.issues({})`)).toBe("all")
expect(await value(runtime, `return await tools.issues.list({})`)).toBe("list")
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues", "issues.list"])
})
test("a callable namespace enumerates its children", async () => {
test("a namespace enumerates its children", async () => {
expect(await value(runtime, `return Object.keys(tools.issues)`)).toEqual(["list"])
})
test("search returns executable paths for both", async () => {
test("search returns executable child paths", async () => {
const result = await value(runtime, `return search({ query: "", namespace: "issues" })`)
expect((result as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
"tools.issues",
"tools.issues.list",
])
expect((result as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual(["tools.issues.list"])
const exact = await value(runtime, `return search({ query: "tools.issues.list" })`)
expect((exact as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual(["tools.issues.list"])
})
test("an unknown child under a callable tool is an UnknownTool error", async () => {
test("an unknown child under a namespace is an UnknownTool error", async () => {
const diagnostic = await failure(runtime, `return await tools.issues.missing({})`)
expect(diagnostic.kind).toBe("UnknownTool")
expect(diagnostic.message).toContain("Unknown tool 'issues.missing'")
@@ -112,8 +108,7 @@ describe("callable namespaces", () => {
})
test("a namespace without its own tool stays non-callable", async () => {
const nested = CodeMode.make({ tools: { "issues.list": echo("List issues", "list") } })
const diagnostic = await failure(nested, `return await tools.issues({})`)
const diagnostic = await failure(runtime, `return await tools.issues({})`)
expect(diagnostic.kind).toBe("UnknownTool")
expect(diagnostic.message).toContain("Tool 'issues' is not callable")
})
@@ -121,14 +116,20 @@ describe("callable namespaces", () => {
describe("tool input diagnostics", () => {
const runtime = CodeMode.make({
tools: {
"notes.echo": Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.String,
execute: ({ text }) => Effect.succeed(text),
tools: [
Namespace.make({
name: "notes",
tools: [
Tool.make({
name: "echo",
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.String,
execute: ({ text }) => Effect.succeed(text),
}),
],
}),
},
],
})
test("a schema mismatch suggests searching for the current signature", async () => {
@@ -146,30 +147,27 @@ describe("tool input diagnostics", () => {
describe("blocked member names on tool paths", () => {
const runtime = CodeMode.make({
tools: {
prototype: echo("Prototype tool", "proto"),
"issues.constructor": echo("Constructor tool", "ctor"),
nested: { ["__proto__"]: echo("Proto tool", "dunder") },
},
tools: [
echo("prototype", "Prototype tool", "proto"),
Namespace.make({
name: "issues",
tools: [echo("constructor", "Constructor tool", "ctor")],
}),
Namespace.make({
name: "nested",
tools: [echo("__proto__", "Proto tool", "dunder")],
}),
],
})
test("tools may use blocked member names because path segments never touch real properties", async () => {
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.constructor", "nested.__proto__", "prototype"])
expect(await value(runtime, `return await tools.prototype({})`)).toBe("proto")
expect(await value(runtime, `return await tools.issues.constructor({})`)).toBe("ctor")
expect(await value(runtime, `return await tools["issues.constructor"]({})`)).toBe("ctor")
expect(await value(runtime, `return await tools.nested.__proto__({})`)).toBe("dunder")
expect(await value(runtime, `return Object.keys(tools.issues)`)).toEqual(["constructor"])
})
test("a literal __proto__ key cannot poison a namespace into a fake tool", async () => {
const poisoned = CodeMode.make({
tools: { ns: { __proto__: echo("Hidden", "hidden"), real: echo("Real tool", "real") } },
})
expect(poisoned.catalog().map((tool) => tool.path)).toEqual(["ns.real"])
expect(await value(poisoned, `return await tools.ns.real({})`)).toBe("real")
})
test("blocked member access on data values stays blocked", async () => {
const diagnostic = await failure(runtime, `const x = {}; return x.constructor`)
expect(diagnostic.message).toContain("constructor")
@@ -177,35 +175,135 @@ describe("blocked member names on tool paths", () => {
})
})
describe("empty segments", () => {
test("tool names with empty segments are rejected at make", () => {
for (const name of ["", "a..b", "trail.", ".lead"]) {
expect(() => CodeMode.make({ tools: { [name]: echo("Bad", "bad") } })).toThrow("empty segment")
}
describe("namespace descriptions", () => {
const tools = [
Namespace.make({
name: "api",
description: "Workspace",
tools: [
echo("admin", "Admin tool", "admin"),
echo("read", "Read data", "read"),
Namespace.make({
name: "users",
description: "Directory",
tools: [echo("list", "List users", "users")],
}),
Namespace.make({
name: "invite",
tools: [echo("send", "Invite user", "invite")],
}),
],
}),
Namespace.make({
name: "other",
tools: [echo("read", "Read other", "other")],
}),
]
const runtime = CodeMode.make({ tools })
test("namespaces with descendant tools are returned in canonical path order", () => {
const descriptions: ReadonlyArray<CodeMode.NamespaceDescription> = runtime.namespaces()
expect(descriptions).toEqual([
{ path: "api", description: "Workspace" },
{ path: "api.invite" },
{ path: "api.users", description: "Directory" },
{ path: "other" },
])
expect(CodeMode.make({ tools: [] }).namespaces()).toEqual([])
})
test.each(["make", "execute"] as const)(
"%s searches ancestor descriptions without changing result descriptors",
async (mode) => {
for (const [query, scope, paths] of [
["Workspace", undefined, ["api.admin", "api.invite.send", "api.read", "api.users.list"]],
["Directory", undefined, ["api.users.list"]],
["Workspace", "api.users", ["api.users.list"]],
["Directory", "api.invite", []],
] as const) {
const code = `return search(${JSON.stringify({ query, namespace: scope })})`
const result = await Effect.runPromise(
mode === "make" ? runtime.execute(code) : CodeMode.execute({ tools, code }),
)
expect(result).toEqual({
ok: true,
value: {
items: paths.map((path) => ({
...runtime.catalog().find((tool) => tool.path === path),
path: `tools.${path}`,
})),
remaining: 0,
next: null,
},
toolCalls: [{ name: "search" }],
})
}
},
)
test("namespaces preserve tool descriptions, enumeration, and callability", async () => {
expect(await value(runtime, `return Object.keys(tools)`)).toEqual(["api", "other"])
expect(await value(runtime, `return Object.keys(tools.api)`)).toEqual(["admin", "read", "users", "invite"])
expect(await value(runtime, `return await tools.api.admin({})`)).toBe("admin")
expect(await value(runtime, `return await tools.api.users.list({})`)).toBe("users")
expect((await failure(runtime, `return await tools.api.users({})`)).message).toContain("is not callable")
expect((await failure(runtime, `return await tools.missing({})`)).message).toContain("Unknown tool 'missing'")
})
test.each(["with-hyphen", "with space", "constructor", "prototype", "__proto__"])(
"namespace names allow %s just like tool names",
async (name) => {
const runtime = CodeMode.make({
tools: [
Namespace.make({
name,
description: "Collection",
tools: [echo("read", "Read", "read")],
}),
],
})
expect(runtime.namespaces()).toEqual([{ path: name, description: "Collection" }])
expect(await value(runtime, `return await tools[${JSON.stringify(name)}].read({})`)).toBe("read")
},
)
})
describe("invalid names", () => {
test("empty names are rejected at make", () => {
expect(() => CodeMode.make({ tools: [echo("", "Bad", "bad")] })).toThrow("Name cannot be empty.")
expect(() =>
CodeMode.make({ tools: [Namespace.make({ name: "", tools: [echo("read", "Read", "read")] })] }),
).toThrow("Name cannot be empty.")
})
test("names containing '.' are rejected at make and execute", () => {
expect(() => CodeMode.make({ tools: [echo("issues.list", "Bad", "bad")] })).toThrow("cannot contain '.'")
expect(() =>
CodeMode.execute({
tools: [Namespace.make({ name: "api.admin", tools: [echo("read", "Read", "read")] })],
code: "return 1",
}),
).toThrow("cannot contain '.'")
})
})
describe("canonical path collisions", () => {
test("the last tool supplied for a canonical path wins", async () => {
const runtime = CodeMode.make({
tools: { "issues.list": echo("First", "first"), issues: { list: echo("Second", "second") } },
})
expect(await value(runtime, `return await tools.issues.list({})`)).toBe("second")
expect(runtime.catalog()).toHaveLength(1)
expect(runtime.catalog()[0]?.description).toBe("Second")
describe("duplicate names", () => {
test("duplicate tools at the same level are rejected", () => {
expect(() =>
CodeMode.make({
tools: [echo("list", "First", "first"), echo("list", "Second", "second")],
}),
).toThrow("Duplicate tool path 'list'")
})
test("overriding one path keeps sibling tools from both shapes", async () => {
const runtime = CodeMode.make({
tools: {
"issues.list": echo("First list", "first"),
issues: { list: echo("Second list", "second"), get: echo("Get issue", "got") },
"issues.close": echo("Close issue", "closed"),
},
})
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.close", "issues.get", "issues.list"])
expect(await value(runtime, `return await tools.issues.list({})`)).toBe("second")
expect(await value(runtime, `return await tools.issues.get({})`)).toBe("got")
expect(await value(runtime, `return await tools.issues.close({})`)).toBe("closed")
test("a tool and namespace cannot share a name", () => {
expect(() =>
CodeMode.make({
tools: [
echo("issues", "All issues", "all"),
Namespace.make({ name: "issues", tools: [echo("list", "List", "list")] }),
],
}),
).toThrow("Duplicate tool path 'issues'")
})
})
+38 -10
View File
@@ -1,6 +1,6 @@
export * as CodeModeTool from "./tool.js"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import { CodeMode, Namespace, Tool, toolError } from "@opencode-ai/codemode"
import type { Content, Context, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool"
import { Effect, Ref, Schema, Semaphore } from "effect"
import { definition, normalizedName } from "../tool/runtime.js"
@@ -148,18 +148,46 @@ function runtime(
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
) {
const tools: Record<string, Tool.Tool<never>> = {}
const root: Branch = { tools: [], namespaces: new Map() }
for (const [name, registration] of registrations) {
const child = definition(registration)
const path = qualifiedName(registration)
tools[path] = Tool.make({
description: child.description,
input: child.inputSchema,
output: child.outputSchema ?? Schema.NullOr(Schema.String),
execute: (input) => executeTool(name, registration, input),
})
const namespace = registration.options?.namespace
addPath(
root,
namespace === undefined ? [] : namespace.split("."),
Tool.make({
name: normalizedName(registration),
description: child.description,
input: child.inputSchema,
output: child.outputSchema ?? Schema.NullOr(Schema.String),
execute: (input) => executeTool(name, registration, input),
}),
)
}
return CodeMode.make<typeof tools>({ tools, ...hooks })
return CodeMode.make({ tools: toCatalog(root), ...hooks })
}
type Branch = {
tools: Array<Tool.Tool<never>>
namespaces: Map<string, Branch>
}
function addPath(branch: Branch, segments: ReadonlyArray<string>, tool: Tool.Tool<never>) {
const head = segments[0]
if (head === undefined) {
branch.tools.push(tool)
return
}
const child = branch.namespaces.get(head) ?? { tools: [], namespaces: new Map() }
branch.namespaces.set(head, child)
addPath(child, segments.slice(1), tool)
}
function toCatalog(branch: Branch): Array<Tool.Tool<never> | Namespace.Namespace<never>> {
return [
...branch.tools,
...Array.from(branch.namespaces, ([name, child]) => Namespace.make({ name, tools: toCatalog(child) })),
]
}
function qualifiedName(registration: Info) {
+5 -21
View File
@@ -110,15 +110,7 @@ test("foreign typed failures settle as Tool.Error at the untrusted boundary", as
expect(error.message).toBe("transport died")
})
test("execute supports callable namespace tools", async () => {
const callable: Info = {
name: "admin",
description: "Administer Slack",
input: Schema.Struct({}),
output: Schema.String,
options: { namespace: "slack" },
execute: () => Effect.succeed({ output: "admin" }),
}
test("execute supports nested namespace tools", async () => {
const child: Info = {
name: "create",
description: "Create a Slack resource",
@@ -127,21 +119,13 @@ test("execute supports callable namespace tools", async () => {
options: { namespace: "slack.admin" },
execute: () => Effect.succeed({ output: "created" }),
}
const codeMode = createCodeMode(
new Map([
["slack_admin", callable],
["slack_admin_create", child],
]),
)
const codeMode = createCodeMode(new Map([["slack_admin_create", child]]))
const result = await Effect.runPromise(
codeMode.execute({ code: "return [await tools.slack.admin({}), await tools.slack.admin.create({})]" }, context),
codeMode.execute({ code: "return await tools.slack.admin.create({})" }, context),
)
expect(result.metadata).toEqual({
toolCalls: [
{ tool: "slack.admin", status: "completed" },
{ tool: "slack.admin.create", status: "completed" },
],
toolCalls: [{ tool: "slack.admin.create", status: "completed" }],
})
expect(result.content).toEqual([{ type: "text", text: '[\n "admin",\n "created"\n]' }])
expect(result.content).toEqual([{ type: "text", text: "created" }])
})