mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 18:26:09 +00:00
feat(core): add tool namespaces (#37529)
This commit is contained in:
@@ -17,7 +17,7 @@ type Summary = typeof Summary.Type
|
||||
const entries = (servers: ReadonlyArray<Summary>) =>
|
||||
servers.flatMap((server) => [
|
||||
` <server name="${server.server}">`,
|
||||
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
|
||||
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.namespace(server.server))}]\`.`,
|
||||
...server.instructions.split("\n").map((line) => ` ${line}`),
|
||||
" </server>",
|
||||
])
|
||||
|
||||
@@ -29,7 +29,7 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
|
||||
## Registration
|
||||
|
||||
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Registrations may provide a
|
||||
group, which flattens direct model names to `<group>_<tool>`, and default into CodeMode (`codemode` defaults true;
|
||||
namespace, which flattens direct model names to `<namespace>_<tool>`, and default into CodeMode (`codemode` defaults true;
|
||||
`codemode: false` keeps the tool on the provider's native tool list).
|
||||
|
||||
Registrations are scoped:
|
||||
|
||||
@@ -39,7 +39,7 @@ type CollectedFiles = {
|
||||
interface Registration {
|
||||
readonly tool: AnyTool
|
||||
readonly name: string
|
||||
readonly group?: string
|
||||
readonly namespace?: string
|
||||
}
|
||||
|
||||
export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
@@ -47,7 +47,7 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||
hooks?: CodeMode.ToolCallHooks,
|
||||
) => {
|
||||
const tools: Record<string, Tool.Definition<never> | Record<string, Tool.Definition<never>>> = {}
|
||||
const tools: Record<string, Tool.Definition<never>> = {}
|
||||
for (const [name, registration] of registrations) {
|
||||
const child = definition(name, registration.tool)
|
||||
const value = Tool.make({
|
||||
@@ -56,24 +56,8 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
output: child.outputSchema,
|
||||
run: (input) => invoke(name, registration, input),
|
||||
})
|
||||
if (registration.group === undefined) {
|
||||
const path = registration.name
|
||||
if (Object.hasOwn(tools, path)) throw new TypeError(`CodeMode tool namespace conflict: ${path}`)
|
||||
tools[path] = value
|
||||
continue
|
||||
}
|
||||
const path = registration.name
|
||||
const namespace = registration.group
|
||||
const group = tools[namespace]
|
||||
if (group && Tool.isDefinition(group)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}`)
|
||||
if (group) {
|
||||
if (Object.hasOwn(group, path)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}.${path}`)
|
||||
group[path] = value
|
||||
continue
|
||||
}
|
||||
const entries: Record<string, Tool.Definition<never>> = {}
|
||||
entries[path] = value
|
||||
tools[namespace] = entries
|
||||
const path = registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
|
||||
tools[path] = value
|
||||
}
|
||||
return CodeMode.make<typeof tools>({ tools, ...hooks })
|
||||
}
|
||||
|
||||
@@ -13,10 +13,11 @@ import { Tools } from "./tools"
|
||||
import { ToolRegistry } from "./registry"
|
||||
|
||||
/**
|
||||
* Registry group and permission action names for MCP tools.
|
||||
* Registry namespace and permission action names for MCP tools.
|
||||
*/
|
||||
export const group = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
export const name = (server: string, tool: string) => `${group(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
|
||||
export const namespace = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
export const name = (server: string, tool: string) =>
|
||||
`${namespace(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
@@ -107,7 +108,7 @@ export const layer = Layer.effectDiscard(
|
||||
const next = yield* Scope.fork(scope)
|
||||
yield* Effect.forEach(
|
||||
groups,
|
||||
([group, record]) => tools.register(record, { group }),
|
||||
([server, record]) => tools.register(record, { namespace: namespace(server) }),
|
||||
{
|
||||
discard: true,
|
||||
},
|
||||
|
||||
@@ -10,7 +10,15 @@ import { SessionSchema } from "../session/schema"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { Wildcard } from "../util/wildcard"
|
||||
import { ExecuteTool } from "./execute"
|
||||
import { definition, permission, registrationEntries, RegistrationError, settle, type AnyTool } from "./tool"
|
||||
import {
|
||||
definition,
|
||||
permission,
|
||||
registrationEntries,
|
||||
RegistrationError,
|
||||
settle,
|
||||
validateNamespace,
|
||||
type AnyTool,
|
||||
} from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolHooks } from "./hooks"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
@@ -95,7 +103,7 @@ const registryLayer = Layer.effect(
|
||||
type Registration = {
|
||||
readonly tool: AnyTool
|
||||
readonly name: string
|
||||
readonly group?: string
|
||||
readonly namespace?: string
|
||||
readonly codemode: boolean
|
||||
}
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||
@@ -186,7 +194,8 @@ const registryLayer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
|
||||
const entries = registrationEntries(tools, options?.group)
|
||||
if (options?.namespace !== undefined) yield* validateNamespace(options.namespace)
|
||||
const entries = registrationEntries(tools, options?.namespace)
|
||||
if (entries.length === 0) return
|
||||
const codemode = options?.codemode ?? true
|
||||
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
|
||||
@@ -205,7 +214,7 @@ const registryLayer = Layer.effect(
|
||||
registration: {
|
||||
tool: entry.tool,
|
||||
name: entry.name,
|
||||
group: entry.group,
|
||||
namespace: entry.namespace,
|
||||
codemode,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -268,6 +268,7 @@ describe("MCP errors", () => {
|
||||
})
|
||||
|
||||
test("MCP tool names match V1 sanitization", () => {
|
||||
expect(McpTool.namespace("context 7")).toBe("context_7")
|
||||
expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
|
||||
})
|
||||
|
||||
|
||||
@@ -274,7 +274,7 @@ describe("PluginV2", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("groups tool names and routes codemode registrations through execute", () =>
|
||||
it.effect("namespaces tool names and routes codemode registrations through execute", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
@@ -291,8 +291,8 @@ describe("PluginV2", () => {
|
||||
ctx.tool
|
||||
.transform((draft) => {
|
||||
draft.add("plain", tool("Plain"), { codemode: false })
|
||||
draft.add("look/up", tool("Lookup"), { group: "context 7", codemode: false })
|
||||
draft.add("search", tool("Search"), { group: "context 7" })
|
||||
draft.add("look/up", tool("Lookup"), { namespace: "context7", codemode: false })
|
||||
draft.add("search", tool("Search"), { namespace: "context7" })
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
})
|
||||
@@ -301,7 +301,7 @@ describe("PluginV2", () => {
|
||||
|
||||
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toEqual([
|
||||
"plain",
|
||||
"context_7_look_up",
|
||||
"context7_look_up",
|
||||
"execute",
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -84,6 +84,17 @@ const constant = (text: string) =>
|
||||
})
|
||||
|
||||
describe("ToolRegistry", () => {
|
||||
it.effect("rejects invalid dotted namespaces", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const error = yield* service.register({ echo: make() }, { namespace: "slack..admin" }).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Tool.RegistrationError)
|
||||
expect(error.message).toBe('Invalid tool namespace: "slack..admin"')
|
||||
expect((yield* service.materialize()).definitions).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
|
||||
@@ -46,3 +46,50 @@ test("execute preserves successful results with visible unhandled rejections", a
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("execute supports callable namespace tools", async () => {
|
||||
const callable = Tool.make({
|
||||
description: "Administer Slack",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("admin"),
|
||||
})
|
||||
const child = Tool.make({
|
||||
description: "Create a Slack resource",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("created"),
|
||||
})
|
||||
const execute = ExecuteTool.create(
|
||||
new Map([
|
||||
["slack_admin", { tool: callable, name: "admin", namespace: "slack" }],
|
||||
["slack_admin_create", { tool: child, name: "create", namespace: "slack.admin" }],
|
||||
]),
|
||||
)
|
||||
const result = await Effect.runPromise(
|
||||
Tool.settle(
|
||||
execute,
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_execute",
|
||||
name: "execute",
|
||||
input: { code: "return [await tools.slack.admin({}), await tools.slack.admin.create({})]" },
|
||||
},
|
||||
{
|
||||
sessionID: Session.ID.make("ses_execute"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_execute"),
|
||||
callID: "call_execute",
|
||||
progress: () => Effect.void,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
expect(result.structured).toEqual({
|
||||
toolCalls: [
|
||||
{ tool: "slack.admin", status: "completed" },
|
||||
{ tool: "slack.admin.create", status: "completed" },
|
||||
],
|
||||
})
|
||||
expect(result.content).toEqual([{ type: "text", text: '[\n "admin",\n "created"\n]' }])
|
||||
})
|
||||
|
||||
@@ -150,18 +150,24 @@ export const validateName = (name: string) =>
|
||||
? Effect.void
|
||||
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
|
||||
|
||||
export const registrationEntries = (tools: Readonly<Record<string, AnyTool>>, group?: string) =>
|
||||
export const registrationEntries = (tools: Readonly<Record<string, AnyTool>>, namespace?: string) =>
|
||||
Object.entries(tools).map(([name, tool]) => {
|
||||
const normalized = name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
const parent = group?.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
return {
|
||||
key: parent === undefined ? normalized : `${parent}_${normalized}`,
|
||||
key: namespace === undefined ? normalized : `${namespace.replaceAll(".", "_")}_${normalized}`,
|
||||
name: normalized,
|
||||
group: parent,
|
||||
namespace,
|
||||
tool,
|
||||
}
|
||||
})
|
||||
|
||||
export const validateNamespace = (namespace: string) =>
|
||||
namespace.split(".").every((segment) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(segment))
|
||||
? Effect.void
|
||||
: Effect.fail(
|
||||
new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` }),
|
||||
)
|
||||
|
||||
export const withPermission = <T extends AnyTool>(
|
||||
tool: T,
|
||||
permission: string,
|
||||
@@ -288,7 +294,7 @@ export interface ToolExecuteAfterEvent {
|
||||
}
|
||||
|
||||
export interface RegisterOptions {
|
||||
readonly group?: string
|
||||
readonly namespace?: string
|
||||
/** Defaults to true. False exposes the tool directly to the provider. */
|
||||
readonly codemode?: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user