refactor(tool): migrate tool framework + all 18 built-in tools to Effect Schema (#23244)

This commit is contained in:
Kit Langton
2026-04-23 16:09:34 -04:00
committed by GitHub
parent 24892559ae
commit 3910a6e527
25 changed files with 1035 additions and 205 deletions

View File

@@ -0,0 +1,495 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`tool parameters JSON Schema (wire shape) apply_patch 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"patchText": {
"description": "The full patch text that describes all changes to be made",
"type": "string",
},
},
"required": [
"patchText",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) bash 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"command": {
"description": "The command to execute",
"type": "string",
},
"description": {
"description":
"Clear, concise description of what this command does in 5-10 words. Examples:
Input: ls
Output: Lists files in current directory
Input: git status
Output: Shows working tree status
Input: npm install
Output: Installs package dependencies
Input: mkdir foo
Output: Creates directory 'foo'"
,
"type": "string",
},
"timeout": {
"description": "Optional timeout in milliseconds",
"type": "number",
},
"workdir": {
"description": "The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.",
"type": "string",
},
},
"required": [
"command",
"description",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) codesearch 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"query": {
"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'",
"type": "string",
},
"tokensNum": {
"default": 5000,
"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.",
"maximum": 50000,
"minimum": 1000,
"type": "number",
},
},
"required": [
"query",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) edit 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"filePath": {
"description": "The absolute path to the file to modify",
"type": "string",
},
"newString": {
"description": "The text to replace it with (must be different from oldString)",
"type": "string",
},
"oldString": {
"description": "The text to replace",
"type": "string",
},
"replaceAll": {
"description": "Replace all occurrences of oldString (default false)",
"type": "boolean",
},
},
"required": [
"filePath",
"oldString",
"newString",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) glob 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"path": {
"description": "The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.",
"type": "string",
},
"pattern": {
"description": "The glob pattern to match files against",
"type": "string",
},
},
"required": [
"pattern",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) grep 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"include": {
"description": "File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")",
"type": "string",
},
"path": {
"description": "The directory to search in. Defaults to the current working directory.",
"type": "string",
},
"pattern": {
"description": "The regex pattern to search for in file contents",
"type": "string",
},
},
"required": [
"pattern",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) invalid 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"error": {
"type": "string",
},
"tool": {
"type": "string",
},
},
"required": [
"tool",
"error",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) lsp 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"character": {
"description": "The character offset (1-based, as shown in editors)",
"maximum": 9007199254740991,
"minimum": 1,
"type": "integer",
},
"filePath": {
"description": "The absolute or relative path to the file",
"type": "string",
},
"line": {
"description": "The line number (1-based, as shown in editors)",
"maximum": 9007199254740991,
"minimum": 1,
"type": "integer",
},
"operation": {
"description": "The LSP operation to perform",
"enum": [
"goToDefinition",
"findReferences",
"hover",
"documentSymbol",
"workspaceSymbol",
"goToImplementation",
"prepareCallHierarchy",
"incomingCalls",
"outgoingCalls",
],
"type": "string",
},
},
"required": [
"operation",
"filePath",
"line",
"character",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) plan 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {},
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) question 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"questions": {
"description": "Questions to ask",
"items": {
"properties": {
"header": {
"description": "Very short label (max 30 chars)",
"type": "string",
},
"multiple": {
"description": "Allow selecting multiple choices",
"type": "boolean",
},
"options": {
"description": "Available choices",
"items": {
"properties": {
"description": {
"description": "Explanation of choice",
"type": "string",
},
"label": {
"description": "Display text (1-5 words, concise)",
"type": "string",
},
},
"ref": "QuestionOption",
"required": [
"label",
"description",
],
"type": "object",
},
"type": "array",
},
"question": {
"description": "Complete question",
"type": "string",
},
},
"ref": "QuestionPrompt",
"required": [
"question",
"header",
"options",
],
"type": "object",
},
"type": "array",
},
},
"required": [
"questions",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) read 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"filePath": {
"description": "The absolute path to the file or directory to read",
"type": "string",
},
"limit": {
"description": "The maximum number of lines to read (defaults to 2000)",
"type": "number",
},
"offset": {
"description": "The line number to start reading from (1-indexed)",
"type": "number",
},
},
"required": [
"filePath",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) skill 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"name": {
"description": "The name of the skill from available_skills",
"type": "string",
},
},
"required": [
"name",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) task 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"command": {
"description": "The command that triggered this task",
"type": "string",
},
"description": {
"description": "A short (3-5 words) description of the task",
"type": "string",
},
"prompt": {
"description": "The task for the agent to perform",
"type": "string",
},
"subagent_type": {
"description": "The type of specialized agent to use for this task",
"type": "string",
},
"task_id": {
"description": "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
"type": "string",
},
},
"required": [
"description",
"prompt",
"subagent_type",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) todo 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"todos": {
"description": "The updated todo list",
"items": {
"properties": {
"content": {
"description": "Brief description of the task",
"type": "string",
},
"priority": {
"description": "Priority level of the task: high, medium, low",
"type": "string",
},
"status": {
"description": "Current status of the task: pending, in_progress, completed, cancelled",
"type": "string",
},
},
"required": [
"content",
"status",
"priority",
],
"type": "object",
},
"type": "array",
},
},
"required": [
"todos",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) webfetch 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"format": {
"default": "markdown",
"description": "The format to return the content in (text, markdown, or html). Defaults to markdown.",
"enum": [
"text",
"markdown",
"html",
],
"type": "string",
},
"timeout": {
"description": "Optional timeout in seconds (max 120)",
"type": "number",
},
"url": {
"description": "The URL to fetch content from",
"type": "string",
},
},
"required": [
"url",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) websearch 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"contextMaxCharacters": {
"description": "Maximum characters for context string optimized for LLMs (default: 10000)",
"type": "number",
},
"livecrawl": {
"description": "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
"enum": [
"fallback",
"preferred",
],
"type": "string",
},
"numResults": {
"description": "Number of search results to return (default: 8)",
"type": "number",
},
"query": {
"description": "Websearch query",
"type": "string",
},
"type": {
"description": "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
"enum": [
"auto",
"fast",
"deep",
],
"type": "string",
},
},
"required": [
"query",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) write 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"content": {
"description": "The content to write to the file",
"type": "string",
},
"filePath": {
"description": "The absolute path to the file to write (must be absolute, not relative)",
"type": "string",
},
},
"required": [
"content",
"filePath",
],
"type": "object",
}
`;

View File

@@ -0,0 +1,260 @@
import { describe, expect, test } from "bun:test"
import { Result, Schema } from "effect"
import { toJsonSchema } from "../../src/util/effect-zod"
// Each tool exports its parameters schema at module scope so this test can
// import them without running the tool's Effect-based init. The JSON Schema
// snapshot captures what the LLM sees; the parse assertions pin down the
// accepts/rejects contract. `toJsonSchema` is the same helper `session/
// prompt.ts` uses to emit tool schemas to the LLM, so the snapshots stay
// byte-identical regardless of whether a tool has migrated from zod to Schema.
import { Parameters as ApplyPatch } from "../../src/tool/apply_patch"
import { Parameters as Bash } from "../../src/tool/bash"
import { Parameters as CodeSearch } from "../../src/tool/codesearch"
import { Parameters as Edit } from "../../src/tool/edit"
import { Parameters as Glob } from "../../src/tool/glob"
import { Parameters as Grep } from "../../src/tool/grep"
import { Parameters as Invalid } from "../../src/tool/invalid"
import { Parameters as Lsp } from "../../src/tool/lsp"
import { Parameters as Plan } from "../../src/tool/plan"
import { Parameters as Question } from "../../src/tool/question"
import { Parameters as Read } from "../../src/tool/read"
import { Parameters as Skill } from "../../src/tool/skill"
import { Parameters as Task } from "../../src/tool/task"
import { Parameters as Todo } from "../../src/tool/todo"
import { Parameters as WebFetch } from "../../src/tool/webfetch"
import { Parameters as WebSearch } from "../../src/tool/websearch"
import { Parameters as Write } from "../../src/tool/write"
const parse = <S extends Schema.Decoder<unknown>>(schema: S, input: unknown): S["Type"] =>
Schema.decodeUnknownSync(schema)(input)
const accepts = (schema: Schema.Decoder<unknown>, input: unknown): boolean =>
Result.isSuccess(Schema.decodeUnknownResult(schema)(input))
describe("tool parameters", () => {
describe("JSON Schema (wire shape)", () => {
test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot())
test("bash", () => expect(toJsonSchema(Bash)).toMatchSnapshot())
test("codesearch", () => expect(toJsonSchema(CodeSearch)).toMatchSnapshot())
test("edit", () => expect(toJsonSchema(Edit)).toMatchSnapshot())
test("glob", () => expect(toJsonSchema(Glob)).toMatchSnapshot())
test("grep", () => expect(toJsonSchema(Grep)).toMatchSnapshot())
test("invalid", () => expect(toJsonSchema(Invalid)).toMatchSnapshot())
test("lsp", () => expect(toJsonSchema(Lsp)).toMatchSnapshot())
test("plan", () => expect(toJsonSchema(Plan)).toMatchSnapshot())
test("question", () => expect(toJsonSchema(Question)).toMatchSnapshot())
test("read", () => expect(toJsonSchema(Read)).toMatchSnapshot())
test("skill", () => expect(toJsonSchema(Skill)).toMatchSnapshot())
test("task", () => expect(toJsonSchema(Task)).toMatchSnapshot())
test("todo", () => expect(toJsonSchema(Todo)).toMatchSnapshot())
test("webfetch", () => expect(toJsonSchema(WebFetch)).toMatchSnapshot())
test("websearch", () => expect(toJsonSchema(WebSearch)).toMatchSnapshot())
test("write", () => expect(toJsonSchema(Write)).toMatchSnapshot())
})
describe("apply_patch", () => {
test("accepts patchText", () => {
expect(parse(ApplyPatch, { patchText: "*** Begin Patch\n*** End Patch" })).toEqual({
patchText: "*** Begin Patch\n*** End Patch",
})
})
test("rejects missing patchText", () => {
expect(accepts(ApplyPatch, {})).toBe(false)
})
test("rejects non-string patchText", () => {
expect(accepts(ApplyPatch, { patchText: 123 })).toBe(false)
})
})
describe("bash", () => {
test("accepts minimum: command + description", () => {
expect(parse(Bash, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" })
})
test("accepts optional timeout + workdir", () => {
const parsed = parse(Bash, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" })
expect(parsed.timeout).toBe(5000)
expect(parsed.workdir).toBe("/tmp")
})
test("rejects missing description (required by zod)", () => {
expect(accepts(Bash, { command: "ls" })).toBe(false)
})
test("rejects missing command", () => {
expect(accepts(Bash, { description: "list" })).toBe(false)
})
})
describe("codesearch", () => {
test("accepts query; tokensNum defaults to 5000", () => {
expect(parse(CodeSearch, { query: "hooks" })).toEqual({ query: "hooks", tokensNum: 5000 })
})
test("accepts override tokensNum", () => {
expect(parse(CodeSearch, { query: "hooks", tokensNum: 10000 }).tokensNum).toBe(10000)
})
test("rejects tokensNum under 1000", () => {
expect(accepts(CodeSearch, { query: "x", tokensNum: 500 })).toBe(false)
})
test("rejects tokensNum over 50000", () => {
expect(accepts(CodeSearch, { query: "x", tokensNum: 60000 })).toBe(false)
})
})
describe("edit", () => {
test("accepts all four fields", () => {
expect(parse(Edit, { filePath: "/a", oldString: "x", newString: "y", replaceAll: true })).toEqual({
filePath: "/a",
oldString: "x",
newString: "y",
replaceAll: true,
})
})
test("replaceAll is optional", () => {
const parsed = parse(Edit, { filePath: "/a", oldString: "x", newString: "y" })
expect(parsed.replaceAll).toBeUndefined()
})
test("rejects missing filePath", () => {
expect(accepts(Edit, { oldString: "x", newString: "y" })).toBe(false)
})
})
describe("glob", () => {
test("accepts pattern-only", () => {
expect(parse(Glob, { pattern: "**/*.ts" })).toEqual({ pattern: "**/*.ts" })
})
test("accepts optional path", () => {
expect(parse(Glob, { pattern: "**/*.ts", path: "/tmp" }).path).toBe("/tmp")
})
test("rejects missing pattern", () => {
expect(accepts(Glob, {})).toBe(false)
})
})
describe("grep", () => {
test("accepts pattern-only", () => {
expect(parse(Grep, { pattern: "TODO" })).toEqual({ pattern: "TODO" })
})
test("accepts optional path + include", () => {
const parsed = parse(Grep, { pattern: "TODO", path: "/tmp", include: "*.ts" })
expect(parsed.path).toBe("/tmp")
expect(parsed.include).toBe("*.ts")
})
test("rejects missing pattern", () => {
expect(accepts(Grep, {})).toBe(false)
})
})
describe("invalid", () => {
test("accepts tool + error", () => {
expect(parse(Invalid, { tool: "foo", error: "bar" })).toEqual({ tool: "foo", error: "bar" })
})
test("rejects missing fields", () => {
expect(accepts(Invalid, { tool: "foo" })).toBe(false)
expect(accepts(Invalid, { error: "bar" })).toBe(false)
})
})
describe("lsp", () => {
test("accepts all fields", () => {
const parsed = parse(Lsp, { operation: "hover", filePath: "/a.ts", line: 1, character: 1 })
expect(parsed.operation).toBe("hover")
})
test("rejects line < 1", () => {
expect(accepts(Lsp, { operation: "hover", filePath: "/a.ts", line: 0, character: 1 })).toBe(false)
})
test("rejects character < 1", () => {
expect(accepts(Lsp, { operation: "hover", filePath: "/a.ts", line: 1, character: 0 })).toBe(false)
})
test("rejects unknown operation", () => {
expect(accepts(Lsp, { operation: "bogus", filePath: "/a.ts", line: 1, character: 1 })).toBe(false)
})
})
describe("plan", () => {
test("accepts empty object", () => {
expect(parse(Plan, {})).toEqual({})
})
})
describe("question", () => {
test("accepts questions array", () => {
const parsed = parse(Question, {
questions: [
{
question: "pick one",
header: "Header",
custom: false,
options: [{ label: "a", description: "desc" }],
},
],
})
expect(parsed.questions.length).toBe(1)
})
test("rejects missing questions", () => {
expect(accepts(Question, {})).toBe(false)
})
})
describe("read", () => {
test("accepts filePath-only", () => {
expect(parse(Read, { filePath: "/a" }).filePath).toBe("/a")
})
test("accepts optional offset + limit", () => {
const parsed = parse(Read, { filePath: "/a", offset: 10, limit: 100 })
expect(parsed.offset).toBe(10)
expect(parsed.limit).toBe(100)
})
})
describe("skill", () => {
test("accepts name", () => {
expect(parse(Skill, { name: "foo" }).name).toBe("foo")
})
test("rejects missing name", () => {
expect(accepts(Skill, {})).toBe(false)
})
})
describe("task", () => {
test("accepts description + prompt + subagent_type", () => {
const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general" })
expect(parsed.subagent_type).toBe("general")
})
test("rejects missing prompt", () => {
expect(accepts(Task, { description: "d", subagent_type: "general" })).toBe(false)
})
})
describe("todo", () => {
test("accepts todos array", () => {
const parsed = parse(Todo, {
todos: [{ id: "t1", content: "do x", status: "pending", priority: "medium" }],
})
expect(parsed.todos.length).toBe(1)
})
test("rejects missing todos", () => {
expect(accepts(Todo, {})).toBe(false)
})
})
describe("webfetch", () => {
test("accepts url-only", () => {
expect(parse(WebFetch, { url: "https://example.com" }).url).toBe("https://example.com")
})
})
describe("websearch", () => {
test("accepts query", () => {
expect(parse(WebSearch, { query: "opencode" }).query).toBe("opencode")
})
})
describe("write", () => {
test("accepts content + filePath", () => {
expect(parse(Write, { content: "hi", filePath: "/a" })).toEqual({ content: "hi", filePath: "/a" })
})
test("rejects missing filePath", () => {
expect(accepts(Write, { content: "hi" })).toBe(false)
})
})
})

View File

@@ -1,13 +1,13 @@
import { describe, test, expect } from "bun:test"
import { Effect, Layer, ManagedRuntime } from "effect"
import z from "zod"
import { Effect, Layer, ManagedRuntime, Schema } from "effect"
import { Agent } from "../../src/agent/agent"
import { MessageID, SessionID } from "../../src/session/schema"
import { Tool } from "../../src/tool"
import { Truncate } from "../../src/tool"
const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))
const params = z.object({ input: z.string() })
const params = Schema.Struct({ input: Schema.String })
function makeTool(id: string, executeFn?: () => void) {
return {
@@ -56,4 +56,44 @@ describe("Tool.define", () => {
expect(first).not.toBe(second)
})
test("execute receives decoded parameters", async () => {
const parameters = Schema.Struct({
count: Schema.NumberFromString.pipe(Schema.optional, Schema.withDecodingDefaultType(Effect.succeed(5))),
})
const calls: Array<Schema.Schema.Type<typeof parameters>> = []
const info = await runtime.runPromise(
Tool.define(
"test-decoded",
Effect.succeed({
description: "test tool",
parameters,
execute(args: Schema.Schema.Type<typeof parameters>) {
calls.push(args)
return Effect.succeed({ title: "test", output: "ok", metadata: { truncated: false } })
},
}),
),
)
const ctx: Tool.Context = {
sessionID: SessionID.descending(),
messageID: MessageID.ascending(),
agent: "build",
abort: new AbortController().signal,
messages: [],
metadata() {
return Effect.void
},
ask() {
return Effect.void
},
}
const tool = await Effect.runPromise(info.init())
const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType<typeof tool.execute>
await Effect.runPromise(execute({}, ctx))
await Effect.runPromise(execute({ count: "7" }, ctx))
expect(calls).toEqual([{ count: 5 }, { count: 7 }])
})
})