refactor(tool): clean up native-friendly parameter schemas

This commit is contained in:
Kit Langton 2026-04-18 12:54:03 -04:00
parent 3f8c659056
commit e3650cc425
7 changed files with 120 additions and 26 deletions

View file

@ -9,9 +9,10 @@ export const Parameters = Schema.Struct({
description: 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'", "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)) tokensNum: Schema.Finite.pipe(
.check(Schema.isLessThanOrEqualTo(50000)) Schema.check(Schema.isGreaterThanOrEqualTo(1000), Schema.isLessThanOrEqualTo(50000)),
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000))) Schema.withDecodingDefaultTypeKey(Effect.succeed(5000)),
)
.annotate({ .annotate({
description: 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.", "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 { return {
description: DESCRIPTION, description: DESCRIPTION,
parameters: Parameters, parameters: Parameters,
execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) => execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* ctx.ask({ yield* ctx.ask({
permission: "codesearch", permission: "codesearch",

View file

@ -12,11 +12,11 @@ const MAX_TIMEOUT = 120 * 1000 // 2 minutes
export const Parameters = Schema.Struct({ export const Parameters = Schema.Struct({
url: Schema.String.annotate({ description: "The URL to fetch content from" }), url: Schema.String.annotate({ description: "The URL to fetch content from" }),
format: Schema.Literals(["text", "markdown", "html"]) 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({ .annotate({
description: "The format to return the content in (text, markdown, or html). Defaults to markdown.", 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( export const WebFetchTool = Tool.define(

View file

@ -6,17 +6,21 @@ import DESCRIPTION from "./websearch.txt"
export const Parameters = Schema.Struct({ export const Parameters = Schema.Struct({
query: Schema.String.annotate({ description: "Websearch query" }), 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)", 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: description:
"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')", "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", 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)", description: "Maximum characters for context string optimized for LLMs (default: 10000)",
}), }),
}) })
@ -52,9 +56,9 @@ export const WebSearchTool = Tool.define(
McpExa.SearchArgs, McpExa.SearchArgs,
{ {
query: params.query, query: params.query,
type: params.type || "auto", type: params.type,
numResults: params.numResults || 8, numResults: params.numResults,
livecrawl: params.livecrawl || "fallback", livecrawl: params.livecrawl,
contextMaxCharacters: params.contextMaxCharacters, contextMaxCharacters: params.contextMaxCharacters,
}, },
"25 seconds", "25 seconds",

View file

@ -8,6 +8,14 @@ import z from "zod"
*/ */
export const ZodOverride: unique symbol = Symbol.for("effect-zod/override") 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 // AST nodes are immutable and frequently shared across schemas (e.g. a single
// Schema.Class embedded in multiple parents). Memoizing by node identity // Schema.Class embedded in multiple parents). Memoizing by node identity
// avoids rebuilding equivalent Zod subtrees and keeps derived children stable // 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 // Schema.decodeTo / Schema.transform attach encoding to non-Declaration
// nodes, where we do apply the transform. // nodes, where we do apply the transform.
// //
// Schema.withDecodingDefault also attaches encoding, but we want `.default(v)` // Schema.withDecodingDefault and Schema.withDecodingDefaultTypeKey both
// on the inner Zod rather than a transform wrapper — so optional ASTs whose // attach encodings. For JSON Schema we want those as plain `.default(v)`
// encoding resolves a default from Option.none() route through body()/opt(). // 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 hasEncoding = ast.encoding?.length && ast._tag !== "Declaration"
const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined) const fallback = hasEncoding ? extractDefault(ast) : undefined
const base = hasTransform ? encoded(ast) : body(ast) const hasTransform = hasEncoding && fallback === undefined
return ast.checks?.length ? applyChecks(base, ast.checks, ast) : base 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 // 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 return undefined
} }
function body(ast: SchemaAST.AST): z.ZodTypeAny { function body(ast: SchemaAST.AST, fallback?: { value: unknown }): z.ZodTypeAny {
if (SchemaAST.isOptional(ast)) return opt(ast) if (SchemaAST.isOptional(ast)) return opt(ast, fallback)
switch (ast._tag) { switch (ast._tag) {
case "String": 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) if (ast._tag !== "Union") return fail(ast)
const items = ast.types.filter((item) => item._tag !== "Undefined") const items = ast.types.filter((item) => item._tag !== "Undefined")
const inner = const inner =
@ -273,7 +286,6 @@ function opt(ast: SchemaAST.AST): z.ZodTypeAny {
// Schema.withDecodingDefault attaches an encoding `Link` whose transformation // Schema.withDecodingDefault attaches an encoding `Link` whose transformation
// decode Getter resolves `Option.none()` to `Option.some(default)`. Invoke // decode Getter resolves `Option.none()` to `Option.some(default)`. Invoke
// it to extract the default and emit `.default(...)` instead of `.optional()`. // it to extract the default and emit `.default(...)` instead of `.optional()`.
const fallback = extractDefault(ast)
if (fallback !== undefined) return inner.default(fallback.value) if (fallback !== undefined) return inner.default(fallback.value)
return inner.optional() return inner.optional()
} }

View file

@ -441,6 +441,7 @@ exports[`tool parameters JSON Schema (wire shape) websearch 1`] = `
"type": "number", "type": "number",
}, },
"livecrawl": { "livecrawl": {
"default": "fallback",
"description": "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')", "description": "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
"enum": [ "enum": [
"fallback", "fallback",
@ -449,6 +450,7 @@ exports[`tool parameters JSON Schema (wire shape) websearch 1`] = `
"type": "string", "type": "string",
}, },
"numResults": { "numResults": {
"default": 8,
"description": "Number of search results to return (default: 8)", "description": "Number of search results to return (default: 8)",
"type": "number", "type": "number",
}, },
@ -457,6 +459,7 @@ exports[`tool parameters JSON Schema (wire shape) websearch 1`] = `
"type": "string", "type": "string",
}, },
"type": { "type": {
"default": "auto",
"description": "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search", "description": "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
"enum": [ "enum": [
"auto", "auto",

View file

@ -33,6 +33,9 @@ const parse = <S extends Schema.Decoder<unknown>>(schema: S, input: unknown): S[
const accepts = (schema: Schema.Decoder<unknown>, input: unknown): boolean => const accepts = (schema: Schema.Decoder<unknown>, input: unknown): boolean =>
Result.isSuccess(Schema.decodeUnknownResult(schema)(input)) Result.isSuccess(Schema.decodeUnknownResult(schema)(input))
const toNativeJsonSchema = <S extends Schema.Decoder<unknown>>(schema: S) =>
Schema.toStandardJSONSchemaV1(schema)["~standard"].jsonSchema.input({ target: "draft-2020-12" })
describe("tool parameters", () => { describe("tool parameters", () => {
describe("JSON Schema (wire shape)", () => { describe("JSON Schema (wire shape)", () => {
test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot()) test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot())
@ -54,6 +57,39 @@ describe("tool parameters", () => {
test("write", () => expect(toJsonSchema(Write)).toMatchSnapshot()) 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", () => { describe("apply_patch", () => {
test("accepts patchText", () => { test("accepts patchText", () => {
expect(parse(ApplyPatch, { patchText: "*** Begin Patch\n*** End Patch" })).toEqual({ expect(parse(ApplyPatch, { patchText: "*** Begin Patch\n*** End Patch" })).toEqual({
@ -239,13 +275,21 @@ describe("tool parameters", () => {
describe("webfetch", () => { describe("webfetch", () => {
test("accepts url-only", () => { 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", () => { describe("websearch", () => {
test("accepts query", () => { 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",
})
}) })
}) })

View file

@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { Effect, Schema, SchemaGetter } from "effect" import { Effect, Schema, SchemaGetter } from "effect"
import z from "zod" 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) { function json(schema: z.ZodTypeAny) {
const { $schema: _, ...rest } = z.toJSONSchema(schema) const { $schema: _, ...rest } = z.toJSONSchema(schema)
@ -750,5 +750,35 @@ describe("util.effect-zod", () => {
expect(schema.parse({})).toEqual({}) expect(schema.parse({})).toEqual({})
expect(schema.parse({ foo: "hi" })).toEqual({ foo: "hi" }) 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()
})
}) })
}) })