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

@@ -4,8 +4,6 @@ import { EffectBridge } from "@/effect/bridge"
import type { InstanceContext } from "@/project/instance"
import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, Context, Schema } from "effect"
import z from "zod"
import { ZodOverride } from "@opencode-ai/core/effect-zod"
import { Config } from "@/config/config"
import { MCP } from "../mcp"
import { Skill } from "../skill"
@@ -35,12 +33,11 @@ export const Info = Schema.Struct({
model: Schema.optional(Schema.String),
source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])),
// Some command templates are lazy promises from MCP prompt resolution.
template: Schema.Unknown.annotate({ [ZodOverride]: z.promise(z.string()).or(z.string()) }),
template: Schema.Unknown,
subtask: Schema.optional(Schema.Boolean),
hints: Schema.Array(Schema.String),
}).annotate({ identifier: "Command" })
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
export type Info = Omit<Schema.Schema.Type<typeof Info>, "template"> & { template: Promise<string> | string }
export function hints(template: string) {

View File

@@ -1,13 +1,5 @@
import { Schema } from "effect"
import z from "zod"
import { ZodOverride } from "@opencode-ai/core/effect-zod"
// The original Zod schema carried an external $ref pointing at the models.dev
// JSON schema. That external reference is not a named SDK component — it is a
// literal pointer to an outside schema — so the walker cannot re-derive it
// from AST metadata. Preserve the exact original Zod via ZodOverride.
export const ConfigModelID = Schema.String.annotate({
[ZodOverride]: z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }),
})
export const ConfigModelID = Schema.String
export type ConfigModelID = Schema.Schema.Type<typeof ConfigModelID>

View File

@@ -5,7 +5,6 @@ import * as LSPClient from "./client"
import path from "path"
import { pathToFileURL, fileURLToPath } from "url"
import * as LSPServer from "./server"
import z from "zod"
import { Config } from "@/config/config"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Process } from "@/util/process"
@@ -14,7 +13,6 @@ import { Effect, Layer, Context, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { containsPath } from "@/project/instance-context"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { ZodOverride } from "@opencode-ai/core/effect-zod"
const log = Log.create({ service: "lsp" })
@@ -56,9 +54,7 @@ export const Status = Schema.Struct({
id: Schema.String,
name: Schema.String,
root: Schema.String,
status: Schema.Literals(["connected", "error"]).annotate({
[ZodOverride]: z.union([z.literal("connected"), z.literal("error")]),
}),
status: Schema.Literals(["connected", "error"]),
}).annotate({ identifier: "LSPStatus" })
export type Status = typeof Status.Type

View File

@@ -5,8 +5,8 @@ import { InstanceState } from "@/effect/instance-state"
import { MCP } from "@/mcp"
import { Project } from "@/project/project"
import { Session } from "@/session/session"
import { ToolJsonSchema } from "@/tool/json-schema"
import { ToolRegistry } from "@/tool/registry"
import * as EffectZod from "@opencode-ai/core/effect-zod"
import { Worktree } from "@/worktree"
import { Effect, Option } from "effect"
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
@@ -84,7 +84,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
return list.map((item) => ({
id: item.id,
description: item.description,
parameters: EffectZod.toJsonSchema(item.parameters),
parameters: ToolJsonSchema.fromTool(item),
}))
})

View File

@@ -1,6 +1,5 @@
import { BusEvent } from "@/bus/bus-event"
import { SessionID, MessageID, PartID } from "./schema"
import z from "zod"
import { NamedError } from "@opencode-ai/core/util/error"
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
import { LSP } from "@/lsp/lsp"
@@ -55,7 +54,7 @@ export const APIError = namedSchemaError("APIError", {
responseBody: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
})
export type APIError = z.infer<typeof APIError.Schema>
export type APIError = Schema.Schema.Type<typeof APIError.Schema>
export const ContextOverflowError = namedSchemaError("ContextOverflowError", {
message: Schema.String,
responseBody: Schema.optional(Schema.String),

View File

@@ -1,6 +1,5 @@
import path from "path"
import os from "os"
import * as EffectZod from "@opencode-ai/core/effect-zod"
import { SessionID, MessageID, PartID } from "./schema"
import { MessageV2 } from "./message-v2"
import * as Log from "@opencode-ai/core/util/log"
@@ -21,6 +20,7 @@ import PROMPT_PLAN from "../session/prompt/plan.txt"
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
import MAX_STEPS from "../session/prompt/max-steps.txt"
import { ToolRegistry } from "@/tool/registry"
import { ToolJsonSchema } from "@/tool/json-schema"
import { MCP } from "../mcp"
import { LSP } from "@/lsp/lsp"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -565,7 +565,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
providerID: input.model.providerID,
agent: input.agent,
})) {
const schema = ProviderTransform.schema(input.model, EffectZod.toJsonSchema(item.parameters))
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
tools[item.id] = tool({
description: item.description,
inputSchema: jsonSchema(schema),

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)" }),
})

View File

@@ -1,6 +1,4 @@
import { Schema } from "effect"
import z from "zod"
import { zod } from "@opencode-ai/core/effect-zod"
/**
* Create a Schema-backed NamedError-shaped class.
@@ -11,22 +9,14 @@ import { zod } from "@opencode-ai/core/effect-zod"
* OpenAPI/SDK output is byte-identical to the original NamedError schema.
*
* Preserves the existing surface:
* - static `Schema` (Zod schema of the wire shape)
* - static `Schema` (Effect schema of the wire shape)
* - static `isInstance(x)`
* - instance `toObject()` returning `{ name, data }`
* - `new X({ ...data }, { cause })`
*/
export function namedSchemaError<Tag extends string, Fields extends Schema.Struct.Fields>(tag: Tag, fields: Fields) {
// Wire shape matches the original NamedError output so the SDK stays stable.
const dataSchema = Schema.Struct(fields)
const wire = z
.object({
name: z.literal(tag),
data: zod(dataSchema),
})
.meta({ ref: tag })
// Effect Schema for the wire shape — used by HttpApi OpenAPI generation.
// Wire shape matches the original NamedError output so the SDK stays stable.
const effectSchema = Schema.Struct({
name: Schema.Literal(tag),
data: dataSchema,
@@ -35,7 +25,7 @@ export function namedSchemaError<Tag extends string, Fields extends Schema.Struc
type Data = Schema.Schema.Type<typeof dataSchema>
class NamedSchemaError extends Error {
static readonly Schema = wire
static readonly Schema = effectSchema
static readonly EffectSchema = effectSchema
static readonly tag = tag
public static isInstance(input: unknown): input is NamedSchemaError {