mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-22 11:25:15 +00:00
fix(tool): support zod 3 plugin schemas
This commit is contained in:
parent
463af5e079
commit
7fa8ad4ec2
2 changed files with 106 additions and 9 deletions
|
|
@ -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<string, AnySchema>) : undefined
|
||||
const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries)
|
||||
const parameters = zodParams
|
||||
? Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success)
|
||||
? Schema.declare<unknown>((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<Record<string, unknown>>()
|
||||
const seen = new WeakSet<object>()
|
||||
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 = {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
() =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue