From f327adb0f23907b0e68dfca71c7f470bdd209067 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:50:01 -0500 Subject: [PATCH] fix(core): support Zod tool schemas (#44861) --- packages/core/src/tool/runtime.ts | 21 +++++++--- .../test/session-runner-tool-registry.test.ts | 34 +++++++++++++++++ packages/core/test/tool-schema.test.ts | 38 +++++++++++++++++++ packages/schema/src/tool.ts | 7 +--- 4 files changed, 89 insertions(+), 11 deletions(-) diff --git a/packages/core/src/tool/runtime.ts b/packages/core/src/tool/runtime.ts index c8ae6d94819..20a4cfc75c4 100644 --- a/packages/core/src/tool/runtime.ts +++ b/packages/core/src/tool/runtime.ts @@ -2,6 +2,7 @@ import type { ToolDefinition } from "@opencode-ai/ai" import { Tool } from "@opencode-ai/schema/tool" import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec" import { Cache, Effect, JsonSchema, Schema, SchemaIssue, SchemaRepresentation } from "effect" +import { $ZodType, toJSONSchema } from "zod/v4/core" const formatEffectIssues = SchemaIssue.makeFormatterStandardSchemaV1() @@ -129,13 +130,15 @@ const encodeOutput = (schema: Tool.ValueSchema, value: unknown) => { ) } -const isStandardSchema = ( - schema: Tool.ValueSchema, -): schema is StandardSchemaV1 & StandardJSONSchemaV1 => +const isStandardSchema = (schema: Tool.ValueSchema): schema is StandardSchemaV1 => typeof schema === "object" && schema !== null && "~standard" in schema +const isStandardJSONSchema = ( + schema: StandardSchemaV1, +): schema is StandardSchemaV1 & StandardJSONSchemaV1 => "jsonSchema" in schema["~standard"] + const validateStandard = ( - schema: StandardSchemaV1 & StandardJSONSchemaV1, + schema: StandardSchemaV1, value: unknown, ): Effect.Effect> => Effect.gen(function* () { @@ -150,15 +153,21 @@ const validateStandard = ( const inputJsonSchema = (schema: Tool.ValueSchema): JsonSchema.JsonSchema => { if (schema === undefined || schema === null) return {} - if (isStandardSchema(schema)) return schema["~standard"].jsonSchema.input({ target: "draft-2020-12" }) + if (isStandardSchema(schema)) return standardJsonSchema(schema, "input") return Schema.isSchema(schema) ? toJsonSchema(schema) : schema } const outputJsonSchema = (schema: Tool.ValueSchema): JsonSchema.JsonSchema => { - if (isStandardSchema(schema)) return schema["~standard"].jsonSchema.output({ target: "draft-2020-12" }) + if (isStandardSchema(schema)) return standardJsonSchema(schema, "output") return Schema.isSchema(schema) ? toJsonSchema(schema) : schema } +const standardJsonSchema = (schema: StandardSchemaV1, io: "input" | "output"): JsonSchema.JsonSchema => { + if (isStandardJSONSchema(schema)) return schema["~standard"].jsonSchema[io]({ target: "draft-2020-12" }) + if (schema instanceof $ZodType) return toJSONSchema(schema, { target: "draft-2020-12", io }) + throw new Error(`Schema vendor "${schema["~standard"].vendor}" does not support JSON Schema conversion`) +} + const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => { const document = Schema.toJsonSchemaDocument(schema) // Effect emits valid JSON Schema that some inference providers handle poorly. Simplify it diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index bc3c547124d..7c6324345c6 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -11,6 +11,7 @@ import type { Info } from "@opencode-ai/schema/tool" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { executeTool, toolDefinitions } from "./lib/tool" import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema, SchemaGetter, SchemaIssue, Scope } from "effect" +import { z } from "zod" import { testEffect } from "./lib/effect" const imageStore = Layer.mock(Image.Service, { @@ -556,6 +557,39 @@ describe("Tool", () => { }), ) + it.effect("registers, advertises, and executes a Zod tool", () => + Effect.gen(function* () { + const service = yield* Tool.Service + yield* transform( + service, + { + zod: { + name: "zod", + description: "Increment a parsed number", + input: z.object({ count: z.string().transform(Number) }), + output: z.object({ count: z.number() }), + execute: ({ count }) => Effect.succeed({ output: { count: count + 1 } }), + }, + }, + { codemode: false }, + ) + + const snapshot = yield* service.snapshot() + expect(snapshot.definitions.find((tool) => tool.name === "zod")?.inputSchema).toMatchObject({ + type: "object", + properties: { count: { type: "string" } }, + required: ["count"], + }) + expect( + yield* snapshot.execute({ + sessionID, + ...identity, + call: { type: "tool-call", id: "call-zod", name: "zod", input: { count: "41" } }, + }), + ).toMatchObject({ output: { count: 42 } }) + }), + ) + it.effect("executes the tool advertised in a model request", () => Effect.gen(function* () { const service = yield* Tool.Service diff --git a/packages/core/test/tool-schema.test.ts b/packages/core/test/tool-schema.test.ts index 05902c15bb7..7ee98e6043e 100644 --- a/packages/core/test/tool-schema.test.ts +++ b/packages/core/test/tool-schema.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test" import { Effect, Schema } from "effect" +import { z } from "zod" import type { Info } from "@opencode-ai/schema/tool" import { Tool } from "../src/tool" import { definition, execute } from "../src/tool/runtime" @@ -139,6 +140,43 @@ test("portable schemas validate and describe typed tools", async () => { expect(result.output).toBe("42") }) +test("Zod schemas validate, transform, and describe typed tools", async () => { + const tool: Info = { + name: "zod", + description: "Zod tool", + input: z.object({ count: z.string().transform(Number) }), + output: z.object({ count: z.number() }), + execute: ({ count }) => Effect.succeed({ output: { count: count + 1 } }), + } + + expect(definition(tool)).toEqual({ + name: "zod", + description: "Zod tool", + inputSchema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { count: { type: "string" } }, + required: ["count"], + }, + outputSchema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { count: { type: "number" } }, + required: ["count"], + additionalProperties: false, + }, + }) + expect(await Effect.runPromise(execute(tool, { count: "41" }, {} as Tool.Context))).toMatchObject({ + output: { count: 42 }, + }) + expect(await Effect.runPromise(Effect.flip(execute(tool, { count: 41 }, {} as Tool.Context)))).toEqual( + new Tool.Error({ + message: + 'Invalid arguments for tool "zod":\n- count: Invalid input: expected string, received number\n\nArguments provided:\n{\n "count": 41\n}\n\nUpdate the arguments and call the tool again.', + }), + ) +}) + test("portable schema failures become tool failures", async () => { const input = { "~standard": { diff --git a/packages/schema/src/tool.ts b/packages/schema/src/tool.ts index c3250265266..7f87dccb9ba 100644 --- a/packages/schema/src/tool.ts +++ b/packages/schema/src/tool.ts @@ -1,7 +1,7 @@ export * as Tool from "./tool.js" import { Effect, JsonSchema, Schema } from "effect" -import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec" +import type { StandardSchemaV1 } from "@standard-schema/spec" import type { Agent } from "./agent.js" import type { Session } from "./session.js" import type { SessionMessage } from "./session-message.js" @@ -36,10 +36,7 @@ export type Options = BaseOptions & } ) -export type ValueSchema = - | Schema.Codec - | (StandardSchemaV1 & StandardJSONSchemaV1) - | JsonSchema.JsonSchema +export type ValueSchema = Schema.Codec | StandardSchemaV1 | JsonSchema.JsonSchema type InputValue = 0 extends 1 & S ? any