diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts index e10d21175e..d060a74909 100644 --- a/packages/opencode/src/tool/codesearch.ts +++ b/packages/opencode/src/tool/codesearch.ts @@ -9,9 +9,10 @@ export const Parameters = Schema.Struct({ description: "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'", }), - tokensNum: Schema.Number.check(Schema.isGreaterThanOrEqualTo(1000)) - .check(Schema.isLessThanOrEqualTo(50000)) - .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000))) + tokensNum: Schema.Finite.pipe( + Schema.check(Schema.isGreaterThanOrEqualTo(1000), Schema.isLessThanOrEqualTo(50000)), + Schema.withDecodingDefaultTypeKey(Effect.succeed(5000)), + ) .annotate({ description: "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.", @@ -26,7 +27,7 @@ export const CodeSearchTool = Tool.define( return { description: DESCRIPTION, parameters: Parameters, - execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { yield* ctx.ask({ permission: "codesearch", diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index d2561a1301..57491f896b 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -12,11 +12,11 @@ const MAX_TIMEOUT = 120 * 1000 // 2 minutes export const Parameters = Schema.Struct({ url: Schema.String.annotate({ description: "The URL to fetch content from" }), format: Schema.Literals(["text", "markdown", "html"]) - .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const))) + .pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed("markdown" as const))) .annotate({ description: "The format to return the content in (text, markdown, or html). Defaults to markdown.", }), - timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in seconds (max 120)" }), + timeout: Schema.optional(Schema.Finite).annotate({ description: "Optional timeout in seconds (max 120)" }), }) export const WebFetchTool = Tool.define( diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index ff4c696a25..1b8003f7c3 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -6,17 +6,21 @@ import DESCRIPTION from "./websearch.txt" export const Parameters = Schema.Struct({ query: Schema.String.annotate({ description: "Websearch query" }), - numResults: Schema.optional(Schema.Number).annotate({ + numResults: Schema.Finite.pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed(8))).annotate({ description: "Number of search results to return (default: 8)", }), - livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({ + livecrawl: Schema.Literals(["fallback", "preferred"]).pipe( + Schema.withDecodingDefaultTypeKey(Effect.succeed("fallback" as const)), + ).annotate({ description: "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')", }), - type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({ + type: Schema.Literals(["auto", "fast", "deep"]).pipe( + Schema.withDecodingDefaultTypeKey(Effect.succeed("auto" as const)), + ).annotate({ description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search", }), - contextMaxCharacters: Schema.optional(Schema.Number).annotate({ + contextMaxCharacters: Schema.optional(Schema.Finite).annotate({ description: "Maximum characters for context string optimized for LLMs (default: 10000)", }), }) @@ -52,9 +56,9 @@ export const WebSearchTool = Tool.define( McpExa.SearchArgs, { query: params.query, - type: params.type || "auto", - numResults: params.numResults || 8, - livecrawl: params.livecrawl || "fallback", + type: params.type, + numResults: params.numResults, + livecrawl: params.livecrawl, contextMaxCharacters: params.contextMaxCharacters, }, "25 seconds", diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 332a5c76eb..93b452db89 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -8,6 +8,14 @@ import z from "zod" */ export const ZodOverride: unique symbol = Symbol.for("effect-zod/override") +/** + * Annotation key for attaching a `z.preprocess(...)` coercion to the derived + * zod schema without changing the Effect Schema's runtime behaviour. Useful + * for LLM-facing JSON Schemas that need to accept looser inputs (e.g. string + * → number) than the underlying Schema admits. + */ +export const ZodPreprocess: unique symbol = Symbol.for("effect-zod/preprocess") + // AST nodes are immutable and frequently shared across schemas (e.g. a single // Schema.Class embedded in multiple parents). Memoizing by node identity // avoids rebuilding equivalent Zod subtrees and keeps derived children stable @@ -87,13 +95,18 @@ function bodyWithChecks(ast: SchemaAST.AST): z.ZodTypeAny { // Schema.decodeTo / Schema.transform attach encoding to non-Declaration // nodes, where we do apply the transform. // - // Schema.withDecodingDefault also attaches encoding, but we want `.default(v)` - // on the inner Zod rather than a transform wrapper — so optional ASTs whose - // encoding resolves a default from Option.none() route through body()/opt(). + // Schema.withDecodingDefault and Schema.withDecodingDefaultTypeKey both + // attach encodings. For JSON Schema we want those as plain `.default(v)` + // annotations rather than transform wrappers, so ASTs whose encoding + // resolves a default from Option.none() route through body()/opt(). const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration" - const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined) - const base = hasTransform ? encoded(ast) : body(ast) - return ast.checks?.length ? applyChecks(base, ast.checks, ast) : base + const fallback = hasEncoding ? extractDefault(ast) : undefined + const hasTransform = hasEncoding && fallback === undefined + const base = hasTransform ? encoded(ast) : body(ast, fallback) + const checked = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base + const defaulted = fallback !== undefined && !SchemaAST.isOptional(ast) ? checked.default(fallback.value) : checked + const preprocess = (ast.annotations as { [ZodPreprocess]?: (val: unknown) => unknown } | undefined)?.[ZodPreprocess] + return preprocess ? z.preprocess(preprocess, defaulted) : defaulted } // Walk the encoded side and apply each link's decode to produce the decoded @@ -227,8 +240,8 @@ function issueMessage(issue: any): string | undefined { return undefined } -function body(ast: SchemaAST.AST): z.ZodTypeAny { - if (SchemaAST.isOptional(ast)) return opt(ast) +function body(ast: SchemaAST.AST, fallback?: { value: unknown }): z.ZodTypeAny { + if (SchemaAST.isOptional(ast)) return opt(ast, fallback) switch (ast._tag) { case "String": @@ -261,7 +274,7 @@ function body(ast: SchemaAST.AST): z.ZodTypeAny { } } -function opt(ast: SchemaAST.AST): z.ZodTypeAny { +function opt(ast: SchemaAST.AST, fallback = extractDefault(ast)): z.ZodTypeAny { if (ast._tag !== "Union") return fail(ast) const items = ast.types.filter((item) => item._tag !== "Undefined") const inner = @@ -273,7 +286,6 @@ function opt(ast: SchemaAST.AST): z.ZodTypeAny { // Schema.withDecodingDefault attaches an encoding `Link` whose transformation // decode Getter resolves `Option.none()` to `Option.some(default)`. Invoke // it to extract the default and emit `.default(...)` instead of `.optional()`. - const fallback = extractDefault(ast) if (fallback !== undefined) return inner.default(fallback.value) return inner.optional() } diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index eb3fe6cce4..0e92ae7d2e 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -441,6 +441,7 @@ exports[`tool parameters JSON Schema (wire shape) websearch 1`] = ` "type": "number", }, "livecrawl": { + "default": "fallback", "description": "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')", "enum": [ "fallback", @@ -449,6 +450,7 @@ exports[`tool parameters JSON Schema (wire shape) websearch 1`] = ` "type": "string", }, "numResults": { + "default": 8, "description": "Number of search results to return (default: 8)", "type": "number", }, @@ -457,6 +459,7 @@ exports[`tool parameters JSON Schema (wire shape) websearch 1`] = ` "type": "string", }, "type": { + "default": "auto", "description": "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search", "enum": [ "auto", diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 8ea008a457..9a4679674d 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -33,6 +33,9 @@ const parse = >(schema: S, input: unknown): S[ const accepts = (schema: Schema.Decoder, input: unknown): boolean => Result.isSuccess(Schema.decodeUnknownResult(schema)(input)) +const toNativeJsonSchema = >(schema: S) => + Schema.toStandardJSONSchemaV1(schema)["~standard"].jsonSchema.input({ target: "draft-2020-12" }) + describe("tool parameters", () => { describe("JSON Schema (wire shape)", () => { test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot()) @@ -54,6 +57,39 @@ describe("tool parameters", () => { test("write", () => expect(toJsonSchema(Write)).toMatchSnapshot()) }) + describe("native JSON Schema (experimental tool route)", () => { + test("codesearch uses a plain finite number", () => { + const native = toNativeJsonSchema(CodeSearch) as any + expect(native.properties.tokensNum).toEqual({ + type: "number", + allOf: [{ minimum: 1000 }, { maximum: 50000 }], + }) + }) + + test("webfetch format stays a string enum", () => { + const native = toNativeJsonSchema(WebFetch) as any + expect(native.properties.format).toEqual({ + type: "string", + enum: ["text", "markdown", "html"], + }) + }) + + test("websearch defaulted fields stay non-nullable", () => { + const native = toNativeJsonSchema(WebSearch) as any + expect(native.properties.numResults).toEqual({ + type: "number", + }) + expect(native.properties.livecrawl).toEqual({ + type: "string", + enum: ["fallback", "preferred"], + }) + expect(native.properties.type).toEqual({ + type: "string", + enum: ["auto", "fast", "deep"], + }) + }) + }) + describe("apply_patch", () => { test("accepts patchText", () => { expect(parse(ApplyPatch, { patchText: "*** Begin Patch\n*** End Patch" })).toEqual({ @@ -239,13 +275,21 @@ describe("tool parameters", () => { describe("webfetch", () => { test("accepts url-only", () => { - expect(parse(WebFetch, { url: "https://example.com" }).url).toBe("https://example.com") + expect(parse(WebFetch, { url: "https://example.com" })).toEqual({ + url: "https://example.com", + format: "markdown", + }) }) }) describe("websearch", () => { test("accepts query", () => { - expect(parse(WebSearch, { query: "opencode" }).query).toBe("opencode") + expect(parse(WebSearch, { query: "opencode" })).toEqual({ + query: "opencode", + numResults: 8, + livecrawl: "fallback", + type: "auto", + }) }) }) diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 70cd8f0e64..87325fbb55 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema, SchemaGetter } from "effect" import z from "zod" -import { zod, ZodOverride } from "../../src/util/effect-zod" +import { toJsonSchema, zod, ZodOverride, ZodPreprocess } from "../../src/util/effect-zod" function json(schema: z.ZodTypeAny) { const { $schema: _, ...rest } = z.toJSONSchema(schema) @@ -750,5 +750,35 @@ describe("util.effect-zod", () => { expect(schema.parse({})).toEqual({}) expect(schema.parse({ foo: "hi" })).toEqual({ foo: "hi" }) }) + + test("key defaults fill in missing struct keys", () => { + const schema = zod( + Schema.Struct({ + mode: Schema.String.pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed("ctrl-x"))), + }), + ) + + expect(schema.parse({})).toEqual({ mode: "ctrl-x" }) + }) + + test("key defaults still accept explicit values", () => { + const schema = zod( + Schema.Struct({ + mode: Schema.String.pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed("ctrl-x"))), + }), + ) + + expect(schema.parse({ mode: "ctrl-c" })).toEqual({ mode: "ctrl-c" }) + }) + + test("JSON Schema output includes the default key for key defaults", () => { + const shape = toJsonSchema( + Schema.Struct({ + mode: Schema.String.pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed("ctrl-x"))), + }), + ) as any + expect(shape.properties.mode.default).toBe("ctrl-x") + expect(shape.required).toBeUndefined() + }) }) })