Skip to content
Closed
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
70 changes: 68 additions & 2 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>()
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<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)
}

// 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<string, unknown>)) 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: "",
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 { 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 @@ -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")
},
})
})
})
2 changes: 2 additions & 0 deletions packages/plugin/src/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export function tool<Args extends z.ZodRawShape>(input: {
args: Args
execute(args: z.infer<z.ZodObject<Args>>, context: ToolContext): Promise<string>
}) {
// 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
Expand Down
Loading