fix(core): support Zod tool schemas (#44861)
This commit is contained in:
@@ -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<any>, value: unknown) => {
|
||||
)
|
||||
}
|
||||
|
||||
const isStandardSchema = (
|
||||
schema: Tool.ValueSchema<any>,
|
||||
): schema is StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any> =>
|
||||
const isStandardSchema = (schema: Tool.ValueSchema<any>): schema is StandardSchemaV1<any, any> =>
|
||||
typeof schema === "object" && schema !== null && "~standard" in schema
|
||||
|
||||
const isStandardJSONSchema = (
|
||||
schema: StandardSchemaV1<any, any>,
|
||||
): schema is StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any> => "jsonSchema" in schema["~standard"]
|
||||
|
||||
const validateStandard = (
|
||||
schema: StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any>,
|
||||
schema: StandardSchemaV1<any, any>,
|
||||
value: unknown,
|
||||
): Effect.Effect<StandardSchemaV1.Result<unknown>> =>
|
||||
Effect.gen(function* () {
|
||||
@@ -150,15 +153,21 @@ const validateStandard = (
|
||||
|
||||
const inputJsonSchema = (schema: Tool.ValueSchema<any>): 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<any>): 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<any, any>, 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<A = unknown> =
|
||||
| Schema.Codec<A, any>
|
||||
| (StandardSchemaV1<any, A> & StandardJSONSchemaV1<any, A>)
|
||||
| JsonSchema.JsonSchema
|
||||
export type ValueSchema<A = unknown> = Schema.Codec<A, any> | StandardSchemaV1<any, A> | JsonSchema.JsonSchema
|
||||
|
||||
type InputValue<S> = 0 extends 1 & S
|
||||
? any
|
||||
|
||||
Reference in New Issue
Block a user