mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 16:24:38 +00:00
feat(core): register tool namespaces
This commit is contained in:
parent
d04257eeb4
commit
ec717a2bab
13 changed files with 116 additions and 20 deletions
|
|
@ -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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue