Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/tool-invalid-args-actionable-error.md
Original file line number Diff line number Diff line change
@@ -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 `<json-path>: <reason>` line per failing field, and decoding enumerates every offending field instead of stopping at the first.
41 changes: 38 additions & 3 deletions packages/opencode/src/tool/tool.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -31,6 +31,41 @@ export class InvalidArgumentsError extends Schema.TaggedErrorClass<InvalidArgume
}
}

// kilocode_change start

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: New Kilo-only logic added directly to a shared upstream file

formatter, format(), path(), and reason() are ~35 lines of pure Kilo-specific logic added inline to tool.ts, a shared opencode file. Per the Fork Isolation Rule in packages/opencode/AGENTS.md, Kilo-specific logic touching a shared upstream file should be extracted into a mirror file under src/kilocode/tool/tool.ts and called from here behind a single kilocode_change hook, rather than inlined with a kilocode_change start/end block. This keeps the upstream diff minimal for future merges — right now this whole block will conflict with any upstream change to the surrounding error-formatting code.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

const formatter = SchemaIssue.makeFormatterStandardSchemaV1()

function format(error: unknown) {
if (!Schema.isSchemaError(error)) {
return String(error)
}
const result = formatter(error.issue)
if (result.issues.length === 0) {
return String(error)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Fallback can silently reintroduce the exact SchemaError(...) jargon this PR fixes

If Schema.isSchemaError(error) is true but the standard-schema formatter yields zero issues (e.g. a compound/union failure the formatter doesn't flatten to leaf issues), this falls back to String(error), which is the raw, model-unreadable text the PR is trying to eliminate. Since this is a real schema error (not just an unrecognized error shape), it may be worth a more generic-but-still-readable fallback (e.g. error.message) instead of the raw String(error) jargon.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

}
return result.issues.map((issue) => `${path(issue.path)}: ${reason(issue.message)}`).join("\n")
}

function path(keys: ReadonlyArray<unknown> | 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") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Exact string match on an internal library message is fragile

message === "Missing key" depends on an exact, undocumented string from Effect's SchemaIssue formatter. If a future Effect upgrade changes this wording even slightly (casing, punctuation, phrasing), this branch silently stops matching and the reason falls through to the raw internal message again — with no test failure unless the exact .toLowerCase().includes("missing")/"required" assertions catch it. Consider matching more defensively (e.g. message.toLowerCase().includes("missing")) so minor wording changes upstream don't regress the readability fix.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

return "is missing and is required"
}
return message
}
// kilocode_change end

export type Context<M extends Metadata = Metadata> = {
sessionID: SessionID
messageID: MessageID
Expand Down Expand Up @@ -116,12 +151,12 @@ function wrap<Parameters extends Schema.Decoder<unknown>, 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
}),
),
)
Expand Down
80 changes: 71 additions & 9 deletions packages/opencode/test/tool/tool-define.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ function makeTool(id: string, executeFn?: () => void) {
}
}

// kilocode_change start
function invalid(exit: Exit.Exit<unknown, unknown>) {
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* () {
Expand Down Expand Up @@ -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<typeof tool.execute>

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<typeof tool.execute>

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
})
Loading