feat(core): register tool namespaces

This commit is contained in:
Aiden Cline 2026-08-31 16:54:07 -05:00
parent d04257eeb4
commit ec717a2bab
13 changed files with 116 additions and 20 deletions

View file

@ -1,7 +1,15 @@
export * as CodeModeTool from "./tool.js"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import type { Content, Context, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool"
import { CodeMode, Namespace, Tool, toolError } from "@opencode-ai/codemode"
import type {
Content,
Context,
Error,
Info,
Metadata,
Namespace as ToolNamespace,
Result,
} from "@opencode-ai/schema/tool"
import { Effect, Ref, Schema, Semaphore } from "effect"
import { definition, normalizedName } from "../tool/runtime.js"
@ -44,6 +52,7 @@ const description = [
export const create = (
registrations: ReadonlyMap<string, Info>,
executeTool: (name: string, tool: Info, input: unknown, context: Context) => Effect.Effect<Result, Error>,
namespaces: ReadonlyMap<string, ToolNamespace> = new Map(),
) => {
return {
name: "execute",
@ -62,6 +71,7 @@ export const create = (
)
const result = yield* runtime(
registrations,
namespaces,
(name, tool, input) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
@ -132,23 +142,27 @@ export const create = (
} satisfies Info
}
export const catalog = (registrations: ReadonlyMap<string, Info>) => {
export const catalog = (
registrations: ReadonlyMap<string, Info>,
namespaces: ReadonlyMap<string, ToolNamespace> = new Map(),
) => {
const pinned = new Set(
Array.from(registrations.values())
.filter((registration) => registration.options?.pinned === true)
.map(qualifiedName),
)
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable")))
return runtime(registrations, namespaces, () => Effect.fail(toolError("Execute context is unavailable")))
.catalog()
.map((entry) => ({ ...entry, pinned: pinned.has(entry.path) }))
}
function runtime(
registrations: ReadonlyMap<string, Info>,
namespaces: ReadonlyMap<string, ToolNamespace>,
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
) {
const tools: Record<string, Tool.Tool<never>> = {}
const tools: Record<string, Tool.Tool<never> | Namespace.Namespace<never>> = {}
for (const [name, registration] of registrations) {
const child = definition(registration)
const path = qualifiedName(registration)
@ -159,6 +173,9 @@ function runtime(
execute: (input) => executeTool(name, registration, input),
})
}
for (const namespace of namespaces.values())
if (!Object.hasOwn(tools, namespace.name))
tools[namespace.name] = Namespace.make({ description: namespace.description, tools: {} })
return CodeMode.make<typeof tools>({ tools, ...hooks })
}

View file

@ -1,6 +1,6 @@
export * as Tool from "./tool.js"
export { CallID, Content, Error, FileContent, TextContent } from "@opencode-ai/schema/tool"
export type { Context, Metadata, Options, Result } from "@opencode-ai/schema/tool"
export type { Context, Metadata, Namespace, Options, Result } from "@opencode-ai/schema/tool"
import { ToolDefinition, type ToolCall } from "@opencode-ai/ai"
import { Tool } from "@opencode-ai/schema/tool"
@ -26,6 +26,7 @@ export class RegistrationError extends Schema.TaggedError<RegistrationError>()("
export interface Draft {
readonly list: () => readonly (Tool.Info & { readonly id: string })[]
readonly get: (id: string) => (Tool.Info & { readonly id: string }) | undefined
readonly namespace: (namespace: Tool.Namespace) => void
readonly add: (tool: Tool.Info) => void
readonly update: (id: string, update: (tool: Types.Mutable<Tool.Info>) => void) => void
readonly remove: (id: string) => void
@ -33,7 +34,8 @@ export interface Draft {
type Data = {
tools: Map<string, Tool.Info & { readonly id: string }>
errors: { tool: Tool.Info; error: RegistrationError }[]
namespaces: Map<string, Tool.Namespace>
errors: { kind: "tool" | "namespace"; name: string; namespace?: string; error: RegistrationError }[]
}
export interface Interface extends State.Transformable<Draft> {
@ -151,15 +153,24 @@ const layer = Layer.effect(
name: "tool",
initial: () => ({
tools: new Map(),
namespaces: new Map(),
errors: [],
}),
draft: (draft) => ({
list: () => Array.from(draft.tools.values()),
get: (id) => draft.tools.get(id),
namespace: (namespace) => {
const error = namespaceError(namespace.name)
if (error) {
draft.errors.push({ kind: "namespace", name: namespace.name, namespace: namespace.name, error })
return
}
draft.namespaces.set(namespace.name, { ...namespace })
},
add: (tool) => {
const error = registrationError(tool)
if (error) {
draft.errors.push({ tool, error })
draft.errors.push({ kind: "tool", name: tool.name, namespace: tool.options?.namespace, error })
return
}
const id = effectiveName(tool)
@ -176,7 +187,7 @@ const layer = Layer.effect(
tool.options = { ...tool.options, namespace: current.options?.namespace }
const error = registrationError(tool)
if (error) {
draft.errors.push({ tool, error })
draft.errors.push({ kind: "tool", name: tool.name, namespace: tool.options?.namespace, error })
return
}
draft.tools.set(id, tool)
@ -188,10 +199,10 @@ const layer = Layer.effect(
finalize: () =>
Effect.forEach(
state.get().errors,
({ tool, error }) =>
Effect.logError("Skipping invalid tool registration", {
name: tool.name,
namespace: tool.options?.namespace,
({ kind, name, namespace, error }) =>
Effect.logError(`Skipping invalid ${kind} registration`, {
name,
namespace,
error: error.message,
}),
{ discard: true },
@ -213,13 +224,16 @@ const layer = Layer.effect(
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
const codemodeEnabled = !whollyDisabled("execute", rules)
const codemodeTool = codemodeEnabled
? CodeModeTool.create(codemode, (name, tool, input, context) =>
beforeExecute(name, input, context).pipe(
Effect.flatMap((event) => executeTool(tool, name, event.input, context)),
),
? CodeModeTool.create(
codemode,
(name, tool, input, context) =>
beforeExecute(name, input, context).pipe(
Effect.flatMap((event) => executeTool(tool, name, event.input, context)),
),
state.get().namespaces,
)
: undefined
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode, state.get().namespaces) : undefined
return {
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
definitions: [
@ -269,8 +283,10 @@ function schemaMakeError(error: unknown) {
function registrationError(tool: Tool.Info) {
const namespace = tool.options?.namespace
if (namespace !== undefined && !namespace.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment)))
return new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` })
if (namespace !== undefined) {
const error = namespaceError(namespace)
if (error) return error
}
const name = normalizedName(tool)
if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) return new RegistrationError({ name, message: `Invalid tool name: ${name}` })
const id = effectiveName(tool)
@ -284,6 +300,11 @@ function registrationError(tool: Tool.Info) {
return Result.isFailure(result) ? result.failure : undefined
}
function namespaceError(name: string) {
if (name.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment))) return
return new RegistrationError({ name, message: `Invalid tool namespace: ${JSON.stringify(name)}` })
}
export const node = makeLocationNode({
service: Service,
layer,

View file

@ -32,6 +32,8 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
Built-ins, plugins, and MCP install tools through `Tool.Service.transform`, adding complete tool objects to the draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
Namespace descriptions are registered once through `draft.namespace(...)`. Tool options continue to reference the namespace by string name; an unregistered namespace remains valid and simply has no namespace description.
The service uses shared `State` to replay synchronous transforms in registration order against a fresh draft. `Tool.Service.reload()` rebuilds from captured source data without changing registration precedence. Registrations are scoped and return a real, idempotent `dispose` Effect:
- The latest valid active registration for the same effective name wins.

View file

@ -534,6 +534,7 @@ describe("Tool", () => {
Effect.gen(function* () {
const service = yield* Tool.Service
yield* service.transform((draft) => {
draft.namespace({ name: "invalid..namespace", description: "Invalid" })
draft.add({ ...make(), name: "first", options: { codemode: false } })
draft.add({ ...make(), name: "second", options: { namespace: "invalid..namespace", codemode: false } })
draft.add({ ...make(), name: "second", options: { namespace: "invalid__namespace" } })
@ -545,6 +546,32 @@ describe("Tool", () => {
}),
)
it.effect("registers namespace descriptions separately from namespaced tools", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* service.transform((draft) => {
draft.namespace({ name: "registry", description: "Package publishing and discovery" })
draft.add({ ...make(), name: "plain", options: { namespace: "legacy" } })
draft.add({ ...make(), name: "direct", options: { namespace: "registry", codemode: false } })
draft.add({ ...make(), name: "search", description: "Search packages", options: { namespace: "registry" } })
})
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["registry_direct", "execute"])
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["legacy.plain", "registry.search"])
const result = yield* snapshot.execute({
...call("execute"),
call: {
type: "tool-call",
id: "namespace-search",
name: "execute",
input: { code: 'return search({ query: "publishing discovery" })' },
},
})
expect(result.output).toMatchObject({ output: expect.stringContaining("tools.registry.search") })
}),
)
it.effect("logs invalid tool definitions without dropping healthy tools", () => {
const output: unknown[] = []
const logger = Logger.map(Logger.formatStructured, (entry) => {

View file

@ -14,5 +14,6 @@ export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Rpc } from "@opencode-ai/schema/rpc"
export { Skill } from "@opencode-ai/schema/skill"
export { Tool } from "@opencode-ai/schema/tool"
export { Vcs } from "@opencode-ai/schema/vcs"
export { WebSearch } from "@opencode-ai/schema/websearch"

View file

@ -8,6 +8,7 @@ import type { Hooks, Transform } from "./registration.js"
export interface ToolDraft {
list(): readonly (Tool.Info & { readonly id: string })[]
get(id: string): (Tool.Info & { readonly id: string }) | undefined
namespace(namespace: Tool.Namespace): void
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
tool: Tool.Info<Input, Output>,
): void

View file

@ -465,6 +465,7 @@ export function fromPromise(plugin: Plugin) {
const tool = draft.get(id)
return tool ? { ...tool, execute: promiseExecutor(tool.execute) } : undefined
},
namespace: draft.namespace,
add: (tool: Info) =>
draft.add({
...tool,

View file

@ -15,5 +15,6 @@ export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Rpc } from "@opencode-ai/schema/rpc"
export { Skill } from "@opencode-ai/schema/skill"
export { Tool } from "@opencode-ai/schema/tool"
export { Vcs } from "@opencode-ai/schema/vcs"
export { WebSearch } from "@opencode-ai/schema/websearch"

View file

@ -25,6 +25,7 @@ export type Info<
interface ToolDraft {
list(): readonly (Info & { readonly id: string })[]
get(id: string): (Info & { readonly id: string }) | undefined
namespace(namespace: Tool.Namespace): void
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
tool: Info<Input, Output>,
): void

View file

@ -13,6 +13,7 @@ import { Provider } from "@opencode-ai/schema/provider"
import { Reference } from "@opencode-ai/schema/reference"
import { Rpc } from "@opencode-ai/schema/rpc"
import { Skill } from "@opencode-ai/schema/skill"
import { Tool } from "@opencode-ai/schema/tool"
import { Vcs } from "@opencode-ai/schema/vcs"
import { WebSearch } from "@opencode-ai/schema/websearch"
@ -39,6 +40,7 @@ test.each([
expect(entrypoint.Reference).toBe(Reference)
expect(entrypoint.Rpc).toBe(Rpc)
expect(entrypoint.Skill).toBe(Skill)
expect(entrypoint.Tool).toBe(Tool)
expect(entrypoint.Vcs).toBe(Vcs)
expect(entrypoint.WebSearch).toBe(WebSearch)
expect(Object.keys(entrypoint).sort()).toEqual([
@ -56,6 +58,7 @@ test.each([
"Reference",
"Rpc",
"Skill",
"Tool",
"Vcs",
"WebSearch",
])

View file

@ -19,6 +19,11 @@ export interface Context {
readonly progress: (update: Metadata) => Effect.Effect<void>
}
export interface Namespace {
readonly name: string
readonly description: string
}
interface BaseOptions {
readonly namespace?: string
readonly permission?: string

View file

@ -838,6 +838,10 @@ effect: (ctx) =>
Effect.gen(function* () {
const tool = ctx.tool
yield* tool.transform((draft) => {
draft.namespace({
name: "acme",
description: "Customer account tools",
})
draft.add({
name: "greeting",
description: "Create a greeting",
@ -854,6 +858,9 @@ effect: (ctx) =>
}),
```
Register a namespace once to add model-visible search context for every CodeMode tool assigned to it. Tools continue
to reference the namespace by its string name. Namespace registration is optional when no description is needed.
Call `yield* ctx.tool.reload()` after changing source data captured by the callback. Reload replays active transforms
without changing their order; it does not rerun the plugin effect.
@ -889,6 +896,7 @@ Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#s
interface ToolDraft {
list(): readonly (Tool.Info & { readonly id: string })[]
get(id: string): (Tool.Info & { readonly id: string }) | undefined
namespace(namespace: Tool.Namespace): void
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
tool: Tool.Info<Input, Output>,
): void

View file

@ -799,6 +799,10 @@ For the same effective tool name, a later valid registration overrides an earlie
```ts
const registration = await ctx.tool.transform((draft) => {
draft.namespace({
name: "acme",
description: "Customer account tools",
})
draft.add({
name: "greeting",
description: "Create a greeting",
@ -817,6 +821,9 @@ const registration = await ctx.tool.transform((draft) => {
})
```
Register a namespace once to add model-visible search context for every CodeMode tool assigned to it. Tools continue
to reference the namespace by its string name. Namespace registration is optional when no description is needed.
Call `reload()` after changing source data captured by the callback. Reload replays the active transforms without
changing their order; it does not rerun plugin setup.
@ -868,6 +875,7 @@ interface ToolContext {
interface ToolDraft {
list(): readonly (ToolInfo & { readonly id: string })[]
get(id: string): (ToolInfo & { readonly id: string }) | undefined
namespace(namespace: Tool.Namespace): void
add(tool: ToolInfo): void
update(id: string, update: (tool: Types.Mutable<ToolInfo>) => void): void
remove(id: string): void