Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 65 additions & 5 deletions packages/core/src/tool/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ToolDefinition } from "@opencode-ai/ai"
import { Tool } from "@opencode-ai/schema/tool"
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
import { Effect, JsonSchema, Schema } from "effect"
import { Effect, JsonSchema, Schema, SchemaAST } from "effect"

export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
name: effectiveName(tool),
Expand Down Expand Up @@ -31,23 +31,58 @@ export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool
}
})

const decodeInput = (schema: Tool.ValueSchema<any>, value: unknown) => {
if (Schema.isSchema(schema))
const decodeInput = (schema: Tool.ValueSchema<any>, value: unknown) =>
attemptDecodeInput(schema, value).pipe(
Effect.catchTag("Tool.Error", (error) => {
// JSON Schema derived from Effect schemas advertises `X | null` for optional
// fields because JSON cannot express undefined, so callers legitimately send
// null to mean "omitted". Retry with null properties removed: schemas that
// genuinely accept null succeed on the first attempt, and the original error
// is reported when the retry cannot help.
const stripped = withoutNullProperties(value)
if (stripped === value) return error
return attemptDecodeInput(schema, stripped).pipe(Effect.catchTag("Tool.Error", () => error))
}),
)

// Removes null-valued object properties recursively. Array elements are positional
// and stay untouched. Returns the input reference when nothing changed.
const withoutNullProperties = (value: unknown): unknown => {
if (Array.isArray(value)) {
const items = value.map(withoutNullProperties)
return items.some((item, index) => item !== value[index]) ? items : value
}
if (typeof value !== "object" || value === null) return value
const entries = Object.entries(value).flatMap(([key, item]) =>
item === null ? [] : [[key, withoutNullProperties(item)] as const],
)
const changed =
entries.length !== Object.keys(value).length ||
entries.some(([key, item]) => (value as Record<string, unknown>)[key] !== item)
return changed ? Object.fromEntries(entries) : value
}

const attemptDecodeInput = (schema: Tool.ValueSchema<any>, value: unknown) => {
if (Schema.isSchema(schema)) {
if (isForeignSchema(schema)) return foreignSchemaPassthrough(value)
return Schema.decodeUnknownEffect(schema)(value).pipe(
Effect.mapError((error) => new Tool.Error({ message: `Invalid tool input: ${error.message}` })),
)
}
if (isStandardSchema(schema)) return validateStandard(schema, value, "Invalid tool input")
return Effect.succeed(value)
}

const encodeOutput = (schema: Tool.ValueSchema<any>, value: unknown) => {
if (Schema.isSchema(schema))
if (Schema.isSchema(schema)) {
if (isForeignSchema(schema)) return foreignSchemaPassthrough(value)
return Schema.encodeEffect(schema)(value).pipe(
Effect.mapError(
(error) =>
new Tool.Error({ message: `Tool returned an invalid value for its output schema: ${error.message}` }),
),
)
}
if (isStandardSchema(schema))
return validateStandard(schema, value, "Tool returned an invalid value for its output schema")
return Schema.decodeUnknownEffect(Schema.Json)(value).pipe(
Expand All @@ -57,6 +92,23 @@ const encodeOutput = (schema: Tool.ValueSchema<any>, value: unknown) => {
)
}

// A schema created by a different copy of `effect` (for example one loaded from a
// plugin's own node_modules) still satisfies `Schema.isSchema` because the type
// identifier is a shared string, but it cannot be interpreted by this instance:
// schema parsing relies on per-instance sentinels and class identity, so checks
// false-fail on valid values and branded types die as defects. AST classes are plain
// classes, so an instanceof test against this instance's AST base distinguishes the
// two reliably.
const isForeignSchema = (schema: Schema.Top) => !(schema.ast instanceof SchemaAST.Base)

// Current @opencode-ai/plugin versions convert plugin schemas to Standard Schema
// wrappers before registration, keeping validation in the authoring instance. For
// plugins built against older versions, skip validation rather than misvalidate.
const foreignSchemaPassthrough = (value: unknown) =>
Effect.logWarning(
"Tool schema was created by a different `effect` module instance; skipping validation. Update the plugin's @opencode-ai/plugin dependency to restore validation.",
).pipe(Effect.as(value))

const isStandardSchema = (
schema: Tool.ValueSchema<any>,
): schema is StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any> =>
Expand All @@ -78,11 +130,19 @@ const validateStandard = (
: pending
if (result.issues)
return yield* new Tool.Error({
message: `${prefix}: ${result.issues.map((issue) => issue.message).join(", ")}`,
message: `${prefix}: ${result.issues.map(standardIssueText).join(", ")}`,
})
return result.value
})

const standardIssueText = (issue: StandardSchemaV1.Issue) => {
if (issue.path === undefined || issue.path.length === 0) return issue.message
const segments = issue.path.map((segment) =>
typeof segment === "object" && segment !== null && "key" in segment ? segment.key : segment,
)
return `${issue.message} at ${JSON.stringify(segments)}`
}

const standardFailure = (prefix: string, error: unknown) =>
new Tool.Error({ message: `${prefix}: ${error instanceof Error ? error.message : String(error)}` })

Expand Down
110 changes: 110 additions & 0 deletions packages/core/test/tool-input-null.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { expect, test } from "bun:test"
import { Tool } from "@opencode-ai/core/tool"
import { execute } from "@opencode-ai/core/tool/runtime"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { Info } from "@opencode-ai/schema/tool"
import { Effect, Schema } from "effect"

const context = {
sessionID: Session.ID.make("ses_null"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_null"),
id: Tool.CallID.make("call_null"),
progress: () => Effect.void,
}

// The JSON Schema advertised for these tools renders optional fields as `X | null`
// (JSON cannot express undefined), so callers legitimately send null to mean
// "omitted". The runtime must accept that without weakening schemas that
// genuinely distinguish null.
const collect = (input: Info["input"]) => {
let received: unknown
const tool: Info = {
name: "probe",
description: "Probe",
input,
execute: (value) => {
received = value
return Effect.succeed({ content: "ok" })
},
}
return {
tool,
run: (value: unknown) => Effect.runPromise(execute(tool, value, context)).then(() => received),
fail: (value: unknown) => Effect.runPromiseExit(execute(tool, value, context)).then((exit) => exit.toString()),
}
}

test("null optional properties decode as omitted", async () => {
const probe = collect(
Schema.Struct({
title: Schema.String,
agent: Schema.optional(Schema.String),
}),
)
expect(await probe.run({ title: "probe", agent: null })).toEqual({ title: "probe" })
})

test("nested null optional properties decode as omitted", async () => {
const probe = collect(
Schema.Struct({
worktree: Schema.optional(
Schema.Struct({
branch: Schema.String,
base: Schema.optional(Schema.String),
}),
),
}),
)
expect(await probe.run({ worktree: { branch: "main", base: null } })).toEqual({ worktree: { branch: "main" } })
})

test("schemas that accept null keep it", async () => {
const probe = collect(Schema.Struct({ next: Schema.NullOr(Schema.String) }))
expect(await probe.run({ next: null })).toEqual({ next: null })
})

test("null array elements survive the retry", async () => {
const probe = collect(
Schema.Struct({
tags: Schema.Array(Schema.NullOr(Schema.String)),
agent: Schema.optional(Schema.String),
}),
)
expect(await probe.run({ tags: ["a", null], agent: null })).toEqual({ tags: ["a", null] })
})

test("unfixable nulls report the original error", async () => {
const probe = collect(Schema.Struct({ title: Schema.String }))
const message = await probe.fail({ title: null })
expect(message).toContain("Invalid tool input")
expect(message).toContain("Expected string")
})

test("standard schema inputs get the same retry", async () => {
const attempts: Array<unknown> = []
const input = {
"~standard": {
version: 1,
vendor: "test",
validate: (value: unknown) => {
attempts.push(value)
const record = value as Record<string, unknown>
if ("agent" in record && record.agent === null) return { issues: [{ message: "Expected string | undefined" }] }
return { value }
},
jsonSchema: {
input: () => ({ type: "object" }),
output: () => ({ type: "object" }),
},
},
} as unknown as Info["input"]
const probe = collect(input)
expect(await probe.run({ title: "probe", agent: null })).toEqual({ title: "probe" })
expect(attempts).toEqual([
{ title: "probe", agent: null },
{ title: "probe" },
])
})
138 changes: 138 additions & 0 deletions packages/core/test/tool-runtime-foreign-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { beforeAll, expect, test } from "bun:test"
import { cp, mkdir, mkdtemp, readFile, symlink } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import { Tool } from "@opencode-ai/core/tool"
import { definition, execute } from "@opencode-ai/core/tool/runtime"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { Info } from "@opencode-ai/schema/tool"
import { Effect, Schema } from "effect"

const context = {
sessionID: Session.ID.make("ses_foreign"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_foreign"),
id: Tool.CallID.make("call_foreign"),
progress: () => Effect.void,
}

// Plugins load `effect` from their own node_modules, so their schemas come from a
// different module instance than the host's. Simulate that by copying the effect
// package to a temporary directory and importing the copy: same version, distinct
// instance, exactly like a plugin installed in the config directory.
let foreign: typeof Schema

beforeAll(async () => {
const source = path.dirname(fileURLToPath(import.meta.resolve("effect/package.json")))
const base = await mkdtemp(path.join(tmpdir(), "opencode-foreign-effect-"))
const target = path.join(base, "node_modules", "effect")
await cp(source, target, { recursive: true })
const dependencies = JSON.parse(await readFile(path.join(source, "package.json"), "utf8")).dependencies ?? {}
for (const name of Object.keys(dependencies)) {
const real = path.dirname(Bun.resolveSync(`${name}/package.json`, source))
const link = path.join(base, "node_modules", name)
await mkdir(path.dirname(link), { recursive: true })
await symlink(real, link, "dir")
}
const mod = (await import(pathToFileURL(path.join(target, "dist", "index.js")).href)) as { Schema: typeof Schema }
foreign = mod.Schema
expect<unknown>(foreign).not.toBe(Schema)
})

test("foreign live schemas skip validation instead of misvalidating checks", async () => {
// Regression: a minLength check from a foreign instance used to fail on valid
// values ('Expected a value with a length of at least 1 at ["title"]') because the
// host parser hands the foreign filter an internal sentinel instead of the value.
const input = foreign.Struct({
title: foreign.optional(foreign.String.check(foreign.isMinLength(1))),
prompt: foreign.optional(foreign.String),
})
expect(Schema.isSchema(input)).toBe(true)
let received: unknown
const tool: Info = {
name: "create",
description: "Create",
input,
execute: (value) => {
received = value
return Effect.succeed({ content: "ok" })
},
}
const result = await Effect.runPromise(execute(tool, { title: "probe", prompt: "Say ready." }, context))
expect(result.content).toEqual([{ type: "text", text: "ok" }])
expect(received).toEqual({ title: "probe", prompt: "Say ready." })
})

test("foreign branded schemas no longer die as defects", async () => {
// Regression: decoding a foreign branded ID (like Session.ID) threw "Sync adapter
// can only throw schema errors", surfacing as a bare "Tool execution failed".
const input = foreign.Struct({
sessionID: foreign.String.check(foreign.isStartsWith("ses")).pipe(foreign.brand("SessionID")),
})
const tool: Info = {
name: "notify",
description: "Notify",
input,
execute: (value) => Effect.succeed({ content: JSON.stringify(value) }),
}
const result = await Effect.runPromise(execute(tool, { sessionID: "ses_123" }, context))
expect(result.content).toEqual([{ type: "text", text: '{"sessionID":"ses_123"}' }])
})

test("foreign output schemas pass the produced value through", async () => {
const tool: Info = {
name: "get",
description: "Get",
input: foreign.Struct({}),
output: foreign.Struct({ sessionID: foreign.String }),
execute: () => Effect.succeed({ output: { sessionID: "ses_123" } }),
}
const result = await Effect.runPromise(execute(tool, {}, context))
expect(result.output).toEqual({ sessionID: "ses_123" })
})

// Mirrors the conversion current @opencode-ai/plugin versions perform in the
// authoring instance before registration (see packages/plugin/src/effect/tool-schema.ts).
const convert = (schema: unknown, direction: "input" | "output") => {
const anyForeign = foreign as any
const oriented = direction === "input" ? schema : anyForeign.flip(schema)
const augmented = anyForeign.toStandardJSONSchemaV1(anyForeign.toStandardSchemaV1(oriented))
return { "~standard": augmented["~standard"] } as Info["input"]
}

test("converted standard wrappers validate in the authoring instance", async () => {
const input = convert(
foreign.Struct({
title: foreign.optional(foreign.String.check(foreign.isMinLength(1))),
}),
"input",
)
expect(Schema.isSchema(input)).toBe(false)
let received: unknown
const tool: Info = {
name: "create",
description: "Create",
input,
output: convert(foreign.Struct({ sessionID: foreign.String }), "output"),
execute: (value) => {
received = value
return Effect.succeed({ output: { sessionID: "ses_123" }, content: "created" })
},
}

const success = await Effect.runPromise(execute(tool, { title: "probe" }, context))
expect(received).toEqual({ title: "probe" })
expect(success.output).toEqual({ sessionID: "ses_123" })

const failure = await Effect.runPromiseExit(execute(tool, { title: "" }, context))
expect(failure.toString()).toContain("Invalid tool input")
expect(failure.toString()).toContain("a value with a length of at least 1")
expect(failure.toString()).toContain('at ["title"]')

const derived = definition(tool)
expect(derived.inputSchema).toMatchObject({ type: "object" })
expect((derived.inputSchema as { properties?: Record<string, unknown> }).properties).toHaveProperty("title")
})
22 changes: 20 additions & 2 deletions packages/plugin/src/effect/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { PluginApi } from "@opencode-ai/client/effect/api"
import type { Effect, Scope } from "effect"
import { instanceSafeTool } from "./tool-schema.js"
import type { PluginOptions } from "../options.js"
import type { App } from "../app.js"
import type { AgentDomain } from "./agent.js"
Expand Down Expand Up @@ -41,6 +42,23 @@ export interface Plugin<R = Scope.Scope> {
readonly effect: (context: Context) => Effect.Effect<void, never, R>
}

export function define<R = Scope.Scope>(plugin: Plugin<R>) {
return plugin
export function define<R = Scope.Scope>(plugin: Plugin<R>): Plugin<R> {
return {
...plugin,
effect: (context) => plugin.effect(instanceSafeContext(context)),
}
}

// Tool schemas cross from the plugin's module world into the host at `draft.add`;
// convert them while authoring-instance code is still on the stack so the host never
// interprets a foreign Effect schema. See `instanceSafeTool`.
function instanceSafeContext(context: Context): Context {
return {
...context,
tool: {
...context.tool,
transform: (callback) =>
context.tool.transform((draft) => callback({ add: (tool) => draft.add(instanceSafeTool(tool)) })),
},
}
}
Loading
Loading