diff --git a/packages/core/src/tool/runtime.ts b/packages/core/src/tool/runtime.ts index d3d3a0cc465..b048f180691 100644 --- a/packages/core/src/tool/runtime.ts +++ b/packages/core/src/tool/runtime.ts @@ -31,7 +31,38 @@ export const execute = (tool: Tool.Info, input: unknown, context: Tool } }) -const decodeInput = (schema: Tool.ValueSchema, value: unknown) => { +const decodeInput = (schema: Tool.ValueSchema, 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)[key] !== item) + return changed ? Object.fromEntries(entries) : value +} + +const attemptDecodeInput = (schema: Tool.ValueSchema, value: unknown) => { if (Schema.isSchema(schema)) { if (isForeignSchema(schema)) return foreignSchemaPassthrough(value) return Schema.decodeUnknownEffect(schema)(value).pipe( diff --git a/packages/core/test/tool-input-null.test.ts b/packages/core/test/tool-input-null.test.ts new file mode 100644 index 00000000000..c5d9c41f50a --- /dev/null +++ b/packages/core/test/tool-input-null.test.ts @@ -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 = [] + const input = { + "~standard": { + version: 1, + vendor: "test", + validate: (value: unknown) => { + attempts.push(value) + const record = value as Record + 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" }, + ]) +})