From 22563bc5cb18bf3dffc801e0407d4f056be337f5 Mon Sep 17 00:00:00 2001 From: khimaros Date: Sun, 29 Mar 2026 08:41:24 -0700 Subject: [PATCH] fix(custom-tools): preserve arg descriptions and enforce validation rehydrate cross-instance zod metadata so .describe() survives JSON schema export, and validate tool input at runtime before execute. --- packages/opencode/src/tool/registry.ts | 37 ++++++++++++++- packages/opencode/test/tool/registry.test.ts | 50 ++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 800c45ced0c0..d8ca80adc414 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -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()): 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 + if (meta && typeof meta === "object" && !("_zod" in meta)) + Object.assign(base, meta as Record) + 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) + 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: "", diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index e3a274bb211a..c85cc0bdc254 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -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" @@ -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") + }, + }) + }) })