From d6abb56f58bc89b0223f2ea009b4e6b96e0c192a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 18 Apr 2026 00:01:50 -0400 Subject: [PATCH 1/4] test(tool): pin every tool's parameters schema before migration Pre-migration safety net for the upcoming tool-by-tool zod\u2192Schema conversion. Every tool's parameters schema now has: 1. A JSON Schema snapshot (`z.toJSONSchema` with `io: "input"`) \u2014 this captures exactly what the LLM sees at tool registration time, so any drift caused by a future migration fails the snapshot. 2. Parse-accept/parse-reject assertions per tool pinning the user-visible behavioural contract (required fields, refinement bounds, enum membership, default values). To make the snapshots possible without standing up each tool's full Effect runtime, every tool file now exports its parameters schema as `Parameters` at module scope: - 9 tools already had a module-level const \u2014 just added `export`, and standardised the name to `Parameters` (uppercase) where it was previously `parameters`. - 9 tools had their schema inline inside `Tool.define` \u2014 hoisted to module scope under the same `Parameters` name and wired back through. Zero behaviour change: Tool.define still sees the same schema, runtime validation path is identical, SDK (types.gen.ts + openapi.json) is byte-identical, and the full 2054-test suite passes. 18 JSON Schema snapshots and 43 explicit parse/reject assertions for the 18 built-in tools (apply_patch, bash, codesearch, edit, glob, grep, invalid, lsp, multiedit, plan, question, read, skill, task, todo, webfetch, websearch, write). --- packages/opencode/src/tool/apply_patch.ts | 8 +- packages/opencode/src/tool/bash.ts | 2 +- packages/opencode/src/tool/codesearch.ts | 32 +- packages/opencode/src/tool/edit.ts | 2 +- packages/opencode/src/tool/glob.ts | 20 +- packages/opencode/src/tool/grep.ts | 12 +- packages/opencode/src/tool/invalid.ts | 10 +- packages/opencode/src/tool/lsp.ts | 14 +- packages/opencode/src/tool/multiedit.ts | 28 +- packages/opencode/src/tool/plan.ts | 4 +- packages/opencode/src/tool/question.ts | 8 +- packages/opencode/src/tool/read.ts | 8 +- packages/opencode/src/tool/skill.ts | 2 +- packages/opencode/src/tool/task.ts | 8 +- packages/opencode/src/tool/todo.ts | 10 +- packages/opencode/src/tool/webfetch.ts | 6 +- packages/opencode/src/tool/websearch.ts | 2 +- packages/opencode/src/tool/write.ts | 10 +- .../__snapshots__/parameters.test.ts.snap | 541 ++++++++++++++++++ .../opencode/test/tool/parameters.test.ts | 272 +++++++++ 20 files changed, 914 insertions(+), 85 deletions(-) create mode 100644 packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap create mode 100644 packages/opencode/test/tool/parameters.test.ts diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 7da7dd255c..3f52d9ac1a 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -15,7 +15,7 @@ import DESCRIPTION from "./apply_patch.txt" import { File } from "../file" import { Format } from "../format" -const PatchParams = z.object({ +export const Parameters = z.object({ patchText: z.string().describe("The full patch text that describes all changes to be made"), }) @@ -27,7 +27,7 @@ export const ApplyPatchTool = Tool.define( const format = yield* Format.Service const bus = yield* Bus.Service - const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer, ctx: Tool.Context) { + const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer, ctx: Tool.Context) { if (!params.patchText) { return yield* Effect.fail(new Error("patchText is required")) } @@ -287,8 +287,8 @@ export const ApplyPatchTool = Tool.define( return { description: DESCRIPTION, - parameters: PatchParams, - execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), + parameters: Parameters, + execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 6260b22216..a3a56820e9 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -50,7 +50,7 @@ const FILES = new Set([ const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) -const Parameters = z.object({ +export const Parameters = z.object({ command: z.string().describe("The command to execute"), timeout: z.number().describe("Optional timeout in milliseconds").optional(), workdir: z diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts index ac9961e250..b0d6fab625 100644 --- a/packages/opencode/src/tool/codesearch.ts +++ b/packages/opencode/src/tool/codesearch.ts @@ -5,6 +5,22 @@ import * as Tool from "./tool" import * as McpExa from "./mcp-exa" import DESCRIPTION from "./codesearch.txt" +export const Parameters = z.object({ + query: z + .string() + .describe( + "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: z + .number() + .min(1000) + .max(50000) + .default(5000) + .describe( + "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.", + ), +}) + export const CodeSearchTool = Tool.define( "codesearch", Effect.gen(function* () { @@ -12,21 +28,7 @@ export const CodeSearchTool = Tool.define( return { description: DESCRIPTION, - parameters: z.object({ - query: z - .string() - .describe( - "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: z - .number() - .min(1000) - .max(50000) - .default(5000) - .describe( - "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.", - ), - }), + parameters: Parameters, execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) => Effect.gen(function* () { yield* ctx.ask({ diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index f535183d4c..20b9db25df 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -32,7 +32,7 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string { return text.replaceAll("\n", "\r\n") } -const Parameters = z.object({ +export const Parameters = z.object({ filePath: z.string().describe("The absolute path to the file to modify"), oldString: z.string().describe("The text to replace"), newString: z.string().describe("The text to replace it with (must be different from oldString)"), diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 673bb9cc8f..208bb6913c 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -9,6 +9,16 @@ import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./glob.txt" import * as Tool from "./tool" +export const Parameters = z.object({ + pattern: z.string().describe("The glob pattern to match files against"), + path: z + .string() + .optional() + .describe( + `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.`, + ), +}) + export const GlobTool = Tool.define( "glob", Effect.gen(function* () { @@ -17,15 +27,7 @@ export const GlobTool = Tool.define( return { description: DESCRIPTION, - parameters: z.object({ - pattern: z.string().describe("The glob pattern to match files against"), - path: z - .string() - .optional() - .describe( - `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.`, - ), - }), + parameters: Parameters, execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) => Effect.gen(function* () { const ins = yield* InstanceState.context diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index caa75edad5..5fc0c02246 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -10,6 +10,12 @@ import * as Tool from "./tool" const MAX_LINE_LENGTH = 2000 +export const Parameters = z.object({ + pattern: z.string().describe("The regex pattern to search for in file contents"), + path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."), + include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'), +}) + export const GrepTool = Tool.define( "grep", Effect.gen(function* () { @@ -18,11 +24,7 @@ export const GrepTool = Tool.define( return { description: DESCRIPTION, - parameters: z.object({ - pattern: z.string().describe("The regex pattern to search for in file contents"), - path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."), - include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'), - }), + parameters: Parameters, execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) => Effect.gen(function* () { const empty = { diff --git a/packages/opencode/src/tool/invalid.ts b/packages/opencode/src/tool/invalid.ts index aca3618b6d..e080d820d6 100644 --- a/packages/opencode/src/tool/invalid.ts +++ b/packages/opencode/src/tool/invalid.ts @@ -2,14 +2,16 @@ import z from "zod" import { Effect } from "effect" import * as Tool from "./tool" +export const Parameters = z.object({ + tool: z.string(), + error: z.string(), +}) + export const InvalidTool = Tool.define( "invalid", Effect.succeed({ description: "Do not use", - parameters: z.object({ - tool: z.string(), - error: z.string(), - }), + parameters: Parameters, execute: (params: { tool: string; error: string }) => Effect.succeed({ title: "Invalid Tool", diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index 263bfe81d2..fe25f661af 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -21,6 +21,13 @@ const operations = [ "outgoingCalls", ] as const +export const Parameters = z.object({ + operation: z.enum(operations).describe("The LSP operation to perform"), + filePath: z.string().describe("The absolute or relative path to the file"), + line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"), + character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"), +}) + export const LspTool = Tool.define( "lsp", Effect.gen(function* () { @@ -29,12 +36,7 @@ export const LspTool = Tool.define( return { description: DESCRIPTION, - parameters: z.object({ - operation: z.enum(operations).describe("The LSP operation to perform"), - filePath: z.string().describe("The absolute or relative path to the file"), - line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"), - character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"), - }), + parameters: Parameters, execute: ( args: { operation: (typeof operations)[number]; filePath: string; line: number; character: number }, ctx: Tool.Context, diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts index 004d3c870d..16a4c86767 100644 --- a/packages/opencode/src/tool/multiedit.ts +++ b/packages/opencode/src/tool/multiedit.ts @@ -6,6 +6,20 @@ import DESCRIPTION from "./multiedit.txt" import path from "path" import { Instance } from "../project/instance" +export const Parameters = z.object({ + filePath: z.string().describe("The absolute path to the file to modify"), + edits: z + .array( + z.object({ + filePath: z.string().describe("The absolute path to the file to modify"), + oldString: z.string().describe("The text to replace"), + newString: z.string().describe("The text to replace it with (must be different from oldString)"), + replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), + }), + ) + .describe("Array of edit operations to perform sequentially on the file"), +}) + export const MultiEditTool = Tool.define( "multiedit", Effect.gen(function* () { @@ -14,19 +28,7 @@ export const MultiEditTool = Tool.define( return { description: DESCRIPTION, - parameters: z.object({ - filePath: z.string().describe("The absolute path to the file to modify"), - edits: z - .array( - z.object({ - filePath: z.string().describe("The absolute path to the file to modify"), - oldString: z.string().describe("The text to replace"), - newString: z.string().describe("The text to replace it with (must be different from oldString)"), - replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), - }), - ) - .describe("Array of edit operations to perform sequentially on the file"), - }), + parameters: Parameters, execute: ( params: { filePath: string diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index fd7276e09c..4afd4088ba 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -17,6 +17,8 @@ function getLastModel(sessionID: SessionID) { return undefined } +export const Parameters = z.object({}) + export const PlanExitTool = Tool.define( "plan_exit", Effect.gen(function* () { @@ -26,7 +28,7 @@ export const PlanExitTool = Tool.define( return { description: EXIT_DESCRIPTION, - parameters: z.object({}), + parameters: Parameters, execute: (_params: {}, ctx: Tool.Context) => Effect.gen(function* () { const info = yield* session.get(ctx.sessionID) diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index e5bb33aa69..fed69784d0 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -4,7 +4,7 @@ import * as Tool from "./tool" import { Question } from "../question" import DESCRIPTION from "./question.txt" -const parameters = z.object({ +export const Parameters = z.object({ questions: z.array(Question.Prompt.zod).describe("Questions to ask"), }) @@ -12,15 +12,15 @@ type Metadata = { answers: ReadonlyArray } -export const QuestionTool = Tool.define( +export const QuestionTool = Tool.define( "question", Effect.gen(function* () { const question = yield* Question.Service return { description: DESCRIPTION, - parameters, - execute: (params: z.infer, ctx: Tool.Context) => + parameters: Parameters, + execute: (params: z.infer, ctx: Tool.Context) => Effect.gen(function* () { const answers = yield* question.ask({ sessionID: ctx.sessionID, diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 18c668ca07..23524ee5eb 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -18,7 +18,7 @@ const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` const MAX_BYTES = 50 * 1024 const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB` -const parameters = z.object({ +export const Parameters = z.object({ filePath: z.string().describe("The absolute path to the file or directory to read"), offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(), limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(), @@ -77,7 +77,7 @@ export const ReadTool = Tool.define( yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope)) }) - const run = Effect.fn("ReadTool.execute")(function* (params: z.infer, ctx: Tool.Context) { + const run = Effect.fn("ReadTool.execute")(function* (params: z.infer, ctx: Tool.Context) { if (params.offset !== undefined && params.offset < 1) { return yield* Effect.fail(new Error("offset must be greater than or equal to 1")) } @@ -212,8 +212,8 @@ export const ReadTool = Tool.define( return { description: DESCRIPTION, - parameters, - execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), + parameters: Parameters, + execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 58a66ee744..8b9c411a31 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -8,7 +8,7 @@ import { Ripgrep } from "../file/ripgrep" import { Skill } from "../skill" import * as Tool from "./tool" -const Parameters = z.object({ +export const Parameters = z.object({ name: z.string().describe("The name of the skill from available_skills"), }) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 3da0664f3d..c77a15bcb9 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -17,7 +17,7 @@ export interface TaskPromptOps { const id = "task" -const parameters = z.object({ +export const Parameters = z.object({ description: z.string().describe("A short (3-5 words) description of the task"), prompt: z.string().describe("The task for the agent to perform"), subagent_type: z.string().describe("The type of specialized agent to use for this task"), @@ -37,7 +37,7 @@ export const TaskTool = Tool.define( const config = yield* Config.Service const sessions = yield* Session.Service - const run = Effect.fn("TaskTool.execute")(function* (params: z.infer, ctx: Tool.Context) { + const run = Effect.fn("TaskTool.execute")(function* (params: z.infer, ctx: Tool.Context) { const cfg = yield* config.get() if (!ctx.extra?.bypassAgentCheck) { @@ -168,8 +168,8 @@ export const TaskTool = Tool.define( return { description: DESCRIPTION, - parameters, - execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), + parameters: Parameters, + execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index 5090f17a7c..db041d4ee1 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -4,7 +4,7 @@ import * as Tool from "./tool" import DESCRIPTION_WRITE from "./todowrite.txt" import { Todo } from "../session/todo" -const parameters = z.object({ +export const Parameters = z.object({ todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"), }) @@ -12,15 +12,15 @@ type Metadata = { todos: Todo.Info[] } -export const TodoWriteTool = Tool.define( +export const TodoWriteTool = Tool.define( "todowrite", Effect.gen(function* () { const todo = yield* Todo.Service return { description: DESCRIPTION_WRITE, - parameters, - execute: (params: z.infer, ctx: Tool.Context) => + parameters: Parameters, + execute: (params: z.infer, ctx: Tool.Context) => Effect.gen(function* () { yield* ctx.ask({ permission: "todowrite", @@ -42,6 +42,6 @@ export const TodoWriteTool = Tool.define + } satisfies Tool.DefWithoutID }), ) diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index 6498b871f8..ba80330516 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -9,7 +9,7 @@ const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds const MAX_TIMEOUT = 120 * 1000 // 2 minutes -const parameters = z.object({ +export const Parameters = z.object({ url: z.string().describe("The URL to fetch content from"), format: z .enum(["text", "markdown", "html"]) @@ -26,8 +26,8 @@ export const WebFetchTool = Tool.define( return { description: DESCRIPTION, - parameters, - execute: (params: z.infer, ctx: Tool.Context) => + parameters: Parameters, + execute: (params: z.infer, ctx: Tool.Context) => Effect.gen(function* () { if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) { throw new Error("URL must start with http:// or https://") diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index 34cefd031f..047df79a3d 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -5,7 +5,7 @@ import * as Tool from "./tool" import * as McpExa from "./mcp-exa" import DESCRIPTION from "./websearch.txt" -const Parameters = z.object({ +export const Parameters = z.object({ query: z.string().describe("Websearch query"), numResults: z.number().optional().describe("Number of search results to return (default: 8)"), livecrawl: z diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 741091b21d..843e4e7e5f 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -16,6 +16,11 @@ import { assertExternalDirectoryEffect } from "./external-directory" const MAX_PROJECT_DIAGNOSTICS_FILES = 5 +export const Parameters = z.object({ + content: z.string().describe("The content to write to the file"), + filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"), +}) + export const WriteTool = Tool.define( "write", Effect.gen(function* () { @@ -26,10 +31,7 @@ export const WriteTool = Tool.define( return { description: DESCRIPTION, - parameters: z.object({ - content: z.string().describe("The content to write to the file"), - filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"), - }), + parameters: Parameters, execute: (params: { content: string; filePath: string }, ctx: Tool.Context) => Effect.gen(function* () { const filepath = path.isAbsolute(params.filePath) diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap new file mode 100644 index 0000000000..ea3f3262eb --- /dev/null +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -0,0 +1,541 @@ +// 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) multiedit 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "edits": { + "description": "Array of edit operations to perform sequentially on the file", + "items": { + "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", + }, + "type": "array", + }, + "filePath": { + "description": "The absolute path to the file to modify", + "type": "string", + }, + }, + "required": [ + "filePath", + "edits", + ], + "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", +} +`; diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts new file mode 100644 index 0000000000..2f2c384bbf --- /dev/null +++ b/packages/opencode/test/tool/parameters.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, test } from "bun:test" +import z from "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. Both must survive any future migration (e.g. from +// zod to Effect Schema via the effect-zod walker) byte-for-byte. + +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 MultiEdit } from "../../src/tool/multiedit" +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" + +// Helper: the JSON Schema the LLM sees at tool registration time +// (session/prompt.ts runs `z.toJSONSchema(tool.parameters)` with the AI SDK's +// default `io` mode). Snapshots pin the exact wire shape. +const toJsonSchema = (schema: z.ZodType) => z.toJSONSchema(schema, { io: "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("multiedit", () => expect(toJsonSchema(MultiEdit)).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(ApplyPatch.parse({ patchText: "*** Begin Patch\n*** End Patch" })).toEqual({ + patchText: "*** Begin Patch\n*** End Patch", + }) + }) + test("rejects missing patchText", () => { + expect(ApplyPatch.safeParse({}).success).toBe(false) + }) + test("rejects non-string patchText", () => { + expect(ApplyPatch.safeParse({ patchText: 123 }).success).toBe(false) + }) + }) + + describe("bash", () => { + test("accepts minimum: command + description", () => { + expect(Bash.parse({ command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) + }) + test("accepts optional timeout + workdir", () => { + const parsed = Bash.parse({ 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(Bash.safeParse({ command: "ls" }).success).toBe(false) + }) + test("rejects missing command", () => { + expect(Bash.safeParse({ description: "list" }).success).toBe(false) + }) + }) + + describe("codesearch", () => { + test("accepts query; tokensNum defaults to 5000", () => { + expect(CodeSearch.parse({ query: "hooks" })).toEqual({ query: "hooks", tokensNum: 5000 }) + }) + test("accepts override tokensNum", () => { + expect(CodeSearch.parse({ query: "hooks", tokensNum: 10000 }).tokensNum).toBe(10000) + }) + test("rejects tokensNum under 1000", () => { + expect(CodeSearch.safeParse({ query: "x", tokensNum: 500 }).success).toBe(false) + }) + test("rejects tokensNum over 50000", () => { + expect(CodeSearch.safeParse({ query: "x", tokensNum: 60000 }).success).toBe(false) + }) + }) + + describe("edit", () => { + test("accepts all four fields", () => { + expect(Edit.parse({ filePath: "/a", oldString: "x", newString: "y", replaceAll: true })).toEqual({ + filePath: "/a", + oldString: "x", + newString: "y", + replaceAll: true, + }) + }) + test("replaceAll is optional", () => { + const parsed = Edit.parse({ filePath: "/a", oldString: "x", newString: "y" }) + expect(parsed.replaceAll).toBeUndefined() + }) + test("rejects missing filePath", () => { + expect(Edit.safeParse({ oldString: "x", newString: "y" }).success).toBe(false) + }) + }) + + describe("glob", () => { + test("accepts pattern-only", () => { + expect(Glob.parse({ pattern: "**/*.ts" })).toEqual({ pattern: "**/*.ts" }) + }) + test("accepts optional path", () => { + expect(Glob.parse({ pattern: "**/*.ts", path: "/tmp" }).path).toBe("/tmp") + }) + test("rejects missing pattern", () => { + expect(Glob.safeParse({}).success).toBe(false) + }) + }) + + describe("grep", () => { + test("accepts pattern-only", () => { + expect(Grep.parse({ pattern: "TODO" })).toEqual({ pattern: "TODO" }) + }) + test("accepts optional path + include", () => { + const parsed = Grep.parse({ pattern: "TODO", path: "/tmp", include: "*.ts" }) + expect(parsed.path).toBe("/tmp") + expect(parsed.include).toBe("*.ts") + }) + test("rejects missing pattern", () => { + expect(Grep.safeParse({}).success).toBe(false) + }) + }) + + describe("invalid", () => { + test("accepts tool + error", () => { + expect(Invalid.parse({ tool: "foo", error: "bar" })).toEqual({ tool: "foo", error: "bar" }) + }) + test("rejects missing fields", () => { + expect(Invalid.safeParse({ tool: "foo" }).success).toBe(false) + expect(Invalid.safeParse({ error: "bar" }).success).toBe(false) + }) + }) + + describe("lsp", () => { + test("accepts all fields", () => { + const parsed = Lsp.parse({ operation: "hover", filePath: "/a.ts", line: 1, character: 1 }) + expect(parsed.operation).toBe("hover") + }) + test("rejects line < 1", () => { + expect(Lsp.safeParse({ operation: "hover", filePath: "/a.ts", line: 0, character: 1 }).success).toBe(false) + }) + test("rejects character < 1", () => { + expect(Lsp.safeParse({ operation: "hover", filePath: "/a.ts", line: 1, character: 0 }).success).toBe(false) + }) + test("rejects unknown operation", () => { + expect(Lsp.safeParse({ operation: "bogus", filePath: "/a.ts", line: 1, character: 1 }).success).toBe(false) + }) + }) + + describe("multiedit", () => { + test("accepts empty edits array", () => { + expect(MultiEdit.parse({ filePath: "/a", edits: [] }).edits).toEqual([]) + }) + test("accepts an edit entry", () => { + const parsed = MultiEdit.parse({ + filePath: "/a", + edits: [{ filePath: "/a", oldString: "x", newString: "y" }], + }) + expect(parsed.edits.length).toBe(1) + }) + }) + + describe("plan", () => { + test("accepts empty object", () => { + expect(Plan.parse({})).toEqual({}) + }) + }) + + describe("question", () => { + test("accepts questions array", () => { + const parsed = Question.parse({ + questions: [ + { + question: "pick one", + header: "Header", + custom: false, + options: [{ label: "a", description: "desc" }], + }, + ], + }) + expect(parsed.questions.length).toBe(1) + }) + test("rejects missing questions", () => { + expect(Question.safeParse({}).success).toBe(false) + }) + }) + + describe("read", () => { + test("accepts filePath-only", () => { + expect(Read.parse({ filePath: "/a" }).filePath).toBe("/a") + }) + test("accepts optional offset + limit", () => { + const parsed = Read.parse({ filePath: "/a", offset: 10, limit: 100 }) + expect(parsed.offset).toBe(10) + expect(parsed.limit).toBe(100) + }) + }) + + describe("skill", () => { + test("accepts name", () => { + expect(Skill.parse({ name: "foo" }).name).toBe("foo") + }) + test("rejects missing name", () => { + expect(Skill.safeParse({}).success).toBe(false) + }) + }) + + describe("task", () => { + test("accepts description + prompt + subagent_type", () => { + const parsed = Task.parse({ description: "d", prompt: "p", subagent_type: "general" }) + expect(parsed.subagent_type).toBe("general") + }) + test("rejects missing prompt", () => { + expect(Task.safeParse({ description: "d", subagent_type: "general" }).success).toBe(false) + }) + }) + + describe("todo", () => { + test("accepts todos array", () => { + const parsed = Todo.parse({ + todos: [{ id: "t1", content: "do x", status: "pending", priority: "medium" }], + }) + expect(parsed.todos.length).toBe(1) + }) + test("rejects missing todos", () => { + expect(Todo.safeParse({}).success).toBe(false) + }) + }) + + describe("webfetch", () => { + test("accepts url-only", () => { + expect(WebFetch.parse({ url: "https://example.com" }).url).toBe("https://example.com") + }) + }) + + describe("websearch", () => { + test("accepts query", () => { + expect(WebSearch.parse({ query: "opencode" }).query).toBe("opencode") + }) + }) + + describe("write", () => { + test("accepts content + filePath", () => { + expect(Write.parse({ content: "hi", filePath: "/a" })).toEqual({ content: "hi", filePath: "/a" }) + }) + test("rejects missing filePath", () => { + expect(Write.safeParse({ content: "hi" }).success).toBe(false) + }) + }) +}) From a49b5adfbd5febc65c93da0b7aefbe26dcbb14e4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 18 Apr 2026 11:58:38 -0400 Subject: [PATCH 2/4] refactor(tool): migrate tool framework + all 18 built-in tools to Effect Schema (#23293) --- .../server/routes/instance/experimental.ts | 3 +- packages/opencode/src/session/prompt.ts | 3 +- packages/opencode/src/tool/apply_patch.ts | 11 +- packages/opencode/src/tool/bash.ts | 26 ++--- packages/opencode/src/tool/codesearch.ts | 29 +++-- packages/opencode/src/tool/edit.ts | 19 ++-- packages/opencode/src/tool/glob.ts | 16 +-- packages/opencode/src/tool/grep.ts | 14 ++- packages/opencode/src/tool/invalid.ts | 9 +- packages/opencode/src/tool/lsp.ts | 17 +-- packages/opencode/src/tool/multiedit.ts | 29 ++--- packages/opencode/src/tool/plan.ts | 5 +- packages/opencode/src/tool/question.ts | 9 +- packages/opencode/src/tool/read.ts | 24 ++-- packages/opencode/src/tool/registry.ts | 12 +- packages/opencode/src/tool/skill.ts | 9 +- packages/opencode/src/tool/task.ts | 25 ++--- packages/opencode/src/tool/todo.ts | 18 ++- packages/opencode/src/tool/tool.ts | 55 ++++----- packages/opencode/src/tool/webfetch.ts | 20 ++-- packages/opencode/src/tool/websearch.ts | 35 +++--- packages/opencode/src/tool/write.ts | 10 +- packages/opencode/src/util/effect-zod.ts | 10 ++ .../opencode/test/tool/parameters.test.ts | 105 +++++++++--------- .../opencode/test/tool/tool-define.test.ts | 46 +++++++- 25 files changed, 319 insertions(+), 240 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts index 9c86494987..e29da4cbd0 100644 --- a/packages/opencode/src/server/routes/instance/experimental.ts +++ b/packages/opencode/src/server/routes/instance/experimental.ts @@ -1,6 +1,7 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" +import * as EffectZod from "@/util/effect-zod" import { ProviderID, ModelID } from "@/provider/schema" import { ToolRegistry } from "@/tool" import { Worktree } from "@/worktree" @@ -213,7 +214,7 @@ export const ExperimentalRoutes = lazy(() => tools.map((t) => ({ id: t.id, description: t.description, - parameters: z.toJSONSchema(t.parameters), + parameters: EffectZod.toJsonSchema(t.parameters), })), ) }, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 431189d19c..fb90f29be4 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,6 +1,7 @@ import path from "path" import os from "os" import z from "zod" +import * as EffectZod from "@/util/effect-zod" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" import { Log } from "../util" @@ -403,7 +404,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the providerID: input.model.providerID, agent: input.agent, })) { - const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) + const schema = ProviderTransform.schema(input.model, EffectZod.toJsonSchema(item.parameters)) tools[item.id] = tool({ description: item.description, inputSchema: jsonSchema(schema), diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 3f52d9ac1a..e56777f167 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -1,6 +1,5 @@ -import z from "zod" import * as path from "path" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" import { Bus } from "../bus" import { FileWatcher } from "../file/watcher" @@ -15,8 +14,8 @@ import DESCRIPTION from "./apply_patch.txt" import { File } from "../file" import { Format } from "../format" -export const Parameters = z.object({ - patchText: z.string().describe("The full patch text that describes all changes to be made"), +export const Parameters = Schema.Struct({ + patchText: Schema.String.annotate({ description: "The full patch text that describes all changes to be made" }), }) export const ApplyPatchTool = Tool.define( @@ -27,7 +26,7 @@ export const ApplyPatchTool = Tool.define( const format = yield* Format.Service const bus = yield* Bus.Service - const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer, ctx: Tool.Context) { + const run = Effect.fn("ApplyPatchTool.execute")(function* (params: Schema.Schema.Type, ctx: Tool.Context) { if (!params.patchText) { return yield* Effect.fail(new Error("patchText is required")) } @@ -288,7 +287,7 @@ export const ApplyPatchTool = Tool.define( return { description: DESCRIPTION, parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index a3a56820e9..2d4d59b1bf 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -1,4 +1,4 @@ -import z from "zod" +import { Schema } from "effect" import os from "os" import { createWriteStream } from "node:fs" import * as Tool from "./tool" @@ -50,20 +50,16 @@ const FILES = new Set([ const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) -export const Parameters = z.object({ - command: z.string().describe("The command to execute"), - timeout: z.number().describe("Optional timeout in milliseconds").optional(), - workdir: z - .string() - .describe( - `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, - ) - .optional(), - description: z - .string() - .describe( +export const Parameters = Schema.Struct({ + command: Schema.String.annotate({ description: "The command to execute" }), + timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in milliseconds" }), + workdir: Schema.optional(Schema.String).annotate({ + description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, + }), + description: Schema.String.annotate({ + description: "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - ), + }), }) type Part = { @@ -587,7 +583,7 @@ export const BashTool = Tool.define( .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { const cwd = params.workdir ? yield* resolvePath(params.workdir, Instance.directory, shell) diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts index b0d6fab625..e10d21175e 100644 --- a/packages/opencode/src/tool/codesearch.ts +++ b/packages/opencode/src/tool/codesearch.ts @@ -1,24 +1,21 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import { HttpClient } from "effect/unstable/http" import * as Tool from "./tool" import * as McpExa from "./mcp-exa" import DESCRIPTION from "./codesearch.txt" -export const Parameters = z.object({ - query: z - .string() - .describe( +export const Parameters = Schema.Struct({ + query: Schema.String.annotate({ + 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: z - .number() - .min(1000) - .max(50000) - .default(5000) - .describe( - "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.", - ), + }), + tokensNum: Schema.Number.check(Schema.isGreaterThanOrEqualTo(1000)) + .check(Schema.isLessThanOrEqualTo(50000)) + .pipe(Schema.optional, Schema.withDecodingDefault(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.", + }), }) export const CodeSearchTool = Tool.define( @@ -47,7 +44,7 @@ export const CodeSearchTool = Tool.define( McpExa.CodeArgs, { query: params.query, - tokensNum: params.tokensNum || 5000, + tokensNum: params.tokensNum, }, "30 seconds", ) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 20b9db25df..36feb3d414 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -3,9 +3,8 @@ // https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts // https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts -import z from "zod" import * as path from "path" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch, diffLines } from "diff" @@ -32,11 +31,15 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string { return text.replaceAll("\n", "\r\n") } -export const Parameters = z.object({ - filePath: z.string().describe("The absolute path to the file to modify"), - oldString: z.string().describe("The text to replace"), - newString: z.string().describe("The text to replace it with (must be different from oldString)"), - replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), +export const Parameters = Schema.Struct({ + filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }), + oldString: Schema.String.annotate({ description: "The text to replace" }), + newString: Schema.String.annotate({ + description: "The text to replace it with (must be different from oldString)", + }), + replaceAll: Schema.optional(Schema.Boolean).annotate({ + description: "Replace all occurrences of oldString (default false)", + }), }) export const EditTool = Tool.define( @@ -50,7 +53,7 @@ export const EditTool = Tool.define( return { description: DESCRIPTION, parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { if (!params.filePath) { throw new Error("filePath is required") diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 208bb6913c..aeecfecb72 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -1,6 +1,5 @@ import path from "path" -import z from "zod" -import { Effect, Option } from "effect" +import { Effect, Option, Schema } from "effect" import * as Stream from "effect/Stream" import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" @@ -9,14 +8,11 @@ import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./glob.txt" import * as Tool from "./tool" -export const Parameters = z.object({ - pattern: z.string().describe("The glob pattern to match files against"), - path: z - .string() - .optional() - .describe( - `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.`, - ), +export const Parameters = Schema.Struct({ + pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }), + path: Schema.optional(Schema.String).annotate({ + 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.`, + }), }) export const GlobTool = Tool.define( diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 5fc0c02246..4160054311 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -1,5 +1,5 @@ import path from "path" -import z from "zod" +import { Schema } from "effect" import { Effect, Option } from "effect" import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" @@ -10,10 +10,14 @@ import * as Tool from "./tool" const MAX_LINE_LENGTH = 2000 -export const Parameters = z.object({ - pattern: z.string().describe("The regex pattern to search for in file contents"), - path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."), - include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'), +export const Parameters = Schema.Struct({ + pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }), + path: Schema.optional(Schema.String).annotate({ + description: "The directory to search in. Defaults to the current working directory.", + }), + include: Schema.optional(Schema.String).annotate({ + description: 'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")', + }), }) export const GrepTool = Tool.define( diff --git a/packages/opencode/src/tool/invalid.ts b/packages/opencode/src/tool/invalid.ts index e080d820d6..b8d145d0be 100644 --- a/packages/opencode/src/tool/invalid.ts +++ b/packages/opencode/src/tool/invalid.ts @@ -1,10 +1,9 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" -export const Parameters = z.object({ - tool: z.string(), - error: z.string(), +export const Parameters = Schema.Struct({ + tool: Schema.String, + error: Schema.String, }) export const InvalidTool = Tool.define( diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index fe25f661af..f50d8a9407 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -1,5 +1,4 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" import path from "path" import { LSP } from "../lsp" @@ -21,11 +20,15 @@ const operations = [ "outgoingCalls", ] as const -export const Parameters = z.object({ - operation: z.enum(operations).describe("The LSP operation to perform"), - filePath: z.string().describe("The absolute or relative path to the file"), - line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"), - character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"), +export const Parameters = Schema.Struct({ + operation: Schema.Literals(operations).annotate({ description: "The LSP operation to perform" }), + filePath: Schema.String.annotate({ description: "The absolute or relative path to the file" }), + line: Schema.Number.check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(1)) + .annotate({ description: "The line number (1-based, as shown in editors)" }), + character: Schema.Number.check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(1)) + .annotate({ description: "The character offset (1-based, as shown in editors)" }), }) export const LspTool = Tool.define( diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts index 16a4c86767..7454556cd6 100644 --- a/packages/opencode/src/tool/multiedit.ts +++ b/packages/opencode/src/tool/multiedit.ts @@ -1,23 +1,26 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" import { EditTool } from "./edit" import DESCRIPTION from "./multiedit.txt" import path from "path" import { Instance } from "../project/instance" -export const Parameters = z.object({ - filePath: z.string().describe("The absolute path to the file to modify"), - edits: z - .array( - z.object({ - filePath: z.string().describe("The absolute path to the file to modify"), - oldString: z.string().describe("The text to replace"), - newString: z.string().describe("The text to replace it with (must be different from oldString)"), - replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), +export const Parameters = Schema.Struct({ + filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }), + edits: Schema.mutable( + Schema.Array( + Schema.Struct({ + filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }), + oldString: Schema.String.annotate({ description: "The text to replace" }), + newString: Schema.String.annotate({ + description: "The text to replace it with (must be different from oldString)", + }), + replaceAll: Schema.optional(Schema.Boolean).annotate({ + description: "Replace all occurrences of oldString (default false)", + }), }), - ) - .describe("Array of edit operations to perform sequentially on the file"), + ), + ).annotate({ description: "Array of edit operations to perform sequentially on the file" }), }) export const MultiEditTool = Tool.define( diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index 4afd4088ba..8e2f11360e 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -1,6 +1,5 @@ -import z from "zod" import path from "path" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" import { Question } from "../question" import { Session } from "../session" @@ -17,7 +16,7 @@ function getLastModel(sessionID: SessionID) { return undefined } -export const Parameters = z.object({}) +export const Parameters = Schema.Struct({}) export const PlanExitTool = Tool.define( "plan_exit", diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index fed69784d0..51f1e71e28 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -1,11 +1,10 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" import { Question } from "../question" import DESCRIPTION from "./question.txt" -export const Parameters = z.object({ - questions: z.array(Question.Prompt.zod).describe("Questions to ask"), +export const Parameters = Schema.Struct({ + questions: Schema.mutable(Schema.Array(Question.Prompt)).annotate({ description: "Questions to ask" }), }) type Metadata = { @@ -20,7 +19,7 @@ export const QuestionTool = Tool.define, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { const answers = yield* question.ask({ sessionID: ctx.sessionID, diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 23524ee5eb..4dd5cb3ea6 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -1,5 +1,4 @@ -import z from "zod" -import { Effect, Scope } from "effect" +import { Effect, Schema, Scope } from "effect" import { createReadStream } from "fs" import { open } from "fs/promises" import * as path from "path" @@ -18,10 +17,19 @@ const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` const MAX_BYTES = 50 * 1024 const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB` -export const Parameters = z.object({ - filePath: z.string().describe("The absolute path to the file or directory to read"), - offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(), - limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(), +// `offset` and `limit` were originally `z.coerce.number()` — the runtime +// coercion was useful when the tool was called from a shell but serves no +// purpose in the LLM tool-call path (the model emits typed JSON). The JSON +// Schema output is identical (`type: "number"`), so the LLM view is +// unchanged; purely CLI-facing uses must now send numbers rather than strings. +export const Parameters = Schema.Struct({ + filePath: Schema.String.annotate({ description: "The absolute path to the file or directory to read" }), + offset: Schema.optional(Schema.Number).annotate({ + description: "The line number to start reading from (1-indexed)", + }), + limit: Schema.optional(Schema.Number).annotate({ + description: "The maximum number of lines to read (defaults to 2000)", + }), }) export const ReadTool = Tool.define( @@ -77,7 +85,7 @@ export const ReadTool = Tool.define( yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope)) }) - const run = Effect.fn("ReadTool.execute")(function* (params: z.infer, ctx: Tool.Context) { + const run = Effect.fn("ReadTool.execute")(function* (params: Schema.Schema.Type, ctx: Tool.Context) { if (params.offset !== undefined && params.offset < 1) { return yield* Effect.fail(new Error("offset must be greater than or equal to 1")) } @@ -213,7 +221,7 @@ export const ReadTool = Tool.define( return { description: DESCRIPTION, parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index e27593e597..89aa5c99b0 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -15,7 +15,9 @@ import { SkillTool } from "./skill" import * as Tool from "./tool" import { Config } from "../config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" +import { Schema } from "effect" import z from "zod" +import { ZodOverride } from "@/util/effect-zod" import { Plugin } from "../plugin" import { Provider } from "../provider" import { ProviderID, type ModelID } from "../provider/schema" @@ -120,9 +122,17 @@ export const layer: Layer.Layer< const custom: Tool.Def[] = [] function fromPlugin(id: string, def: ToolDefinition): Tool.Def { + // Plugin tools define their args as a raw Zod shape. Wrap the + // derived Zod object in a `Schema.declare` so it slots into the + // Schema-typed framework, and annotate with `ZodOverride` so the + // walker emits the original Zod object for LLM JSON Schema. + const zodParams = z.object(def.args) + const parameters = Schema.declare((u): u is unknown => zodParams.safeParse(u).success).annotate({ + [ZodOverride]: zodParams, + }) return { id, - parameters: z.object(def.args), + parameters, description: def.description, execute: (args, toolCtx) => Effect.gen(function* () { diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 8b9c411a31..b7046b600e 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -1,15 +1,14 @@ import path from "path" import { pathToFileURL } from "url" -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Stream from "effect/Stream" import { EffectLogger } from "@/effect" import { Ripgrep } from "../file/ripgrep" import { Skill } from "../skill" import * as Tool from "./tool" -export const Parameters = z.object({ - name: z.string().describe("The name of the skill from available_skills"), +export const Parameters = Schema.Struct({ + name: Schema.String.annotate({ description: "The name of the skill from available_skills" }), }) export const SkillTool = Tool.define( @@ -43,7 +42,7 @@ export const SkillTool = Tool.define( return { description, parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { const info = yield* skill.get(params.name) if (!info) { diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index c77a15bcb9..98f6bdd98f 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -1,13 +1,12 @@ import * as Tool from "./tool" import DESCRIPTION from "./task.txt" -import z from "zod" import { Session } from "../session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" import type { SessionPrompt } from "../session/prompt" import { Config } from "../config" -import { Effect } from "effect" +import { Effect, Schema } from "effect" export interface TaskPromptOps { cancel(sessionID: SessionID): void @@ -17,17 +16,15 @@ export interface TaskPromptOps { const id = "task" -export const Parameters = z.object({ - description: z.string().describe("A short (3-5 words) description of the task"), - prompt: z.string().describe("The task for the agent to perform"), - subagent_type: z.string().describe("The type of specialized agent to use for this task"), - task_id: z - .string() - .describe( +export const Parameters = Schema.Struct({ + description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }), + prompt: Schema.String.annotate({ description: "The task for the agent to perform" }), + subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }), + task_id: Schema.optional(Schema.String).annotate({ + 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)", - ) - .optional(), - command: z.string().describe("The command that triggered this task").optional(), + }), + command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }), }) export const TaskTool = Tool.define( @@ -37,7 +34,7 @@ export const TaskTool = Tool.define( const config = yield* Config.Service const sessions = yield* Session.Service - const run = Effect.fn("TaskTool.execute")(function* (params: z.infer, ctx: Tool.Context) { + const run = Effect.fn("TaskTool.execute")(function* (params: Schema.Schema.Type, ctx: Tool.Context) { const cfg = yield* config.get() if (!ctx.extra?.bypassAgentCheck) { @@ -169,7 +166,7 @@ export const TaskTool = Tool.define( return { description: DESCRIPTION, parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index db041d4ee1..c493d3a71a 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -1,11 +1,19 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" import DESCRIPTION_WRITE from "./todowrite.txt" import { Todo } from "../session/todo" -export const Parameters = z.object({ - todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"), +// Todo.Info is still a zod schema (session/todo.ts). Inline the field shape +// here rather than referencing its `.shape` — the LLM-visible JSON Schema is +// identical, and it removes the last zod dependency from this tool. +const TodoItem = Schema.Struct({ + content: Schema.String.annotate({ description: "Brief description of the task" }), + status: Schema.String.annotate({ description: "Current status of the task: pending, in_progress, completed, cancelled" }), + priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }), +}) + +export const Parameters = Schema.Struct({ + todos: Schema.mutable(Schema.Array(TodoItem)).annotate({ description: "The updated todo list" }), }) type Metadata = { @@ -20,7 +28,7 @@ export const TodoWriteTool = Tool.define, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { yield* ctx.ask({ permission: "todowrite", diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 179149afd2..c9115e9ff1 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -1,5 +1,4 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import type { MessageV2 } from "../session/message-v2" import type { Permission } from "../permission" import type { SessionID, MessageID } from "../session/schema" @@ -32,29 +31,33 @@ export interface ExecuteResult { attachments?: Omit[] } -export interface Def { +export interface Def = Schema.Decoder, M extends Metadata = Metadata> { id: string description: string parameters: Parameters - execute(args: z.infer, ctx: Context): Effect.Effect> - formatValidationError?(error: z.ZodError): string + execute(args: Schema.Schema.Type, ctx: Context): Effect.Effect> + formatValidationError?(error: unknown): string } -export type DefWithoutID = Omit< +export type DefWithoutID = Schema.Decoder, M extends Metadata = Metadata> = Omit< Def, "id" > -export interface Info { +export interface Info = Schema.Decoder, M extends Metadata = Metadata> { id: string init: () => Effect.Effect> } -type Init = +type Init, M extends Metadata> = | DefWithoutID | (() => Effect.Effect>) export type InferParameters = - T extends Info ? z.infer

: T extends Effect.Effect, any, any> ? z.infer

: never + T extends Info + ? Schema.Schema.Type

+ : T extends Effect.Effect, any, any> + ? Schema.Schema.Type

+ : never export type InferMetadata = T extends Info ? M : T extends Effect.Effect, any, any> ? M : never @@ -65,7 +68,7 @@ export type InferDef = ? Def : never -function wrap( +function wrap, Result extends Metadata>( id: string, init: Init, truncate: Truncate.Interface, @@ -74,6 +77,10 @@ function wrap( return () => Effect.gen(function* () { const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init } + // Compile the parser closure once per tool init; `decodeUnknownEffect` + // allocates a new closure per call, so hoisting avoids re-closing it for + // every LLM tool invocation. + const decode = Schema.decodeUnknownEffect(toolInfo.parameters) const execute = toolInfo.execute toolInfo.execute = (args, ctx) => { const attrs = { @@ -83,19 +90,17 @@ function wrap( ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}), } return Effect.gen(function* () { - yield* Effect.try({ - try: () => toolInfo.parameters.parse(args), - catch: (error) => { - if (error instanceof z.ZodError && toolInfo.formatValidationError) { - return new Error(toolInfo.formatValidationError(error), { cause: error }) - } - return new Error( - `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`, - { cause: error }, - ) - }, - }) - const result = yield* execute(args, ctx) + const decoded = yield* decode(args).pipe( + Effect.mapError((error) => + toolInfo.formatValidationError + ? new Error(toolInfo.formatValidationError(error), { cause: error }) + : new Error( + `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`, + { cause: error }, + ), + ), + ) + const result = yield* execute(decoded as Schema.Schema.Type, ctx) if (result.metadata.truncated !== undefined) { return result } @@ -116,7 +121,7 @@ function wrap( }) } -export function define( +export function define, Result extends Metadata, R, ID extends string = string>( id: ID, init: Effect.Effect, never, R>, ): Effect.Effect, never, R | Truncate.Service | Agent.Service> & { id: ID } { @@ -131,7 +136,7 @@ export function define(info: Info): Effect.Effect> { +export function init

, M extends Metadata>(info: Info): Effect.Effect> { return Effect.gen(function* () { const init = yield* info.init() return { diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index ba80330516..9b39dedca5 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -1,5 +1,4 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" import * as Tool from "./tool" import TurndownService from "turndown" @@ -9,13 +8,14 @@ const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds const MAX_TIMEOUT = 120 * 1000 // 2 minutes -export const Parameters = z.object({ - url: z.string().describe("The URL to fetch content from"), - format: z - .enum(["text", "markdown", "html"]) - .default("markdown") - .describe("The format to return the content in (text, markdown, or html). Defaults to markdown."), - timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(), +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))) + .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)" }), }) export const WebFetchTool = Tool.define( @@ -27,7 +27,7 @@ export const WebFetchTool = Tool.define( return { description: DESCRIPTION, parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) { throw new Error("URL must start with http:// or https://") diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index 047df79a3d..ff4c696a25 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -1,27 +1,24 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import { HttpClient } from "effect/unstable/http" import * as Tool from "./tool" import * as McpExa from "./mcp-exa" import DESCRIPTION from "./websearch.txt" -export const Parameters = z.object({ - query: z.string().describe("Websearch query"), - numResults: z.number().optional().describe("Number of search results to return (default: 8)"), - livecrawl: z - .enum(["fallback", "preferred"]) - .optional() - .describe( +export const Parameters = Schema.Struct({ + query: Schema.String.annotate({ description: "Websearch query" }), + numResults: Schema.optional(Schema.Number).annotate({ + description: "Number of search results to return (default: 8)", + }), + livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({ + description: "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')", - ), - type: z - .enum(["auto", "fast", "deep"]) - .optional() - .describe("Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search"), - contextMaxCharacters: z - .number() - .optional() - .describe("Maximum characters for context string optimized for LLMs (default: 10000)"), + }), + type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({ + description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search", + }), + contextMaxCharacters: Schema.optional(Schema.Number).annotate({ + description: "Maximum characters for context string optimized for LLMs (default: 10000)", + }), }) export const WebSearchTool = Tool.define( @@ -34,7 +31,7 @@ export const WebSearchTool = Tool.define( return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString()) }, parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { yield* ctx.ask({ permission: "websearch", diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 843e4e7e5f..a676488446 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -1,4 +1,4 @@ -import z from "zod" +import { Schema } from "effect" import * as path from "path" import { Effect } from "effect" import * as Tool from "./tool" @@ -16,9 +16,11 @@ import { assertExternalDirectoryEffect } from "./external-directory" const MAX_PROJECT_DIAGNOSTICS_FILES = 5 -export const Parameters = z.object({ - content: z.string().describe("The content to write to the file"), - filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"), +export const Parameters = Schema.Struct({ + content: Schema.String.annotate({ description: "The content to write to the file" }), + filePath: Schema.String.annotate({ + description: "The absolute path to the file to write (must be absolute, not relative)", + }), }) export const WriteTool = Tool.define( diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index bf1caa035b..6a1105edd2 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -59,6 +59,16 @@ export function zod(schema: S): z.ZodType> } +/** + * Emit a JSON Schema for a tool/route parameter schema — derives the zod form + * via the walker so Effect Schema inputs flow through the same zod-openapi + * pipeline the LLM/SDK layer already depends on. `io: "input"` mirrors what + * `session/prompt.ts` has always passed to `ai`'s `jsonSchema()` helper. + */ +export function toJsonSchema(schema: S) { + return z.toJSONSchema(zod(schema), { io: "input" }) +} + function walk(ast: SchemaAST.AST): z.ZodTypeAny { const cached = walkCache.get(ast) if (cached) return cached diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 2f2c384bbf..92ef21a2f9 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -1,11 +1,13 @@ import { describe, expect, test } from "bun:test" -import z from "zod" +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. Both must survive any future migration (e.g. from -// zod to Effect Schema via the effect-zod walker) byte-for-byte. +// 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" @@ -26,10 +28,11 @@ import { Parameters as WebFetch } from "../../src/tool/webfetch" import { Parameters as WebSearch } from "../../src/tool/websearch" import { Parameters as Write } from "../../src/tool/write" -// Helper: the JSON Schema the LLM sees at tool registration time -// (session/prompt.ts runs `z.toJSONSchema(tool.parameters)` with the AI SDK's -// default `io` mode). Snapshots pin the exact wire shape. -const toJsonSchema = (schema: z.ZodType) => z.toJSONSchema(schema, { io: "input" }) +const parse = >(schema: S, input: unknown): S["Type"] => + Schema.decodeUnknownSync(schema)(input) + +const accepts = (schema: Schema.Decoder, input: unknown): boolean => + Result.isSuccess(Schema.decodeUnknownResult(schema)(input)) describe("tool parameters", () => { describe("JSON Schema (wire shape)", () => { @@ -55,53 +58,53 @@ describe("tool parameters", () => { describe("apply_patch", () => { test("accepts patchText", () => { - expect(ApplyPatch.parse({ patchText: "*** Begin Patch\n*** End Patch" })).toEqual({ + expect(parse(ApplyPatch, { patchText: "*** Begin Patch\n*** End Patch" })).toEqual({ patchText: "*** Begin Patch\n*** End Patch", }) }) test("rejects missing patchText", () => { - expect(ApplyPatch.safeParse({}).success).toBe(false) + expect(accepts(ApplyPatch, {})).toBe(false) }) test("rejects non-string patchText", () => { - expect(ApplyPatch.safeParse({ patchText: 123 }).success).toBe(false) + expect(accepts(ApplyPatch, { patchText: 123 })).toBe(false) }) }) describe("bash", () => { test("accepts minimum: command + description", () => { - expect(Bash.parse({ command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) + expect(parse(Bash, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) }) test("accepts optional timeout + workdir", () => { - const parsed = Bash.parse({ command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) + 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(Bash.safeParse({ command: "ls" }).success).toBe(false) + expect(accepts(Bash, { command: "ls" })).toBe(false) }) test("rejects missing command", () => { - expect(Bash.safeParse({ description: "list" }).success).toBe(false) + expect(accepts(Bash, { description: "list" })).toBe(false) }) }) describe("codesearch", () => { test("accepts query; tokensNum defaults to 5000", () => { - expect(CodeSearch.parse({ query: "hooks" })).toEqual({ query: "hooks", tokensNum: 5000 }) + expect(parse(CodeSearch, { query: "hooks" })).toEqual({ query: "hooks", tokensNum: 5000 }) }) test("accepts override tokensNum", () => { - expect(CodeSearch.parse({ query: "hooks", tokensNum: 10000 }).tokensNum).toBe(10000) + expect(parse(CodeSearch, { query: "hooks", tokensNum: 10000 }).tokensNum).toBe(10000) }) test("rejects tokensNum under 1000", () => { - expect(CodeSearch.safeParse({ query: "x", tokensNum: 500 }).success).toBe(false) + expect(accepts(CodeSearch, { query: "x", tokensNum: 500 })).toBe(false) }) test("rejects tokensNum over 50000", () => { - expect(CodeSearch.safeParse({ query: "x", tokensNum: 60000 }).success).toBe(false) + expect(accepts(CodeSearch, { query: "x", tokensNum: 60000 })).toBe(false) }) }) describe("edit", () => { test("accepts all four fields", () => { - expect(Edit.parse({ filePath: "/a", oldString: "x", newString: "y", replaceAll: true })).toEqual({ + expect(parse(Edit, { filePath: "/a", oldString: "x", newString: "y", replaceAll: true })).toEqual({ filePath: "/a", oldString: "x", newString: "y", @@ -109,72 +112,72 @@ describe("tool parameters", () => { }) }) test("replaceAll is optional", () => { - const parsed = Edit.parse({ filePath: "/a", oldString: "x", newString: "y" }) + const parsed = parse(Edit, { filePath: "/a", oldString: "x", newString: "y" }) expect(parsed.replaceAll).toBeUndefined() }) test("rejects missing filePath", () => { - expect(Edit.safeParse({ oldString: "x", newString: "y" }).success).toBe(false) + expect(accepts(Edit, { oldString: "x", newString: "y" })).toBe(false) }) }) describe("glob", () => { test("accepts pattern-only", () => { - expect(Glob.parse({ pattern: "**/*.ts" })).toEqual({ pattern: "**/*.ts" }) + expect(parse(Glob, { pattern: "**/*.ts" })).toEqual({ pattern: "**/*.ts" }) }) test("accepts optional path", () => { - expect(Glob.parse({ pattern: "**/*.ts", path: "/tmp" }).path).toBe("/tmp") + expect(parse(Glob, { pattern: "**/*.ts", path: "/tmp" }).path).toBe("/tmp") }) test("rejects missing pattern", () => { - expect(Glob.safeParse({}).success).toBe(false) + expect(accepts(Glob, {})).toBe(false) }) }) describe("grep", () => { test("accepts pattern-only", () => { - expect(Grep.parse({ pattern: "TODO" })).toEqual({ pattern: "TODO" }) + expect(parse(Grep, { pattern: "TODO" })).toEqual({ pattern: "TODO" }) }) test("accepts optional path + include", () => { - const parsed = Grep.parse({ pattern: "TODO", path: "/tmp", include: "*.ts" }) + 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(Grep.safeParse({}).success).toBe(false) + expect(accepts(Grep, {})).toBe(false) }) }) describe("invalid", () => { test("accepts tool + error", () => { - expect(Invalid.parse({ tool: "foo", error: "bar" })).toEqual({ tool: "foo", error: "bar" }) + expect(parse(Invalid, { tool: "foo", error: "bar" })).toEqual({ tool: "foo", error: "bar" }) }) test("rejects missing fields", () => { - expect(Invalid.safeParse({ tool: "foo" }).success).toBe(false) - expect(Invalid.safeParse({ error: "bar" }).success).toBe(false) + expect(accepts(Invalid, { tool: "foo" })).toBe(false) + expect(accepts(Invalid, { error: "bar" })).toBe(false) }) }) describe("lsp", () => { test("accepts all fields", () => { - const parsed = Lsp.parse({ operation: "hover", filePath: "/a.ts", line: 1, character: 1 }) + const parsed = parse(Lsp, { operation: "hover", filePath: "/a.ts", line: 1, character: 1 }) expect(parsed.operation).toBe("hover") }) test("rejects line < 1", () => { - expect(Lsp.safeParse({ operation: "hover", filePath: "/a.ts", line: 0, character: 1 }).success).toBe(false) + expect(accepts(Lsp, { operation: "hover", filePath: "/a.ts", line: 0, character: 1 })).toBe(false) }) test("rejects character < 1", () => { - expect(Lsp.safeParse({ operation: "hover", filePath: "/a.ts", line: 1, character: 0 }).success).toBe(false) + expect(accepts(Lsp, { operation: "hover", filePath: "/a.ts", line: 1, character: 0 })).toBe(false) }) test("rejects unknown operation", () => { - expect(Lsp.safeParse({ operation: "bogus", filePath: "/a.ts", line: 1, character: 1 }).success).toBe(false) + expect(accepts(Lsp, { operation: "bogus", filePath: "/a.ts", line: 1, character: 1 })).toBe(false) }) }) describe("multiedit", () => { test("accepts empty edits array", () => { - expect(MultiEdit.parse({ filePath: "/a", edits: [] }).edits).toEqual([]) + expect(parse(MultiEdit, { filePath: "/a", edits: [] }).edits).toEqual([]) }) test("accepts an edit entry", () => { - const parsed = MultiEdit.parse({ + const parsed = parse(MultiEdit, { filePath: "/a", edits: [{ filePath: "/a", oldString: "x", newString: "y" }], }) @@ -184,13 +187,13 @@ describe("tool parameters", () => { describe("plan", () => { test("accepts empty object", () => { - expect(Plan.parse({})).toEqual({}) + expect(parse(Plan, {})).toEqual({}) }) }) describe("question", () => { test("accepts questions array", () => { - const parsed = Question.parse({ + const parsed = parse(Question, { questions: [ { question: "pick one", @@ -203,16 +206,16 @@ describe("tool parameters", () => { expect(parsed.questions.length).toBe(1) }) test("rejects missing questions", () => { - expect(Question.safeParse({}).success).toBe(false) + expect(accepts(Question, {})).toBe(false) }) }) describe("read", () => { test("accepts filePath-only", () => { - expect(Read.parse({ filePath: "/a" }).filePath).toBe("/a") + expect(parse(Read, { filePath: "/a" }).filePath).toBe("/a") }) test("accepts optional offset + limit", () => { - const parsed = Read.parse({ filePath: "/a", offset: 10, limit: 100 }) + const parsed = parse(Read, { filePath: "/a", offset: 10, limit: 100 }) expect(parsed.offset).toBe(10) expect(parsed.limit).toBe(100) }) @@ -220,53 +223,53 @@ describe("tool parameters", () => { describe("skill", () => { test("accepts name", () => { - expect(Skill.parse({ name: "foo" }).name).toBe("foo") + expect(parse(Skill, { name: "foo" }).name).toBe("foo") }) test("rejects missing name", () => { - expect(Skill.safeParse({}).success).toBe(false) + expect(accepts(Skill, {})).toBe(false) }) }) describe("task", () => { test("accepts description + prompt + subagent_type", () => { - const parsed = Task.parse({ description: "d", prompt: "p", subagent_type: "general" }) + const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general" }) expect(parsed.subagent_type).toBe("general") }) test("rejects missing prompt", () => { - expect(Task.safeParse({ description: "d", subagent_type: "general" }).success).toBe(false) + expect(accepts(Task, { description: "d", subagent_type: "general" })).toBe(false) }) }) describe("todo", () => { test("accepts todos array", () => { - const parsed = Todo.parse({ + 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(Todo.safeParse({}).success).toBe(false) + expect(accepts(Todo, {})).toBe(false) }) }) describe("webfetch", () => { test("accepts url-only", () => { - expect(WebFetch.parse({ url: "https://example.com" }).url).toBe("https://example.com") + expect(parse(WebFetch, { url: "https://example.com" }).url).toBe("https://example.com") }) }) describe("websearch", () => { test("accepts query", () => { - expect(WebSearch.parse({ query: "opencode" }).query).toBe("opencode") + expect(parse(WebSearch, { query: "opencode" }).query).toBe("opencode") }) }) describe("write", () => { test("accepts content + filePath", () => { - expect(Write.parse({ content: "hi", filePath: "/a" })).toEqual({ content: "hi", filePath: "/a" }) + expect(parse(Write, { content: "hi", filePath: "/a" })).toEqual({ content: "hi", filePath: "/a" }) }) test("rejects missing filePath", () => { - expect(Write.safeParse({ content: "hi" }).success).toBe(false) + expect(accepts(Write, { content: "hi" })).toBe(false) }) }) }) diff --git a/packages/opencode/test/tool/tool-define.test.ts b/packages/opencode/test/tool/tool-define.test.ts index 00d1e039a7..283708767d 100644 --- a/packages/opencode/test/tool/tool-define.test.ts +++ b/packages/opencode/test/tool/tool-define.test.ts @@ -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> = [] + const info = await runtime.runPromise( + Tool.define( + "test-decoded", + Effect.succeed({ + description: "test tool", + parameters, + execute(args: Schema.Schema.Type) { + 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 + + await Effect.runPromise(execute({}, ctx)) + await Effect.runPromise(execute({ count: "7" }, ctx)) + + expect(calls).toEqual([{ count: 5 }, { count: 7 }]) + }) }) From f2cbcacf00a26f7c2c26438ed35a801d9bc8d9f2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 18 Apr 2026 12:54:29 -0400 Subject: [PATCH 3/4] fix(tool): align multiedit schema with single-file edits --- packages/opencode/src/tool/multiedit.ts | 17 +--- .../__snapshots__/parameters.test.ts.snap | 5 -- packages/opencode/test/tool/multiedit.test.ts | 78 +++++++++++++++++++ .../opencode/test/tool/parameters.test.ts | 2 +- 4 files changed, 83 insertions(+), 19 deletions(-) create mode 100644 packages/opencode/test/tool/multiedit.test.ts diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts index 7454556cd6..3c0986fd55 100644 --- a/packages/opencode/src/tool/multiedit.ts +++ b/packages/opencode/src/tool/multiedit.ts @@ -10,7 +10,6 @@ export const Parameters = Schema.Struct({ edits: Schema.mutable( Schema.Array( Schema.Struct({ - filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }), oldString: Schema.String.annotate({ description: "The text to replace" }), newString: Schema.String.annotate({ description: "The text to replace it with (must be different from oldString)", @@ -32,17 +31,10 @@ export const MultiEditTool = Tool.define( return { description: DESCRIPTION, parameters: Parameters, - execute: ( - params: { - filePath: string - edits: Array<{ filePath: string; oldString: string; newString: string; replaceAll?: boolean }> - }, - ctx: Tool.Context, - ) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { - const results = [] - for (const [, entry] of params.edits.entries()) { - const result = yield* edit.execute( + const results = yield* Effect.forEach(params.edits, (entry) => + edit.execute( { filePath: params.filePath, oldString: entry.oldString, @@ -51,8 +43,7 @@ export const MultiEditTool = Tool.define( }, ctx, ) - results.push(result) - } + ) return { title: path.relative(Instance.worktree, params.filePath), metadata: { diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index ea3f3262eb..1623acc6ad 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -228,10 +228,6 @@ exports[`tool parameters JSON Schema (wire shape) multiedit 1`] = ` "description": "Array of edit operations to perform sequentially on the file", "items": { "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", @@ -246,7 +242,6 @@ exports[`tool parameters JSON Schema (wire shape) multiedit 1`] = ` }, }, "required": [ - "filePath", "oldString", "newString", ], diff --git a/packages/opencode/test/tool/multiedit.test.ts b/packages/opencode/test/tool/multiedit.test.ts new file mode 100644 index 0000000000..9313fbd85e --- /dev/null +++ b/packages/opencode/test/tool/multiedit.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Effect, Layer } from "effect" +import { Agent } from "../../src/agent/agent" +import { Bus } from "../../src/bus" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Format } from "../../src/format" +import { LSP } from "../../src/lsp" +import { Instance } from "../../src/project/instance" +import { MessageID, SessionID } from "../../src/session/schema" +import { MultiEditTool } from "../../src/tool/multiedit" +import { Truncate, Tool } from "../../src/tool" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const ctx = { + sessionID: SessionID.make("ses_test-multiedit-session"), + messageID: MessageID.make(""), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +afterEach(async () => { + await Instance.disposeAll() +}) + +const it = testEffect( + Layer.mergeAll( + LSP.defaultLayer, + AppFileSystem.defaultLayer, + Format.defaultLayer, + Bus.layer, + CrossSpawnSpawner.defaultLayer, + Truncate.defaultLayer, + Agent.defaultLayer, + ), +) + +const init = Effect.fn("MultiEditToolTest.init")(function* () { + const info = yield* MultiEditTool + return yield* info.init() +}) + +const run = Effect.fn("MultiEditToolTest.run")(function* ( + args: Tool.InferParameters, + next: Tool.Context = ctx, +) { + const tool = yield* init() + return yield* tool.execute(args, next) +}) + +describe("tool.multiedit", () => { + it.live("applies multiple edits to the same file in sequence", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filePath = path.join(dir, "file.txt") + yield* Effect.promise(() => fs.writeFile(filePath, "alpha\nbeta\ngamma\n", "utf-8")) + + yield* run({ + filePath, + edits: [ + { oldString: "alpha", newString: "delta" }, + { oldString: "gamma", newString: "omega" }, + ], + }) + + const content = yield* Effect.promise(() => fs.readFile(filePath, "utf-8")) + expect(content).toBe("delta\nbeta\nomega\n") + }), + ), + ) +}) diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 92ef21a2f9..c2516deb73 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -179,7 +179,7 @@ describe("tool parameters", () => { test("accepts an edit entry", () => { const parsed = parse(MultiEdit, { filePath: "/a", - edits: [{ filePath: "/a", oldString: "x", newString: "y" }], + edits: [{ oldString: "x", newString: "y" }], }) expect(parsed.edits.length).toBe(1) }) From e2eff19a321b50e5f81eae711a8e6e3c3dc4f5bf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 18 Apr 2026 13:02:29 -0400 Subject: [PATCH 4/4] refactor(tool): reuse edit entry schema in multiedit --- packages/opencode/src/tool/edit.ts | 8 ++++++-- packages/opencode/src/tool/multiedit.ts | 16 +++------------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 36feb3d414..b833c88fe2 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -31,8 +31,7 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string { return text.replaceAll("\n", "\r\n") } -export const Parameters = Schema.Struct({ - filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }), +export const Entry = Schema.Struct({ oldString: Schema.String.annotate({ description: "The text to replace" }), newString: Schema.String.annotate({ description: "The text to replace it with (must be different from oldString)", @@ -42,6 +41,11 @@ export const Parameters = Schema.Struct({ }), }) +export const Parameters = Schema.Struct({ + filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }), + ...Entry.fields, +}) + export const EditTool = Tool.define( "edit", Effect.gen(function* () { diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts index 3c0986fd55..c6df816462 100644 --- a/packages/opencode/src/tool/multiedit.ts +++ b/packages/opencode/src/tool/multiedit.ts @@ -1,6 +1,6 @@ import { Effect, Schema } from "effect" import * as Tool from "./tool" -import { EditTool } from "./edit" +import * as Edit from "./edit" import DESCRIPTION from "./multiedit.txt" import path from "path" import { Instance } from "../project/instance" @@ -8,24 +8,14 @@ import { Instance } from "../project/instance" export const Parameters = Schema.Struct({ filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }), edits: Schema.mutable( - Schema.Array( - Schema.Struct({ - oldString: Schema.String.annotate({ description: "The text to replace" }), - newString: Schema.String.annotate({ - description: "The text to replace it with (must be different from oldString)", - }), - replaceAll: Schema.optional(Schema.Boolean).annotate({ - description: "Replace all occurrences of oldString (default false)", - }), - }), - ), + Schema.Array(Edit.Entry), ).annotate({ description: "Array of edit operations to perform sequentially on the file" }), }) export const MultiEditTool = Tool.define( "multiedit", Effect.gen(function* () { - const editInfo = yield* EditTool + const editInfo = yield* Edit.EditTool const edit = yield* editInfo.init() return {