Remove effect-zod bridge (#26956)

This commit is contained in:
Kit Langton
2026-05-11 21:14:55 -04:00
committed by GitHub
parent abb1ee6278
commit e5aa5161f2
21 changed files with 425 additions and 1266 deletions

View File

@@ -0,0 +1,164 @@
import type { JSONSchema7 } from "@ai-sdk/provider"
import { JsonSchema, Schema } from "effect"
import type * as Tool from "./tool"
type JsonObject = Record<string, unknown>
const cache = new WeakMap<Schema.Top, JSONSchema7>()
export function fromSchema(schema: Schema.Top): JSONSchema7 {
const cached = cache.get(schema)
if (cached) return cached
const document = Schema.toJsonSchemaDocument(schema, { additionalProperties: true })
const result = normalize({
$schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12,
...document.schema,
...(Object.keys(document.definitions).length > 0 ? { $defs: document.definitions } : {}),
})
const inlined = dropDefinitionsIfResolved(inlineLocalReferences(result))
if (!isJsonSchema(inlined)) throw new Error("tool JSON Schema helper produced a non-schema value")
cache.set(schema, inlined)
return inlined
}
export function fromTool(tool: Tool.Def): JSONSchema7 {
return tool.jsonSchema ?? fromSchema(tool.parameters as Schema.Top)
}
function normalize(value: unknown, options: { stripNull?: boolean } = {}): unknown {
if (Array.isArray(value)) return value.map((item) => normalize(item))
if (!isRecord(value)) return value
const required = Array.isArray(value.required)
? new Set(value.required.filter((item) => typeof item === "string"))
: undefined
const schema = Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
key === "properties" && isRecord(item)
? Object.fromEntries(
Object.entries(item).map(([name, property]) => [
name,
normalize(property, { stripNull: !required?.has(name) }),
]),
)
: normalize(item),
]),
)
if (schema.additionalProperties === true) delete schema.additionalProperties
if (options.stripNull && Array.isArray(schema.anyOf)) {
const withoutNull = schema.anyOf.filter((item) => !isRecord(item) || item.type !== "null")
if (withoutNull.length !== schema.anyOf.length) return normalize({ ...schema, anyOf: withoutNull })
}
if (Array.isArray(schema.anyOf)) {
const withoutNull = schema.anyOf
const number = withoutNull.find((item) => isRecord(item) && item.type === "number")
const nonFinite = withoutNull.filter(
(item) => isRecord(item) && Array.isArray(item.enum) && item.enum.every((entry) => isNonFiniteNumber(entry)),
)
if (number && nonFinite.length === withoutNull.length - 1) {
const { anyOf: _, ...rest } = schema
return normalize({ ...number, ...rest })
}
if (isEmptyStructUnion(withoutNull)) {
const { anyOf: _, ...rest } = schema
return normalize({ type: "object", properties: {}, ...rest })
}
if (withoutNull.length === 1 && isRecord(withoutNull[0])) {
const { anyOf: _, ...rest } = schema
return normalize({ ...withoutNull[0], ...rest })
}
}
if (Array.isArray(schema.allOf) && schema.allOf.every(isRecord) && canFlattenAllOf(schema.allOf, schema)) {
const { allOf, ...rest } = schema
return normalize({ ...Object.assign({}, ...allOf), ...rest })
}
if (schema.type === "integer" && schema.maximum === undefined) {
return { minimum: Number.MIN_SAFE_INTEGER, ...schema, maximum: Number.MAX_SAFE_INTEGER }
}
return schema
}
function isRecord(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function isJsonSchema(value: unknown): value is JSONSchema7 {
return typeof value === "boolean" || isRecord(value)
}
function isNonFiniteNumber(value: unknown) {
return value === "NaN" || value === "Infinity" || value === "-Infinity"
}
function isEmptyStructUnion(items: unknown[]) {
return (
items.length === 2 &&
items.some((item) => isRecord(item) && item.type === "object" && item.properties === undefined) &&
items.some((item) => isRecord(item) && item.type === "array" && item.items === undefined)
)
}
function canFlattenAllOf(allOf: JsonObject[], parent: JsonObject) {
const keys = new Set(Object.keys(parent).filter((key) => key !== "allOf"))
return allOf.every((item) =>
Object.keys(item).every((key) => {
if (keys.has(key)) return false
keys.add(key)
return true
}),
)
}
function inlineLocalReferences(value: unknown, definitions?: JsonObject, seen = new Set<string>()): unknown {
if (Array.isArray(value)) return value.map((item) => inlineLocalReferences(item, definitions, seen))
if (!isRecord(value)) return value
const localDefinitions = definitions ?? (isRecord(value.$defs) ? value.$defs : undefined)
if (typeof value.$ref === "string" && localDefinitions) {
const name = value.$ref.match(/^#\/\$defs\/(.+)$/)?.[1] ?? value.$ref.match(/^#\/definitions\/(.+)$/)?.[1]
if (name && !seen.has(name)) {
const target = localDefinitions[name]
if (target) {
const { $ref: _, ...rest } = value
return inlineLocalReferences(
{ ...(isRecord(target) ? target : {}), ...rest },
localDefinitions,
new Set(seen).add(name),
)
}
}
}
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, inlineLocalReferences(item, localDefinitions, seen)]),
)
}
function dropDefinitionsIfResolved(value: unknown): unknown {
if (!isRecord(value) || hasLocalReference(value)) return value
const { $defs: _, definitions: __, ...rest } = value
return rest
}
function hasLocalReference(value: unknown): boolean {
if (Array.isArray(value)) return value.some(hasLocalReference)
if (!isRecord(value)) return false
if (
typeof value.$ref === "string" &&
(value.$ref.startsWith("#/$defs/") || value.$ref.startsWith("#/definitions/"))
) {
return true
}
return Object.values(value).some(hasLocalReference)
}
export * as ToolJsonSchema from "./json-schema"

