Compare commits

...

2 Commits

Author SHA1 Message Date
Kit Langton 7fa8ad4ec2 fix(tool): support zod 3 plugin schemas 2026-05-21 17:26:53 -04:00
Kit Langton 463af5e079 fix(session): reject tool schemas with zod internals 2026-05-21 16:39:56 -04:00
4 changed files with 337 additions and 9 deletions
+30
View File
@@ -20,6 +20,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { EffectBridge } from "@/effect/bridge"
const log = Log.create({ service: "session.tools" })
const schemaInternalKeys = new Set(["_def", "def", "_zod", "~standard", "_cached", "typeName"])
export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
agent: Agent.Info
@@ -78,6 +79,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
agent: input.agent,
})) {
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
assertNoSchemaInternals(item.id, schema)
tools[item.id] = tool({
description: item.description,
inputSchema: jsonSchema(schema),
@@ -121,6 +123,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
const transformed = ProviderTransform.schema(input.model, schema)
assertNoSchemaInternals(key, transformed)
item.inputSchema = jsonSchema(transformed)
item.execute = (args, opts) =>
run.promise(
@@ -205,4 +208,31 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
return tools
})
function assertNoSchemaInternals(toolID: string, schema: unknown) {
const path = schemaInternalPath(schema)
if (!path) return
throw new Error(`Tool ${toolID} input schema contains non-JSON-Schema Zod internals at ${path}`)
}
function schemaInternalPath(value: unknown, path = "$", skipKeys = false): string | undefined {
if (Array.isArray(value)) {
return value
.map((item, index) => schemaInternalPath(item, `${path}[${index}]`))
.find((item): item is string => item !== undefined)
}
if (typeof value !== "object" || value === null) return undefined
for (const [key, item] of Object.entries(value)) {
const nextPath = /^[A-Za-z_$][\w$]*$/.test(key) ? `${path}.${key}` : `${path}[${JSON.stringify(key)}]`
if (!skipKeys && schemaInternalKeys.has(key)) return nextPath
const found = schemaInternalPath(
item,
nextPath,
key === "properties" || key === "$defs" || key === "definitions" || key === "patternProperties",
)
if (found) return found
}
return undefined
}
export * as SessionTools from "./tools"
+22 -7
View File
@@ -55,6 +55,13 @@ import { Reference } from "@/reference/reference"
import { BackgroundJob } from "@/background/job"
import { SessionStatus } from "@/session/status"
import { RuntimeFlags } from "@/effect/runtime-flags"
import {
objectFromShape,
safeParse,
type AnyObjectSchema,
type AnySchema,
} from "@modelcontextprotocol/sdk/server/zod-compat.js"
import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js"
const log = Log.create({ service: "tool.registry" })
@@ -150,10 +157,10 @@ export const layer: Layer.Layer<
const args = def.args ?? {}
const entries = Object.entries(args)
const allZod = entries.every((entry) => isZodType(entry[1]))
const zodParams = allZod ? z.object(args) : undefined
const zodParams = allZod ? objectFromShape(args as Record<string, AnySchema>) : undefined
const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries)
const parameters = zodParams
? Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success)
? Schema.declare<unknown>((u): u is unknown => safeParse(zodParams, u).success)
: Schema.Unknown
return {
id,
@@ -402,7 +409,11 @@ export const defaultLayer = Layer.suspend(() =>
.pipe(Layer.provide(RuntimeFlags.defaultLayer)),
)
function isZodType(value: unknown): value is z.ZodType {
function isZodType(value: unknown): value is AnySchema {
return typeof value === "object" && value !== null && ("_zod" in value || "_def" in value)
}
function isZod4Type(value: unknown): value is z.ZodType {
return typeof value === "object" && value !== null && "_zod" in value
}
@@ -425,8 +436,12 @@ function legacyJsonSchema(entries: [string, unknown][]): JSONSchema7 {
}
}
function zodJsonSchema(schema: z.ZodType): JSONSchema7 {
const result = normalizeZodJsonSchema(z.toJSONSchema(schema, { io: "input", metadata: zodMetadataRegistry(schema) }))
function zodJsonSchema(schema: AnyObjectSchema): JSONSchema7 {
const result = normalizeZodJsonSchema(
isZod4Type(schema)
? z.toJSONSchema(schema, { io: "input", metadata: zodMetadataRegistry(schema) })
: toJsonSchemaCompat(schema, { pipeStrategy: "input" }),
)
if (!isJsonSchemaObject(result)) throw new Error("plugin tool Zod schema produced a non-object JSON Schema")
const { $defs, ...rest } = result
return (
@@ -434,7 +449,7 @@ function zodJsonSchema(schema: z.ZodType): JSONSchema7 {
) as JSONSchema7
}
function zodMetadataRegistry(schema: z.ZodType) {
function zodMetadataRegistry(schema: AnyObjectSchema) {
const registry = z.registry<Record<string, unknown>>()
const seen = new WeakSet<object>()
const collect = (value: unknown) => {
@@ -442,7 +457,7 @@ function zodMetadataRegistry(schema: z.ZodType) {
if (seen.has(value)) return
seen.add(value)
if (isZodType(value)) {
if (isZod4Type(value)) {
const metadata = typeof value.meta === "function" ? value.meta() : undefined
const description = typeof value.description === "string" ? value.description : undefined
const merged = {
@@ -0,0 +1,201 @@
import { describe, expect } from "bun:test"
import { jsonSchema } from "ai"
import { Effect, Exit, Layer } from "effect"
import { Agent } from "@/agent/agent"
import { MCP } from "@/mcp"
import { Permission } from "@/permission"
import { ProjectID } from "@/project/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import type { Provider } from "@/provider/provider"
import { SessionTools } from "@/session/tools"
import { MessageV2 } from "@/session/message-v2"
import { MessageID, SessionID } from "@/session/schema"
import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate"
import { Plugin } from "@/plugin"
import { testEffect } from "../lib/effect"
const it = testEffect(
Layer.mergeAll(
Layer.succeed(
ToolRegistry.Service,
ToolRegistry.Service.of({
ids: () => Effect.succeed([]),
all: () => Effect.succeed([]),
named: () => Effect.die("unexpected named tool lookup"),
tools: () => Effect.succeed([]),
}),
),
Layer.succeed(
MCP.Service,
MCP.Service.of({
status: () => Effect.succeed({}),
clients: () => Effect.succeed({}),
tools: () =>
Effect.succeed({
ctx_batch_execute: {
description: "context tool",
inputSchema: jsonSchema({
type: "object",
properties: {
batch: {
type: "array",
items: schemaWithZodInternals(),
},
},
}),
execute: () => Promise.resolve({ content: [{ type: "text" as const, text: "ok" }] }),
},
}),
prompts: () => Effect.succeed({}),
resources: () => Effect.succeed({}),
add: () => Effect.succeed({ status: { status: "disabled" as const } }),
connect: () => Effect.void,
disconnect: () => Effect.void,
getPrompt: () => Effect.succeed(undefined),
readResource: () => Effect.succeed(undefined),
startAuth: () => Effect.die("unexpected MCP auth"),
authenticate: () => Effect.die("unexpected MCP auth"),
finishAuth: () => Effect.die("unexpected MCP auth"),
removeAuth: () => Effect.void,
supportsOAuth: () => Effect.succeed(false),
hasStoredTokens: () => Effect.succeed(false),
getAuthStatus: () => Effect.succeed("not_authenticated" as const),
}),
),
Layer.succeed(
Plugin.Service,
Plugin.Service.of({
trigger: (_name, _input, output) => Effect.succeed(output),
list: () => Effect.succeed([]),
init: () => Effect.void,
}),
),
Layer.succeed(
Permission.Service,
Permission.Service.of({
ask: () => Effect.void,
reply: () => Effect.void,
list: () => Effect.succeed([]),
}),
),
Layer.succeed(
Truncate.Service,
Truncate.Service.of({
cleanup: () => Effect.void,
write: () => Effect.succeed("/tmp/tool-output"),
output: (text) => Effect.succeed({ content: text, truncated: false as const }),
limits: () => Effect.succeed({ maxLines: 2000, maxBytes: 50 * 1024 }),
}),
),
),
)
describe("SessionTools.resolve", () => {
it.effect("fails locally when MCP schemas contain Zod internals", () =>
Effect.gen(function* () {
const exit = yield* SessionTools.resolve({
agent: agentInfo(),
model: kimiModel(),
session: sessionInfo(),
processor: processor(),
bypassAgentCheck: false,
messages: [],
promptOps: promptOps(),
}).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (!Exit.isFailure(exit)) return
expect(String(exit.cause)).toContain("ctx_batch_execute")
expect(String(exit.cause)).toContain("non-JSON-Schema Zod internals")
expect(String(exit.cause)).toContain("$.properties.batch.items._zod")
}),
)
})
function agentInfo(): Agent.Info {
return {
name: "build",
mode: "primary",
permission: [],
options: {},
}
}
function schemaWithZodInternals() {
return JSON.parse(
JSON.stringify({
_zod: { def: { type: "object" } },
def: { type: "object" },
typeName: "ZodObject",
"~standard": { vendor: "zod" },
}),
)
}
function kimiModel(): Provider.Model {
return {
id: ModelID.make("kimi-k2.6"),
providerID: ProviderID.make("moonshotai"),
name: "Kimi K2.6",
limit: { context: 128_000, output: 32_000 },
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
capabilities: {
toolcall: true,
attachment: false,
reasoning: false,
temperature: true,
input: { text: true, image: false, audio: false, video: false, pdf: false },
output: { text: true, image: false, audio: false, video: false, pdf: false },
interleaved: false,
},
api: { id: "kimi-k2.6", url: "https://api.moonshot.example/v1", npm: "@ai-sdk/openai-compatible" },
options: {},
headers: {},
release_date: "2026-01-01",
status: "active",
}
}
function sessionInfo() {
return {
id: SessionID.descending(),
slug: "test",
projectID: ProjectID.global,
directory: "/tmp/test",
title: "test",
version: "test",
time: { created: Date.now(), updated: Date.now() },
}
}
function processor() {
return {
message: {
id: MessageID.ascending(),
sessionID: SessionID.descending(),
role: "assistant",
parentID: MessageID.ascending(),
modelID: ModelID.make("kimi-k2.6"),
providerID: ProviderID.make("moonshotai"),
mode: "build",
agent: "build",
path: { cwd: "/tmp/test", root: "/tmp/test" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: Date.now() },
} satisfies MessageV2.Assistant,
updateToolCall: () => Effect.succeed(undefined),
completeToolCall: () => Effect.void,
}
}
function promptOps() {
return {
cancel: () => Effect.void,
resolvePromptParts: (template: string) => Effect.succeed([{ type: "text" as const, text: template }]),
prompt: () => Effect.die("unexpected prompt call"),
loop: () => Effect.die("unexpected loop call"),
}
}
+84 -2
View File
@@ -34,6 +34,7 @@ import { ProviderID, ModelID } from "@/provider/schema"
import { ToolJsonSchema } from "@/tool/json-schema"
import { MessageID, SessionID } from "@/session/schema"
import { RuntimeFlags } from "@/effect/runtime-flags"
import z3 from "zod/v3"
const node = CrossSpawnSpawner.defaultLayer
const configLayer = TestConfig.layer({
@@ -80,7 +81,7 @@ const brokenPluginLayer = Layer.succeed(
init: () => Effect.void,
trigger: ((_name: unknown, _input: unknown, output: unknown) =>
Effect.succeed(output)) as Plugin.Interface["trigger"],
list: () =>
list: (() =>
Effect.succeed([
{
tool: {
@@ -91,7 +92,41 @@ const brokenPluginLayer = Layer.succeed(
},
},
},
]),
])) as unknown as Plugin.Interface["list"],
}),
)
const zod3PluginLayer = Layer.succeed(
Plugin.Service,
Plugin.Service.of({
init: () => Effect.void,
trigger: ((_name: unknown, _input: unknown, output: unknown) =>
Effect.succeed(output)) as Plugin.Interface["trigger"],
list: (() =>
Effect.succeed([
{
tool: {
ctx_batch_execute: {
description: "context-mode batch executor",
args: {
batch: z3
.preprocess(
(value) => (typeof value === "string" ? JSON.parse(value) : value),
z3
.array(
z3.object({
command: z3.string().describe("Command to execute"),
}),
)
.min(1),
)
.describe("Commands to execute as a batch"),
},
execute: async () => "ok",
},
},
},
])) as unknown as Plugin.Interface["list"],
}),
)
@@ -105,6 +140,7 @@ const background = testEffect(
const withBrokenPlugin = testEffect(
Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer),
)
const withZod3Plugin = testEffect(Layer.mergeAll(registryLayer({ plugin: zod3PluginLayer }), node, Agent.defaultLayer))
afterEach(async () => {
await disposeAllInstances()
@@ -349,6 +385,52 @@ describe("tool.registry", () => {
}),
)
withZod3Plugin.instance("loads plugin tools with Zod 3 args as JSON Schema", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const loaded = (yield* registry.all()).find((tool) => tool.id === "ctx_batch_execute")
if (!loaded) throw new Error("ctx_batch_execute tool was not loaded")
expect(loaded.jsonSchema).toMatchObject({
type: "object",
properties: {
batch: {
type: "array",
description: "Commands to execute as a batch",
items: {
type: "object",
properties: {
command: { type: "string", description: "Command to execute" },
},
},
minItems: 1,
},
},
required: ["batch"],
})
expect(JSON.stringify(loaded.jsonSchema)).not.toContain("_def")
expect(JSON.stringify(loaded.jsonSchema)).not.toContain("_zod")
expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({ batch: [{ command: "pwd" }] }))).toBe(
true,
)
expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false)
}),
)
withZod3Plugin.instance("validates plugin tools with Zod 3 preprocessors", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const loaded = (yield* registry.all()).find((tool) => tool.id === "ctx_batch_execute")
if (!loaded) throw new Error("ctx_batch_execute tool was not loaded")
expect(
Result.isSuccess(
Schema.decodeUnknownResult(loaded.parameters)({ batch: JSON.stringify([{ command: "pwd" }]) }),
),
).toBe(true)
}),
)
it.instance(
"preserves Zod arg descriptions from older config-scoped plugin packages",
() =>