Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,18 +92,51 @@ export namespace ToolRegistry {
Effect.fn("ToolRegistry.state")(function* (ctx) {
const custom: Tool.Def[] = []

// custom tools can load a different zod instance whose per-instance
// .describe()/.meta() registry won't survive z.toJSONSchema(). walk
// schemas once and copy metadata into this runtime's registry.
function rehydrateZodMeta(value: unknown, seen = new WeakSet<object>()): void {
if (!value || typeof value !== "object" || seen.has(value)) return
seen.add(value)

if ("_zod" in value) {
const schema = value as z.ZodType
const metaFn = Reflect.get(schema, "meta")
const meta = metaFn instanceof Function ? Reflect.apply(metaFn, schema, []) : undefined
const base = {} as Record<string, unknown>
if (meta && typeof meta === "object" && !("_zod" in meta))
Object.assign(base, meta as Record<string, unknown>)
const description = Reflect.get(schema, "description")
if (typeof description === "string" && base.description === undefined) base.description = description
if (Object.keys(base).length > 0) z.globalRegistry.add(schema, base)
rehydrateZodMeta(Reflect.get(Reflect.get(schema, "_zod") as object, "def"), seen)
return
}

const items = Array.isArray(value) ? value : Object.values(value as Record<string, unknown>)
for (const item of items) rehydrateZodMeta(item, seen)
}

function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
for (const value of Object.values(def.args)) rehydrateZodMeta(value)
const parameters = z.object(def.args)
return {
id,
parameters: z.object(def.args),
parameters,
description: def.description,
execute: async (args, toolCtx) => {
const validated = parameters.safeParse(args)
if (!validated.success)
throw new Error(
`The ${id} tool was called with invalid arguments: ${validated.error}.\nPlease rewrite the input so it satisfies the expected schema.`,
)

const pluginCtx: PluginToolContext = {
...toolCtx,
directory: ctx.directory,
worktree: ctx.worktree,
}
const result = await def.execute(args as any, pluginCtx)
const result = await def.execute(validated.data as any, pluginCtx)
const out = await Truncate.output(result, {}, await Agent.get(toolCtx.agent))
return {
title: "",
Expand Down
50 changes: 50 additions & 0 deletions packages/opencode/test/tool/registry.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import z from "zod"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { ToolRegistry } from "../../src/tool/registry"
Expand Down Expand Up @@ -154,4 +155,53 @@ describe("tool.registry", () => {
},
})
})

test("preserves described arg metadata for plugin tools", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })

const toolsDir = path.join(opencodeDir, "tools")
await fs.mkdir(toolsDir, { recursive: true })

await Bun.write(
path.join(toolsDir, "search.ts"),
[
"import { tool } from '@opencode-ai/plugin'",
"",
"export default tool({",
" description: 'search custom docs',",
" args: {",
" query: tool.schema.string().describe('query to search for'),",
" type: tool.schema",
" .union([tool.schema.literal('regex'), tool.schema.literal('text')])",
" .describe('regex or text mode'),",
" },",
" async execute() {",
" return 'ok'",
" },",
"})",
"",
].join("\n"),
)
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const tools = await ToolRegistry.tools({ providerID: "openai", modelID: "gpt-5" })
const search = tools.find((item) => item.id === "search")
expect(search).toBeDefined()

const schema = z.toJSONSchema(search!.parameters)
const query = schema.properties?.query as { description?: string }
const type = schema.properties?.type as { description?: string }

expect(query.description).toBe("query to search for")
expect(type.description).toBe("regex or text mode")
},
})
})
})
Loading