feat(core): describe tool namespaces

This commit is contained in:
Aiden Cline 2026-08-31 16:46:14 -05:00
parent d04257eeb4
commit a287f6bf95
7 changed files with 85 additions and 17 deletions

View file

@ -1,9 +1,9 @@
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"
import { definition, namespace, normalizedName } from "../tool/runtime.js"
const ExecuteFile = Schema.Struct({
data: Schema.String,
@ -148,7 +148,8 @@ function runtime(
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>> = {}
const namespaces = new Map<string, string>()
for (const [name, registration] of registrations) {
const child = definition(registration)
const path = qualifiedName(registration)
@ -158,14 +159,19 @@ function runtime(
output: child.outputSchema ?? Schema.NullOr(Schema.String),
execute: (input) => executeTool(name, registration, input),
})
const group = namespace(registration)
if (group?.description !== undefined) namespaces.set(group.name, group.description)
}
for (const [name, description] of namespaces)
if (!Object.hasOwn(tools, name)) tools[name] = Namespace.make({ description, tools: {} })
return CodeMode.make<typeof tools>({ tools, ...hooks })
}
function qualifiedName(registration: Info) {
const normalized = normalizedName(registration)
if (registration.options?.namespace === undefined) return normalized
return `${registration.options.namespace}.${normalized}`
const group = namespace(registration)
if (group === undefined) return normalized
return `${group.name}.${normalized}`
}
// Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact.

View file

@ -15,7 +15,7 @@ import { PluginHooks } from "./plugin/hooks.js"
import { SessionMessage } from "./session/message.js"
import { SessionSchema } from "./session/schema.js"
import { State } from "./state.js"
import { definition, effectiveName, execute, normalizedName, normalizeContent } from "./tool/runtime.js"
import { definition, effectiveName, execute, namespace, normalizedName, normalizeContent } from "./tool/runtime.js"
import { Wildcard } from "./util/wildcard.js"
export class RegistrationError extends Schema.TaggedError<RegistrationError>()("Tool.RegistrationError", {
@ -191,7 +191,7 @@ const layer = Layer.effect(
({ tool, error }) =>
Effect.logError("Skipping invalid tool registration", {
name: tool.name,
namespace: tool.options?.namespace,
namespace: namespace(tool)?.name,
error: error.message,
}),
{ discard: true },
@ -268,9 +268,12 @@ 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)}` })
const group = namespace(tool)
if (group !== undefined && !group.name.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment)))
return new RegistrationError({
name: group.name,
message: `Invalid tool namespace: ${JSON.stringify(group.name)}`,
})
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)

View file

@ -272,7 +272,14 @@ const stringify = (value: unknown) => {
export const normalizedName = (tool: Tool.Info) => tool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
export const effectiveName = (tool: Tool.Info) =>
tool.options?.namespace === undefined
? normalizedName(tool)
: `${tool.options.namespace.replaceAll(".", "_")}_${normalizedName(tool)}`
export const namespace = (tool: Tool.Info) => {
const value = tool.options?.namespace
if (value === undefined) return
return typeof value === "string" ? { name: value } : value
}
export const effectiveName = (tool: Tool.Info) => {
const group = namespace(tool)
if (group === undefined) return normalizedName(tool)
return `${group.name.replaceAll(".", "_")}_${normalizedName(tool)}`
}

View file

@ -536,6 +536,7 @@ describe("Tool", () => {
yield* service.transform((draft) => {
draft.add({ ...make(), name: "first", options: { codemode: false } })
draft.add({ ...make(), name: "second", options: { namespace: "invalid..namespace", codemode: false } })
draft.add({ ...make(), name: "third", options: { namespace: { name: "also..invalid" } } })
draft.add({ ...make(), name: "second", options: { namespace: "invalid__namespace" } })
})
@ -545,6 +546,40 @@ describe("Tool", () => {
}),
)
it.effect("supports described namespaces without changing string namespace behavior", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* service.transform((draft) => {
draft.add({ ...make(), name: "plain", options: { namespace: "legacy" } })
draft.add({
...make(),
name: "direct",
options: { namespace: { name: "registry", description: "Package tools" }, codemode: false },
})
draft.add({
...make(),
name: "search",
description: "Search packages",
options: { namespace: { name: "registry", description: "Package publishing and discovery" } },
})
})
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

@ -20,10 +20,15 @@ export interface Context {
}
interface BaseOptions {
readonly namespace?: string
readonly namespace?: string | Namespace
readonly permission?: string
}
export interface Namespace {
readonly name: string
readonly description?: string
}
export type Options = BaseOptions &
(
| {

View file

@ -843,7 +843,10 @@ effect: (ctx) =>
description: "Create a greeting",
input: Schema.Struct({ name: Schema.String }),
output: Schema.Struct({ greeting: Schema.String }),
options: { namespace: "acme", codemode: true },
options: {
namespace: { name: "acme", description: "Customer account tools" },
codemode: true,
},
execute: ({ name }, context) =>
Effect.gen(function* () {
yield* context.progress({ status: "greeting" })
@ -854,6 +857,9 @@ effect: (ctx) =>
}),
```
Use a namespace object to add model-visible search context for its CodeMode tools. A string such as
`namespace: "acme"` remains supported when no namespace 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.

View file

@ -808,7 +808,10 @@ const registration = await ctx.tool.transform((draft) => {
required: ["name"],
additionalProperties: false,
},
options: { namespace: "acme", codemode: true },
options: {
namespace: { name: "acme", description: "Customer account tools" },
codemode: true,
},
execute: async (input, tool) => {
await tool.progress({ status: "greeting" })
return { content: `Hello ${(input as { name: string }).name}!` }
@ -824,6 +827,9 @@ changing their order; it does not rerun plugin setup.
await ctx.tool.reload()
```
Use a namespace object to add model-visible search context for its CodeMode tools. A string such as
`namespace: "acme"` remains supported when no namespace description is needed.
Use `list()` and `get()` to inspect tools currently in the draft. Tools have an `id` containing their effective name,
and `get()` returns `undefined` when that ID is not present. Use `update` and `remove` with the effective tool name,
including its namespace (`acme_greeting` above). Dots in