mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 06:33:01 -04:00
fix(core): accept null as omitted for optional tool inputs
The JSON Schema advertised for tools renders optional fields as `X | null` because JSON cannot express undefined, so callers (models, Code Mode agents) legitimately pass null meaning "omit" and were rejected with 'Expected string | undefined'. Decode now retries with null-valued object properties removed when the first attempt fails: schemas that genuinely accept null succeed on the first attempt, array elements stay positional, and the original error is reported when the retry cannot help.
This commit is contained in:
@@ -44,7 +44,38 @@ export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool
|
||||
}
|
||||
})
|
||||
|
||||
const decodeInput = (schema: Tool.ValueSchema<any>, value: unknown) => {
|
||||
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. Null array elements remain
|
||||
// positional while object properties inside arrays are still normalized.
|
||||
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))
|
||||
return Schema.decodeUnknownEffect(schema)(value).pipe(
|
||||
Effect.mapError((error) => new Tool.Error({ message: `Invalid tool input: ${error.message}` })),
|
||||
|
||||
@@ -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" },
|
||||
])
|
||||
})
|
||||
Reference in New Issue
Block a user