diff --git a/packages/core/src/tool/runtime.ts b/packages/core/src/tool/runtime.ts index 67812827c2e..a82b82c9fb6 100644 --- a/packages/core/src/tool/runtime.ts +++ b/packages/core/src/tool/runtime.ts @@ -1,7 +1,18 @@ import type { ToolDefinition } from "@opencode-ai/ai" import { Tool } from "@opencode-ai/schema/tool" import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec" -import { Effect, JsonSchema, Schema } from "effect" +import { Cache, Effect, JsonSchema, Schema, SchemaRepresentation } from "effect" + +const jsonSchemas = Effect.runSync( + Cache.make | undefined>({ + capacity: 100, + lookup: (schema) => + Effect.try({ + try: () => jsonSchema(schema), + catch: () => undefined, + }).pipe(Effect.orElseSucceed(() => undefined)), + }), +) export const definition = (tool: Tool.Info): ToolDefinition => ({ name: effectiveName(tool), @@ -50,7 +61,20 @@ const decodeInput = (schema: Tool.ValueSchema, value: unknown) => { Effect.mapError((error) => new Tool.Error({ message: `Invalid tool input: ${error.message}` })), ) if (isStandardSchema(schema)) return validateStandard(schema, value, "Invalid tool input") - return Effect.succeed(value) + 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 jsonSchema = (schema: JsonSchema.JsonSchema) => { + const draft = + (typeof schema.$schema === "string" && schema.$schema.includes("draft-07")) || "definitions" in schema + ? JsonSchema.fromSchemaDraft07(schema) + : JsonSchema.fromSchemaDraft2020_12(schema) + return Schema.make>(SchemaRepresentation.fromJsonSchemaDocument(draft).ast) } const encodeOutput = (schema: Tool.ValueSchema, value: unknown) => { diff --git a/packages/core/test/tool-schema.test.ts b/packages/core/test/tool-schema.test.ts index bf9c9689090..220eb732792 100644 --- a/packages/core/test/tool-schema.test.ts +++ b/packages/core/test/tool-schema.test.ts @@ -152,19 +152,21 @@ test("portable schema failures become tool failures", async () => { }, } - const error = await Effect.runPromiseExit( - execute( - { - name: "invalid", - description: "Invalid", - input, - execute: () => Effect.succeed({ content: "unused" }), - }, - 1, - {} as Tool.Context, + const error = await Effect.runPromise( + Effect.flip( + execute( + { + name: "invalid", + description: "Invalid", + input, + execute: () => Effect.succeed({ content: "unused" }), + }, + 1, + {} as Tool.Context, + ), ), ) - expect(error.toString()).toContain("Invalid tool input: expected a string") + expect(error).toEqual(new Tool.Error({ message: "Invalid tool input: expected a string" })) }) test("canonical results carry metadata with typed output", async () => { @@ -185,8 +187,21 @@ test("canonical results carry metadata with typed output", async () => { }) }) -test("raw JSON schemas are render-only and omitted output means model-only", async () => { - const input = { type: "object", properties: { value: { type: "string" } } } +test("raw JSON schemas validate and decode tool input", async () => { + const input = { + type: "object", + properties: { + value: { type: "string" }, + nested: { + type: "object", + properties: { count: { type: "integer", minimum: 1 } }, + required: ["count"], + additionalProperties: false, + }, + }, + required: ["value"], + additionalProperties: false, + } const tool: Info = { name: "raw", description: "Raw tool", @@ -197,11 +212,61 @@ test("raw JSON schemas are render-only and omitted output means model-only", asy expect(definition(tool)).toEqual({ name: "raw", description: "Raw tool", - inputSchema: { type: "object", properties: { value: { type: "string" } } }, + inputSchema: input, }) - expect(await Effect.runPromise(execute(tool, { value: 1 }, {} as Tool.Context))).toEqual({ + expect(await Effect.runPromise(execute(tool, { value: "ok", extra: true }, {} as Tool.Context))).toEqual({ output: undefined, - content: [{ type: "text", text: '{"value":1}' }], + 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"]' }), + ) + expect(await Effect.runPromise(Effect.flip(execute(tool, {}, {} as Tool.Context)))).toEqual( + new Tool.Error({ message: 'Invalid tool input: Missing key\n at ["value"]' }), + ) + 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"]', + }), + ) +}) + +test("raw JSON schemas resolve draft-07 definitions", async () => { + const tool: Info = { + name: "draft-07", + description: "Draft-07 tool", + input: { + type: "object", + properties: { value: { $ref: "#/definitions/value" } }, + required: ["value"], + definitions: { value: { type: "string" } }, + }, + execute: (input) => Effect.succeed({ content: JSON.stringify(input) }), + } + + expect(await Effect.runPromise(execute(tool, { value: "ok" }, {} as Tool.Context))).toMatchObject({ + 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"]' }), + ) +}) + +test("raw JSON schemas pass input through when they cannot be imported", async () => { + const tool: Info = { + name: "invalid-schema", + description: "Invalid schema tool", + input: { + type: "object", + properties: { value: { $ref: "#/$defs/missing" } }, + }, + execute: (input) => Effect.succeed({ content: JSON.stringify(input) }), + } + + expect(await Effect.runPromise(execute(tool, { value: 1, extra: true }, {} as Tool.Context))).toMatchObject({ + content: [{ type: "text", text: '{"value":1,"extra":true}' }], }) })