diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 6ef6d39a65..67df37c34a 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -55,6 +55,13 @@ import { Reference } from "@/reference/reference" import { BackgroundJob } from "@/background/job" import { SessionStatus } from "@/session/status" import { RuntimeFlags } from "@/effect/runtime-flags" +import { + objectFromShape, + safeParse, + type AnyObjectSchema, + type AnySchema, +} from "@modelcontextprotocol/sdk/server/zod-compat.js" +import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js" const log = Log.create({ service: "tool.registry" }) @@ -150,10 +157,10 @@ export const layer: Layer.Layer< const args = def.args ?? {} const entries = Object.entries(args) const allZod = entries.every((entry) => isZodType(entry[1])) - const zodParams = allZod ? z.object(args) : undefined + const zodParams = allZod ? objectFromShape(args as Record) : undefined const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries) const parameters = zodParams - ? Schema.declare((u): u is unknown => zodParams.safeParse(u).success) + ? Schema.declare((u): u is unknown => safeParse(zodParams, u).success) : Schema.Unknown return { id, @@ -402,7 +409,11 @@ export const defaultLayer = Layer.suspend(() => .pipe(Layer.provide(RuntimeFlags.defaultLayer)), ) -function isZodType(value: unknown): value is z.ZodType { +function isZodType(value: unknown): value is AnySchema { + return typeof value === "object" && value !== null && ("_zod" in value || "_def" in value) +} + +function isZod4Type(value: unknown): value is z.ZodType { return typeof value === "object" && value !== null && "_zod" in value } @@ -425,8 +436,12 @@ function legacyJsonSchema(entries: [string, unknown][]): JSONSchema7 { } } -function zodJsonSchema(schema: z.ZodType): JSONSchema7 { - const result = normalizeZodJsonSchema(z.toJSONSchema(schema, { io: "input", metadata: zodMetadataRegistry(schema) })) +function zodJsonSchema(schema: AnyObjectSchema): JSONSchema7 { + const result = normalizeZodJsonSchema( + isZod4Type(schema) + ? z.toJSONSchema(schema, { io: "input", metadata: zodMetadataRegistry(schema) }) + : toJsonSchemaCompat(schema, { pipeStrategy: "input" }), + ) if (!isJsonSchemaObject(result)) throw new Error("plugin tool Zod schema produced a non-object JSON Schema") const { $defs, ...rest } = result return ( @@ -434,7 +449,7 @@ function zodJsonSchema(schema: z.ZodType): JSONSchema7 { ) as JSONSchema7 } -function zodMetadataRegistry(schema: z.ZodType) { +function zodMetadataRegistry(schema: AnyObjectSchema) { const registry = z.registry>() const seen = new WeakSet() const collect = (value: unknown) => { @@ -442,7 +457,7 @@ function zodMetadataRegistry(schema: z.ZodType) { if (seen.has(value)) return seen.add(value) - if (isZodType(value)) { + if (isZod4Type(value)) { const metadata = typeof value.meta === "function" ? value.meta() : undefined const description = typeof value.description === "string" ? value.description : undefined const merged = { diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index d3549e66f3..aef5d2c49a 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -34,6 +34,7 @@ import { ProviderID, ModelID } from "@/provider/schema" import { ToolJsonSchema } from "@/tool/json-schema" import { MessageID, SessionID } from "@/session/schema" import { RuntimeFlags } from "@/effect/runtime-flags" +import z3 from "zod/v3" const node = CrossSpawnSpawner.defaultLayer const configLayer = TestConfig.layer({ @@ -80,7 +81,7 @@ const brokenPluginLayer = Layer.succeed( init: () => Effect.void, trigger: ((_name: unknown, _input: unknown, output: unknown) => Effect.succeed(output)) as Plugin.Interface["trigger"], - list: () => + list: (() => Effect.succeed([ { tool: { @@ -91,7 +92,41 @@ const brokenPluginLayer = Layer.succeed( }, }, }, - ]), + ])) as unknown as Plugin.Interface["list"], + }), +) + +const zod3PluginLayer = Layer.succeed( + Plugin.Service, + Plugin.Service.of({ + init: () => Effect.void, + trigger: ((_name: unknown, _input: unknown, output: unknown) => + Effect.succeed(output)) as Plugin.Interface["trigger"], + list: (() => + Effect.succeed([ + { + tool: { + ctx_batch_execute: { + description: "context-mode batch executor", + args: { + batch: z3 + .preprocess( + (value) => (typeof value === "string" ? JSON.parse(value) : value), + z3 + .array( + z3.object({ + command: z3.string().describe("Command to execute"), + }), + ) + .min(1), + ) + .describe("Commands to execute as a batch"), + }, + execute: async () => "ok", + }, + }, + }, + ])) as unknown as Plugin.Interface["list"], }), ) @@ -105,6 +140,7 @@ const background = testEffect( const withBrokenPlugin = testEffect( Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer), ) +const withZod3Plugin = testEffect(Layer.mergeAll(registryLayer({ plugin: zod3PluginLayer }), node, Agent.defaultLayer)) afterEach(async () => { await disposeAllInstances() @@ -349,6 +385,52 @@ describe("tool.registry", () => { }), ) + withZod3Plugin.instance("loads plugin tools with Zod 3 args as JSON Schema", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const loaded = (yield* registry.all()).find((tool) => tool.id === "ctx_batch_execute") + if (!loaded) throw new Error("ctx_batch_execute tool was not loaded") + + expect(loaded.jsonSchema).toMatchObject({ + type: "object", + properties: { + batch: { + type: "array", + description: "Commands to execute as a batch", + items: { + type: "object", + properties: { + command: { type: "string", description: "Command to execute" }, + }, + }, + minItems: 1, + }, + }, + required: ["batch"], + }) + expect(JSON.stringify(loaded.jsonSchema)).not.toContain("_def") + expect(JSON.stringify(loaded.jsonSchema)).not.toContain("_zod") + expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({ batch: [{ command: "pwd" }] }))).toBe( + true, + ) + expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false) + }), + ) + + withZod3Plugin.instance("validates plugin tools with Zod 3 preprocessors", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const loaded = (yield* registry.all()).find((tool) => tool.id === "ctx_batch_execute") + if (!loaded) throw new Error("ctx_batch_execute tool was not loaded") + + expect( + Result.isSuccess( + Schema.decodeUnknownResult(loaded.parameters)({ batch: JSON.stringify([{ command: "pwd" }]) }), + ), + ).toBe(true) + }), + ) + it.instance( "preserves Zod arg descriptions from older config-scoped plugin packages", () =>