Remove effect-zod bridge (#26956)
This commit is contained in:
164
packages/opencode/src/tool/json-schema.ts
Normal file
164
packages/opencode/src/tool/json-schema.ts
Normal 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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)" }),
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user