mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-02 16:26:14 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fa8ad4ec2 | |||
| 463af5e079 |
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
() =>
|
||||
|
||||
Reference in New Issue
Block a user