fix(tool): preserve custom tool arg descriptions (#27750)

Co-authored-by: khimaros <231498+khimaros@users.noreply.github.com>
This commit is contained in:
Aiden Cline
2026-05-15 10:11:01 -05:00
committed by GitHub
parent eb630075c3
commit ef7d801271
3 changed files with 81 additions and 3 deletions

View File

@@ -145,7 +145,14 @@ export const layer: Layer.Layer<
const entries = Object.entries(def.args)
const allZod = entries.every((entry) => isZodType(entry[1]))
const zodParams = allZod ? z.object(def.args) : undefined
const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries)
// Newer @opencode-ai/plugin versions precompute JSON Schema with the
// Zod instance that owns arg metadata. Fall back for older/manual
// custom tools that only expose raw Zod args.
const jsonSchema = zodParams
? isJsonSchemaDefinition(def.jsonSchema)
? (def.jsonSchema as JSONSchema7)
: zodJsonSchema(zodParams)
: legacyJsonSchema(entries)
const parameters = zodParams
? Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success)
: Schema.Unknown

View File

@@ -1,7 +1,7 @@
import { afterEach, describe, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { pathToFileURL } from "url"
import { fileURLToPath, pathToFileURL } from "url"
import { Effect, Layer, Result, Schema } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ToolRegistry } from "@/tool/registry"
@@ -263,6 +263,71 @@ describe("tool.registry", () => {
}),
)
it.instance("preserves Zod arg descriptions from config-scoped plugin packages", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode")
const customTools = path.join(opencode, "tools")
const plugin = path.join(opencode, "node_modules", "@opencode-ai", "plugin")
yield* Effect.promise(() => fs.mkdir(path.join(plugin, "dist"), { recursive: true }))
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
yield* Effect.promise(() =>
fs.cp(path.dirname(fileURLToPath(import.meta.resolve("zod"))), path.join(opencode, "node_modules", "zod"), {
dereference: true,
recursive: true,
}),
)
yield* Effect.promise(() =>
Bun.write(
path.join(plugin, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }),
),
)
yield* Effect.promise(() =>
Bun.write(
path.join(plugin, "dist", "index.js"),
[
"import { z } from 'zod'",
"export function tool(input) {",
" return { ...input, jsonSchema: z.toJSONSchema(z.object(input.args), { target: 'draft-7', io: 'input' }) }",
"}",
"tool.schema = z",
"",
].join("\n"),
),
)
yield* Effect.promise(() =>
Bun.write(
path.join(customTools, "addition.ts"),
[
'import { tool } from "@opencode-ai/plugin"',
"export default tool({",
" description: 'Use this tool to add two numbers and return their sum.',",
" args: {",
" left: tool.schema.number().describe('The first number to add'),",
" right: tool.schema.number().describe('The second number to add'),",
" },",
" execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,",
"})",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const loaded = (yield* registry.all()).find((tool) => tool.id === "addition")
if (!loaded) throw new Error("custom addition tool was not loaded")
expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
properties: {
left: { type: "number", description: "The first number to add" },
right: { type: "number", description: "The second number to add" },
},
})
}),
20_000,
)
it.instance("preserves attachments from structured custom tool results", () =>
Effect.gen(function* () {
const test = yield* TestInstance

View File

@@ -48,7 +48,13 @@ export function tool<Args extends z.ZodRawShape>(input: {
args: Args
execute(args: z.infer<z.ZodObject<Args>>, context: ToolContext): Promise<ToolResult>
}) {
return input
return {
...input,
// Generate JSON Schema here with the same Zod instance that created
// `tool.schema` args. Zod metadata such as `.describe()` is stored in a
// module-local registry, so converting later from opencode can lose it.
jsonSchema: z.toJSONSchema(z.object(input.args), { target: "draft-7", io: "input" }),
}
}
tool.schema = z