mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-26 19:31:39 -04:00
refactor(core): normalize tool input errors (#44818)
This commit is contained in:
@@ -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<JsonSchema.JsonSchema, Schema.Codec<unknown> | undefined>({
|
||||
@@ -23,7 +25,7 @@ export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
|
||||
|
||||
export const execute = (tool: Tool.Info<any, any>, 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<any, any>, input: unknown, context: Tool
|
||||
}
|
||||
})
|
||||
|
||||
const decodeInput = (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}` })),
|
||||
const decodeInput = (tool: Tool.Info<any, any>, 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<any>,
|
||||
value: unknown,
|
||||
): Effect.Effect<StandardSchemaV1.Result<unknown>> => {
|
||||
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<StandardSchemaV1.Issue>, value: unknown) => {
|
||||
const details = issues.slice(0, 5).map((issue) => {
|
||||
const path =
|
||||
issue.path?.reduce<string>((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<any>, 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<any, any> & StandardJSONSchemaV1<any, any>,
|
||||
value: unknown,
|
||||
prefix: string,
|
||||
) =>
|
||||
): Effect.Effect<StandardSchemaV1.Result<unknown>> =>
|
||||
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<any>): JsonSchema.JsonSchema => {
|
||||
if (schema === undefined || schema === null) return {}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
|
||||
@@ -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.',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user