mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 04:54:33 +00:00
feat(core): describe tool namespaces
This commit is contained in:
parent
d04257eeb4
commit
a287f6bf95
7 changed files with 85 additions and 17 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 &
|
||||
(
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue