From d78c13fce308a04a66fa488f1c7c20fbb664698c Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:23:25 -0500 Subject: [PATCH] refactor(core): normalize tool input errors (#44818) --- packages/core/src/tool/runtime.ts | 91 ++++++++++++------- .../test/session-runner-tool-registry.test.ts | 6 +- packages/core/test/tool-question.test.ts | 6 +- packages/core/test/tool-schema.test.ts | 90 ++++++++++++++++-- packages/core/test/tool-search.test.ts | 3 +- 5 files changed, 154 insertions(+), 42 deletions(-) diff --git a/packages/core/src/tool/runtime.ts b/packages/core/src/tool/runtime.ts index a82b82c9fb6..c8ae6d94819 100644 --- a/packages/core/src/tool/runtime.ts +++ b/packages/core/src/tool/runtime.ts @@ -1,7 +1,9 @@ import type { ToolDefinition } from "@opencode-ai/ai" import { Tool } from "@opencode-ai/schema/tool" import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec" -import { Cache, Effect, JsonSchema, Schema, SchemaRepresentation } from "effect" +import { Cache, Effect, JsonSchema, Schema, SchemaIssue, SchemaRepresentation } from "effect" + +const formatEffectIssues = SchemaIssue.makeFormatterStandardSchemaV1() const jsonSchemas = Effect.runSync( Cache.make | undefined>({ @@ -23,7 +25,7 @@ export const definition = (tool: Tool.Info): ToolDefinition => ({ export const execute = (tool: Tool.Info, input: unknown, context: Tool.Context) => Effect.gen(function* () { - const decoded = yield* decodeInput(tool.input, input) + const decoded = yield* decodeInput(tool, input) // Tool implementations declare `Tool.Error` but plugins can fail with anything at // runtime. A foreign typed failure would slip past every `catchTag("Tool.Error")` // downstream and leave its call permanently unsettled, so the declared contract is @@ -55,18 +57,43 @@ export const execute = (tool: Tool.Info, input: unknown, context: Tool } }) -const decodeInput = (schema: Tool.ValueSchema, value: unknown) => { - if (Schema.isSchema(schema)) - return Schema.decodeUnknownEffect(schema)(value).pipe( - Effect.mapError((error) => new Tool.Error({ message: `Invalid tool input: ${error.message}` })), +const decodeInput = (tool: Tool.Info, value: unknown) => + Effect.gen(function* () { + const result = yield* validateInput(tool.input, value) + if (result.issues) + return yield* new Tool.Error({ message: formatInputIssues(effectiveName(tool), result.issues, value) }) + return result.value + }) + +const validateInput = ( + schema: Tool.ValueSchema, + value: unknown, +): Effect.Effect> => { + if (isStandardSchema(schema)) return validateStandard(schema, value) + return Effect.gen(function* () { + const codec = Schema.isSchema(schema) ? schema : yield* Cache.get(jsonSchemas, schema) + if (codec === undefined) return { value } + return yield* Schema.decodeUnknownEffect(codec)(value, { errors: "all" }).pipe( + Effect.match({ + onFailure: (error) => formatEffectIssues(error.issue), + onSuccess: (value) => ({ value }), + }), ) - if (isStandardSchema(schema)) return validateStandard(schema, value, "Invalid tool input") - return Cache.get(jsonSchemas, schema).pipe( - Effect.flatMap((schema) => - schema === undefined ? Effect.succeed(value) : Schema.decodeUnknownEffect(schema)(value), - ), - Effect.mapError((error) => new Tool.Error({ message: `Invalid tool input: ${error.message}` })), - ) + }) +} + +const formatInputIssues = (tool: string, issues: ReadonlyArray, value: unknown) => { + const details = issues.slice(0, 5).map((issue) => { + const path = + issue.path?.reduce((path, segment) => { + const key = typeof segment === "object" ? segment.key : segment + if (typeof key === "number") return `${path}[${key}]` + return path === "" ? String(key) : `${path}.${String(key)}` + }, "") || "root" + return `- ${path}: ${issue.message}` + }) + if (issues.length > 5) details.push(`- ...and ${issues.length - 5} more ${issues.length === 6 ? "issue" : "issues"}`) + return `Invalid arguments for tool "${tool}":\n${details.join("\n")}\n\nArguments provided:\n${JSON.stringify(value, null, 2)}\n\nUpdate the arguments and call the tool again.` } const jsonSchema = (schema: JsonSchema.JsonSchema) => { @@ -86,7 +113,15 @@ const encodeOutput = (schema: Tool.ValueSchema, value: unknown) => { ), ) if (isStandardSchema(schema)) - return validateStandard(schema, value, "Tool returned an invalid value for its output schema") + return validateStandard(schema, value).pipe( + Effect.flatMap((result) => + result.issues + ? new Tool.Error({ + message: `Tool returned an invalid value for its output schema: ${result.issues.map((issue) => issue.message).join(", ")}`, + }) + : Effect.succeed(result.value), + ), + ) return Schema.decodeUnknownEffect(Schema.Json)(value).pipe( Effect.mapError( (error) => new Tool.Error({ message: `Tool returned a non-JSON value for its output schema: ${error.message}` }), @@ -102,26 +137,16 @@ const isStandardSchema = ( const validateStandard = ( schema: StandardSchemaV1 & StandardJSONSchemaV1, value: unknown, - prefix: string, -) => +): Effect.Effect> => Effect.gen(function* () { - const pending = yield* Effect.try({ - try: () => schema["~standard"].validate(value), - catch: (error) => standardFailure(prefix, error), - }) - const result = - pending instanceof Promise - ? yield* Effect.tryPromise({ try: () => pending, catch: (error) => standardFailure(prefix, error) }) - : pending - if (result.issues) - return yield* new Tool.Error({ - message: `${prefix}: ${result.issues.map((issue) => issue.message).join(", ")}`, - }) - return result.value - }) - -const standardFailure = (prefix: string, error: unknown) => - new Tool.Error({ message: `${prefix}: ${error instanceof Error ? error.message : String(error)}` }) + const result = yield* Effect.try({ try: () => schema["~standard"].validate(value), catch: (error) => error }) + return result instanceof Promise ? yield* Effect.tryPromise({ try: () => result, catch: (error) => error }) : result + }).pipe( + Effect.match({ + onFailure: (error) => ({ issues: [{ message: error instanceof Error ? error.message : String(error) }] }), + onSuccess: (result) => result, + }), + ) const inputJsonSchema = (schema: Tool.ValueSchema): JsonSchema.JsonSchema => { if (schema === undefined || schema === null) return {} diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index e6ebfcd6048..bc3c547124d 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -511,7 +511,11 @@ describe("Tool", () => { }), ).toMatchObject({ status: "error", - error: { type: "tool.execution", message: expect.stringContaining("Invalid tool input") }, + error: { + type: "tool.execution", + message: + 'Invalid arguments for tool "transformed":\n- value: Expected boolean\n\nArguments provided:\n{\n "value": "yes"\n}\n\nUpdate the arguments and call the tool again.', + }, }) expect(executed).toEqual(["yes"]) diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index d8a78fe3d7e..dfd45715ec8 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -100,7 +100,11 @@ describe("QuestionTool", () => { }), ).toMatchObject({ status: "error", - error: { type: "tool.execution", message: expect.stringContaining("Invalid tool input") }, + error: { + type: "tool.execution", + message: + 'Invalid arguments for tool "question":\n- questions: Expected a value with a length of at least 1\n\nArguments provided:\n{\n "questions": []\n}\n\nUpdate the arguments and call the tool again.', + }, }) expect(capturedInput()).toBeUndefined() }), diff --git a/packages/core/test/tool-schema.test.ts b/packages/core/test/tool-schema.test.ts index 220eb732792..05902c15bb7 100644 --- a/packages/core/test/tool-schema.test.ts +++ b/packages/core/test/tool-schema.test.ts @@ -144,7 +144,12 @@ test("portable schema failures become tool failures", async () => { "~standard": { version: 1, vendor: "test", - validate: (_value: unknown) => ({ issues: [{ message: "expected a string" }] }), + validate: (_value: unknown) => ({ + issues: [ + { path: ["value"], message: "expected a string" }, + { path: [{ key: "nested" }, { key: "count" }], message: "expected a positive integer" }, + ], + }), jsonSchema: { input: () => ({ type: "string" }), output: () => ({ type: "string" }), @@ -166,7 +171,62 @@ test("portable schema failures become tool failures", async () => { ), ), ) - expect(error).toEqual(new Tool.Error({ message: "Invalid tool input: expected a string" })) + expect(error).toEqual( + new Tool.Error({ + message: + 'Invalid arguments for tool "invalid":\n- value: expected a string\n- nested.count: expected a positive integer\n\nArguments provided:\n1\n\nUpdate the arguments and call the tool again.', + }), + ) +}) + +test("Effect schema failures use normalized input issues", async () => { + const tool: Info = { + name: "effect", + description: "Effect tool", + input: Schema.Struct({ + value: Schema.String, + nested: Schema.Struct({ count: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)) }), + }), + execute: () => Effect.succeed({ content: "unused" }), + } + + expect( + await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, {} as Tool.Context))), + ).toEqual( + new Tool.Error({ + message: + 'Invalid arguments for tool "effect":\n- value: Expected string\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": 1,\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.', + }), + ) +}) + +test("input error prompts limit normalized issues", async () => { + const input = { + "~standard": { + version: 1, + vendor: "test", + validate: (_value: unknown) => ({ + issues: Array.from({ length: 6 }, (_, index) => ({ message: `issue ${index + 1}` })), + }), + jsonSchema: { + input: () => ({}), + output: () => ({}), + }, + }, + } + const tool: Info = { + name: "limited", + description: "Limited issues", + input, + execute: () => Effect.succeed({ content: "unused" }), + } + + expect(await Effect.runPromise(Effect.flip(execute(tool, {}, {} as Tool.Context)))).toEqual( + new Tool.Error({ + message: + 'Invalid arguments for tool "limited":\n- root: issue 1\n- root: issue 2\n- root: issue 3\n- root: issue 4\n- root: issue 5\n- ...and 1 more issue\n\nArguments provided:\n{}\n\nUpdate the arguments and call the tool again.', + }), + ) }) test("canonical results carry metadata with typed output", async () => { @@ -219,16 +279,31 @@ test("raw JSON schemas validate and decode tool input", async () => { content: [{ type: "text", text: '{"value":"ok"}' }], }) expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, {} as Tool.Context)))).toEqual( - new Tool.Error({ message: 'Invalid tool input: Expected string\n at ["value"]' }), + new Tool.Error({ + message: + 'Invalid arguments for tool "raw":\n- value: Expected string\n\nArguments provided:\n{\n "value": 1\n}\n\nUpdate the arguments and call the tool again.', + }), ) expect(await Effect.runPromise(Effect.flip(execute(tool, {}, {} as Tool.Context)))).toEqual( - new Tool.Error({ message: 'Invalid tool input: Missing key\n at ["value"]' }), + new Tool.Error({ + message: + 'Invalid arguments for tool "raw":\n- value: Missing key\n\nArguments provided:\n{}\n\nUpdate the arguments and call the tool again.', + }), ) expect( await Effect.runPromise(Effect.flip(execute(tool, { value: "ok", nested: { count: 0 } }, {} as Tool.Context))), ).toEqual( new Tool.Error({ - message: 'Invalid tool input: Expected a value greater than or equal to 1\n at ["nested"]["count"]', + message: + 'Invalid arguments for tool "raw":\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": "ok",\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.', + }), + ) + expect( + await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, {} as Tool.Context))), + ).toEqual( + new Tool.Error({ + message: + 'Invalid arguments for tool "raw":\n- value: Expected string\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": 1,\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.', }), ) }) @@ -250,7 +325,10 @@ test("raw JSON schemas resolve draft-07 definitions", async () => { content: [{ type: "text", text: '{"value":"ok"}' }], }) expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, {} as Tool.Context)))).toEqual( - new Tool.Error({ message: 'Invalid tool input: Expected value\n at ["value"]' }), + new Tool.Error({ + message: + 'Invalid arguments for tool "draft-07":\n- value: Expected value\n\nArguments provided:\n{\n "value": 1\n}\n\nUpdate the arguments and call the tool again.', + }), ) }) diff --git a/packages/core/test/tool-search.test.ts b/packages/core/test/tool-search.test.ts index 5c04f81573f..e8b953a26b5 100644 --- a/packages/core/test/tool-search.test.ts +++ b/packages/core/test/tool-search.test.ts @@ -118,7 +118,8 @@ describe("search tools", () => { status: "error", error: { type: "tool.execution", - message: 'Invalid tool input: Pattern must not be empty\n at ["pattern"]', + message: + 'Invalid arguments for tool "grep":\n- pattern: Pattern must not be empty\n\nArguments provided:\n{\n "pattern": ""\n}\n\nUpdate the arguments and call the tool again.', }, }) }),