Compare commits

...
5 changed files with 154 additions and 42 deletions
+58 -33
View File
@@ -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"])
+5 -1
View File
@@ -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()
}),
+84 -6
View File
@@ -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.',
}),
)
})
+2 -1
View File
@@ -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.',
},
})
}),