diff --git a/.changeset/tool-invalid-args-actionable-error.md b/.changeset/tool-invalid-args-actionable-error.md new file mode 100644 index 00000000000..270db36e620 --- /dev/null +++ b/.changeset/tool-invalid-args-actionable-error.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Tool invalid-argument errors are now actionable to the model: the raw `SchemaError(...)` fallback at the tool boundary is replaced with one readable `: ` line per failing field, and decoding enumerates every offending field instead of stopping at the first. diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index f072773fad2..59dcf29be3a 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -1,4 +1,4 @@ -import { Effect, Schema } from "effect" +import { Effect, Schema, SchemaIssue } from "effect" // kilocode_change import type { JSONSchema7 } from "@ai-sdk/provider" import type { MessageV2 } from "../session/message-v2" import type { Permission } from "../permission" @@ -31,6 +31,41 @@ export class InvalidArgumentsError extends Schema.TaggedErrorClass `${path(issue.path)}: ${reason(issue.message)}`).join("\n") +} + +function path(keys: ReadonlyArray | undefined) { + const value = (keys ?? []) + .map((key) => { + const segment = typeof key === "object" && key !== null && "key" in key ? key.key : key + return typeof segment === "number" ? `[${segment}]` : `[${JSON.stringify(String(segment))}]` + }) + .join("") + return value || "input" +} + +function reason(message: string) { + if (message.toLowerCase().includes("required")) { + return message + } + if (message === "Missing key") { + return "is missing and is required" + } + return message +} +// kilocode_change end + export type Context = { sessionID: SessionID messageID: MessageID @@ -116,12 +151,12 @@ function wrap, Result extends Metadat ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}), } return Effect.gen(function* () { - const decoded = yield* decode(args).pipe( + const decoded = yield* decode(args, { errors: "all" }).pipe( // kilocode_change Effect.mapError( (error) => new InvalidArgumentsError({ tool: id, - detail: toolInfo.formatValidationError ? toolInfo.formatValidationError(error) : String(error), + detail: toolInfo.formatValidationError ? toolInfo.formatValidationError(error) : format(error), // kilocode_change }), ), ) diff --git a/packages/opencode/test/tool/tool-define.test.ts b/packages/opencode/test/tool/tool-define.test.ts index 08e06043622..8d3431bac76 100644 --- a/packages/opencode/test/tool/tool-define.test.ts +++ b/packages/opencode/test/tool/tool-define.test.ts @@ -37,6 +37,19 @@ function makeTool(id: string, executeFn?: () => void) { } } +// kilocode_change start +function invalid(exit: Exit.Exit) { + expect(Exit.isFailure(exit)).toBe(true) + if (!Exit.isFailure(exit)) { + throw new Error("expected tool execution to fail") + } + const die = exit.cause.reasons.find(Cause.isDieReason) + const error = die?.defect + expect(error).toBeInstanceOf(Tool.InvalidArgumentsError) + return error as Tool.InvalidArgumentsError +} +// kilocode_change end + describe("Tool.define", () => { it.effect("object-defined tool does not mutate the original init object", () => Effect.gen(function* () { @@ -135,19 +148,68 @@ describe("Tool.define", () => { // Missing required `question` field on the first questions[] entry. const exit = yield* execute({ questions: [{ options: ["a"] }] }, makeCtx()).pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (!Exit.isFailure(exit)) return - - // The wrap ends with Effect.orDie, so the failure lives in the cause as a - // defect. Recover the typed instance from there. - const die = exit.cause.reasons.find(Cause.isDieReason) - const error = die?.defect - expect(error).toBeInstanceOf(Tool.InvalidArgumentsError) - const args = error as Tool.InvalidArgumentsError + const args = invalid(exit) // kilocode_change expect(args.tool).toBe("qtest") expect(args.message).toContain("qtest tool was called with invalid arguments") expect(args.message).toContain("Please rewrite the input") expect(args.message).toContain(`["questions"][0]["question"]`) }), ) + + // kilocode_change start + it.effect("invalid args explain missing required scalar fields without SchemaError jargon", () => + Effect.gen(function* () { + const parameters = Schema.Struct({ + pattern: Schema.String, + }) + const info = yield* Tool.define( + "grep", + Effect.succeed({ + description: "test tool", + parameters, + execute() { + return Effect.succeed({ title: "ok", output: "ok", metadata: { truncated: false } }) + }, + }), + ) + const tool = yield* info.init() + const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType + + const exit = yield* execute({}, makeCtx()).pipe(Effect.exit) + const args = invalid(exit) + expect(args.message).toContain("grep tool was called with invalid arguments") + expect(args.message).toContain("Please rewrite the input") + expect(args.detail).toContain(`["pattern"]`) + expect(args.detail.toLowerCase()).toContain("missing") + expect(args.detail.toLowerCase()).toContain("required") + expect(args.message).not.toContain("SchemaError(") + }), + ) + + it.effect("invalid args enumerate multiple failing fields", () => + Effect.gen(function* () { + const parameters = Schema.Struct({ + pattern: Schema.String, + path: Schema.String, + }) + const info = yield* Tool.define( + "multi", + Effect.succeed({ + description: "test tool", + parameters, + execute() { + return Effect.succeed({ title: "ok", output: "ok", metadata: { truncated: false } }) + }, + }), + ) + const tool = yield* info.init() + const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType + + const exit = yield* execute({}, makeCtx()).pipe(Effect.exit) + const args = invalid(exit) + expect(args.detail).toContain(`["pattern"]`) + expect(args.detail).toContain(`["path"]`) + }), + ) + // kilocode_change end })