diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index c6d7fbc1e4b2..9bc8e2fce156 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -62,18 +62,84 @@ export namespace ToolRegistry { }) function fromPlugin(id: string, def: ToolDefinition): Tool.Info { + // Why this exists: + // Custom tools are loaded from `.opencode/node_modules`, which can resolve a + // different Zod module instance than opencode core. In Zod v4, field metadata + // from `.describe()` / `.meta()` is stored in a per-instance global registry. + // Without copying that metadata into this runtime's registry, `z.toJSONSchema` + // drops argument descriptions/titles/examples, so the model receives weaker + // tool parameter guidance even though the tool schema itself is valid. + + // Walk nested Zod internals once and copy metadata into this runtime's + // registry so JSON Schema export keeps `.describe()` / `.meta()` fields + // even when tool schemas were created by another Zod instance. + const seen = new WeakSet() + const rehydrate = (value: unknown): void => { + if (!value || typeof value !== "object") return + if (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) + } + + // Continue into child schema defs (object shape, union options, array items, etc.) + // so nested arg metadata is also preserved. + const def = Reflect.get(Reflect.get(schema, "_zod") as object, "def") + rehydrate(def) + return + } + + if (Array.isArray(value)) { + for (const item of value) rehydrate(item) + return + } + + for (const item of Object.values(value as Record)) rehydrate(item) + } + + // Zod v4 metadata (`.describe()`, `.meta()`) lives in a module-local + // registry. Custom tools can be loaded from a different Zod instance, so + // copy metadata for all arg schemas into this instance before export. + for (const value of Object.values(def.args)) rehydrate(value) + + const parameters = z.object(def.args) return { id, init: async (initCtx) => ({ - parameters: z.object(def.args), + parameters, description: def.description, execute: async (args, ctx) => { + // Mirror built-in tool behavior: always validate tool input at runtime + // before execute, even if the provider/model-side tool schema checks pass. + const validated = parameters.safeParse(args) + if (!validated.success) + throw new Error( + `The ${id} tool was called with invalid arguments: ${validated.error}. +Please rewrite the input so it satisfies the expected schema.`, + ) + const pluginCtx = { ...ctx, directory: Instance.directory, worktree: Instance.worktree, } as unknown as PluginToolContext - 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, {}, initCtx?.agent) return { title: "", diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 706a9e12caf9..bbbd3c2ad868 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -1,6 +1,7 @@ import { 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" @@ -119,4 +120,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") + }, + }) + }) }) diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts index 23aa512d9a43..6fc56a922a19 100644 --- a/packages/plugin/src/tool.ts +++ b/packages/plugin/src/tool.ts @@ -31,6 +31,8 @@ export function tool(input: { args: Args execute(args: z.infer>, context: ToolContext): Promise }) { + // Keep runtime shape minimal: custom tool loaders normalize/validate `args`. + // This helper primarily exists for TypeScript inference and shared `tool.schema`. return input } tool.schema = z