View File

@@ -15,9 +15,9 @@ import { SkillTool } from "./skill"
import * as Tool from "./tool"
import { Config } from "@/config/config"
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
import type { JSONSchema7, JSONSchema7Definition } from "@ai-sdk/provider"
import { Schema } from "effect"
import z from "zod"
import { ZodOverride } from "@opencode-ai/core/effect-zod"
import { Plugin } from "../plugin"
import { Provider } from "@/provider/provider"
import { ProviderID, type ModelID } from "../provider/schema"
@@ -137,17 +137,19 @@ export const layer: Layer.Layer<
const custom: Tool.Def[] = []
function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
// Plugin tools define their args as a raw Zod shape. Wrap the
// derived Zod object in a `Schema.declare` so it slots into the
// Schema-typed framework, and annotate with `ZodOverride` so the
// walker emits the original Zod object for LLM JSON Schema.
const zodParams = z.object(def.args)
const parameters = Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success).annotate({
[ZodOverride]: zodParams,
})
// Plugin tools still expose Zod args publicly; keep that compatibility
// boxed at the registry boundary and give the LLM the original JSON Schema.
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)
const parameters = zodParams
? Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success)
: Schema.Unknown
return {
id,
parameters,
jsonSchema,
description: def.description,
execute: (args, toolCtx) =>
Effect.gen(function* () {
@@ -323,8 +325,13 @@ export const layer: Layer.Layer<
const output = {
description: tool.description,
parameters: tool.parameters,
jsonSchema: tool.jsonSchema,
}
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
const jsonSchema =
output.parameters === tool.parameters || output.jsonSchema !== tool.jsonSchema
? output.jsonSchema
: undefined
return {
id: tool.id,
description: [
@@ -335,6 +342,7 @@ export const layer: Layer.Layer<
.filter(Boolean)
.join("\n"),
parameters: output.parameters,
jsonSchema,
execute: tool.execute,
formatValidationError: tool.formatValidationError,
}
@@ -376,4 +384,50 @@ export const defaultLayer = Layer.suspend(() =>
),
)
function isZodType(value: unknown): value is z.ZodType {
return typeof value === "object" && value !== null && "_zod" in value
}
function isJsonSchemaDefinition(value: unknown): value is JSONSchema7Definition {
return typeof value === "boolean" || (typeof value === "object" && value !== null && !Array.isArray(value))
}
function legacyJsonSchema(entries: [string, unknown][]): JSONSchema7 {
const properties = Object.fromEntries(
entries.filter((entry): entry is [string, JSONSchema7Definition] => isJsonSchemaDefinition(entry[1])),
)
return {
type: "object",
properties,
required: Object.keys(properties),
}
}
function zodJsonSchema(schema: z.ZodType): JSONSchema7 {
const result = normalizeZodJsonSchema(z.toJSONSchema(schema, { io: "input" }))
if (!isJsonSchemaObject(result)) throw new Error("plugin tool Zod schema produced a non-object JSON Schema")
const { $defs, ...rest } = result
return (
$defs && isJsonSchemaObject($defs) ? { ...rest, definitions: $defs as JSONSchema7["definitions"] } : rest
) as JSONSchema7
}
function normalizeZodJsonSchema(value: unknown): unknown {
if (Array.isArray(value)) return value.map((item) => normalizeZodJsonSchema(item))
if (typeof value !== "object" || value === null) return value
return Object.fromEntries(
Object.entries(value)
.filter((entry) =>
(entry[0] === "exclusiveMaximum" || entry[0] === "exclusiveMinimum") && typeof entry[1] === "boolean"
? false
: true,
)
.map(([key, item]) => [key, normalizeZodJsonSchema(item)]),
)
}
function isJsonSchemaObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
export * as ToolRegistry from "./registry"

View File

@@ -1,4 +1,5 @@
import { Effect, Schema } from "effect"
import type { JSONSchema7 } from "@ai-sdk/provider"
import type { MessageV2 } from "../session/message-v2"
import type { Permission } from "../permission"
import type { SessionID, MessageID } from "../session/schema"
@@ -38,6 +39,7 @@ export interface Def<
id: string
description: string
parameters: Parameters
jsonSchema?: JSONSchema7
execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
formatValidationError?(error: unknown): string
}

View File

@@ -12,10 +12,11 @@ const MAX_TIMEOUT = 120 * 1000 // 2 minutes
export const Parameters = Schema.Struct({
url: Schema.String.annotate({ description: "The URL to fetch content from" }),
format: Schema.Literals(["text", "markdown", "html"])
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const)))
.annotate({
description: "The format to return the content in (text, markdown, or html). Defaults to markdown.",
}),
default: "markdown",
})
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const))),
timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in seconds (max 120)" }),
})