summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-23 16:09:34 -0400
committerGitHub <[email protected]>2026-04-23 16:09:34 -0400
commit3910a6e527c87f514c479a1c1c3d6d5f4d1aa315 (patch)
tree377003250ac05e4706aefe4677b938737566cf9e
parent24892559ae10e8df5bde8211b7e4c7a6851f3de6 (diff)
downloadopencode-3910a6e527c87f514c479a1c1c3d6d5f4d1aa315.tar.gz
opencode-3910a6e527c87f514c479a1c1c3d6d5f4d1aa315.zip
refactor(tool): migrate tool framework + all 18 built-in tools to Effect Schema (#23244)
-rw-r--r--packages/opencode/src/server/routes/instance/experimental.ts3
-rw-r--r--packages/opencode/src/session/prompt.ts3
-rw-r--r--packages/opencode/src/tool/apply_patch.ts13
-rw-r--r--packages/opencode/src/tool/bash.ts26
-rw-r--r--packages/opencode/src/tool/codesearch.ts35
-rw-r--r--packages/opencode/src/tool/edit.ts19
-rw-r--r--packages/opencode/src/tool/glob.ts20
-rw-r--r--packages/opencode/src/tool/grep.ts18
-rw-r--r--packages/opencode/src/tool/invalid.ts13
-rw-r--r--packages/opencode/src/tool/lsp.ts21
-rw-r--r--packages/opencode/src/tool/plan.ts7
-rw-r--r--packages/opencode/src/tool/question.ts13
-rw-r--r--packages/opencode/src/tool/read.ts26
-rw-r--r--packages/opencode/src/tool/registry.ts12
-rw-r--r--packages/opencode/src/tool/skill.ts9
-rw-r--r--packages/opencode/src/tool/task.ts27
-rw-r--r--packages/opencode/src/tool/todo.ts37
-rw-r--r--packages/opencode/src/tool/tool.ts55
-rw-r--r--packages/opencode/src/tool/webfetch.ts22
-rw-r--r--packages/opencode/src/tool/websearch.ts35
-rw-r--r--packages/opencode/src/tool/write.ts14
-rw-r--r--packages/opencode/src/util/effect-zod.ts11
-rw-r--r--packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap495
-rw-r--r--packages/opencode/test/tool/parameters.test.ts260
-rw-r--r--packages/opencode/test/tool/tool-define.test.ts46
25 files changed, 1035 insertions, 205 deletions
diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts
index 93a5d98c9..f13003cb4 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 0f48eb64e..5f3530bce 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"
@@ -405,7 +406,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 33112c43c..effc428c7 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"
@@ -16,8 +15,8 @@ import { File } from "../file"
import { Format } from "../format"
import * as Bom from "@/util/bom"
-const PatchParams = 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(
@@ -28,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<typeof PatchParams>, ctx: Tool.Context) {
+ const run = Effect.fn("ApplyPatchTool.execute")(function* (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) {
if (!params.patchText) {
return yield* Effect.fail(new Error("patchText is required"))
}
@@ -297,8 +296,8 @@ export const ApplyPatchTool = Tool.define(
return {
description: DESCRIPTION,
- parameters: PatchParams,
- execute: (params: z.infer<typeof PatchParams>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
+ parameters: Parameters,
+ execute: (params: Schema.Schema.Type<typeof Parameters>, 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 6260b2221..2d4d59b1b 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"])
-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<typeof Parameters>, ctx: Tool.Context) =>
+ execute: (params: Schema.Schema.Type<typeof Parameters>, 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 ac9961e25..e10d21175 100644
--- a/packages/opencode/src/tool/codesearch.ts
+++ b/packages/opencode/src/tool/codesearch.ts
@@ -1,10 +1,23 @@
-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 = 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: 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(
"codesearch",
Effect.gen(function* () {
@@ -12,21 +25,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({
@@ -45,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 35dd85b47..cfff5a0a3 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, Semaphore } from "effect"
+import { Effect, Schema, Semaphore } from "effect"
import * as Tool from "./tool"
import { LSP } from "../lsp"
import { createTwoFilesPatch, diffLines } from "diff"
@@ -45,11 +44,15 @@ function lock(filePath: string) {
return next
}
-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(
@@ -63,7 +66,7 @@ export const EditTool = Tool.define(
return {
description: DESCRIPTION,
parameters: Parameters,
- execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
+ execute: (params: Schema.Schema.Type<typeof Parameters>, 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 673bb9cc8..aeecfecb7 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,6 +8,13 @@ import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./glob.txt"
import * as Tool from "./tool"
+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(
"glob",
Effect.gen(function* () {
@@ -17,15 +23,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 caa75edad..416005431 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,6 +10,16 @@ import * as Tool from "./tool"
const MAX_LINE_LENGTH = 2000
+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(
"grep",
Effect.gen(function* () {
@@ -18,11 +28,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 aca3618b6..b8d145d0b 100644
--- a/packages/opencode/src/tool/invalid.ts
+++ b/packages/opencode/src/tool/invalid.ts
@@ -1,15 +1,16 @@
-import z from "zod"
-import { Effect } from "effect"
+import { Effect, Schema } from "effect"
import * as Tool from "./tool"
+export const Parameters = Schema.Struct({
+ tool: Schema.String,
+ error: Schema.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 0a0edc61e..29c6a8d84 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,6 +20,17 @@ const operations = [
"outgoingCalls",
] as const
+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(
"lsp",
Effect.gen(function* () {
@@ -29,12 +39,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/plan.ts b/packages/opencode/src/tool/plan.ts
index fd7276e09..8e2f11360 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,6 +16,8 @@ function getLastModel(sessionID: SessionID) {
return undefined
}
+export const Parameters = Schema.Struct({})
+
export const PlanExitTool = Tool.define(
"plan_exit",
Effect.gen(function* () {
@@ -26,7 +27,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 e5bb33aa6..51f1e71e2 100644
--- a/packages/opencode/src/tool/question.ts
+++ b/packages/opencode/src/tool/question.ts
@@ -1,26 +1,25 @@
-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"
-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 = {
answers: ReadonlyArray<Question.Answer>
}
-export const QuestionTool = Tool.define<typeof parameters, Metadata, Question.Service>(
+export const QuestionTool = Tool.define<typeof Parameters, Metadata, Question.Service>(
"question",
Effect.gen(function* () {
const question = yield* Question.Service
return {
description: DESCRIPTION,
- parameters,
- execute: (params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
+ parameters: Parameters,
+ execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
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 a9b95346a..e7bfc6af3 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, Option, Scope } from "effect"
+import { Effect, Option, Schema, Scope } from "effect"
import { createReadStream } from "fs"
import * as path from "path"
import { createInterface } from "readline"
@@ -19,10 +18,19 @@ const MAX_BYTES = 50 * 1024
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
const SAMPLE_BYTES = 4096
-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(
@@ -140,7 +148,7 @@ export const ReadTool = Tool.define(
return nonPrintableCount / bytes.length > 0.3
}
- const run = Effect.fn("ReadTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
+ const run = Effect.fn("ReadTool.execute")(function* (params: Schema.Schema.Type<typeof Parameters>, 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"))
}
@@ -275,8 +283,8 @@ export const ReadTool = Tool.define(
return {
description: DESCRIPTION,
- parameters,
- execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
+ parameters: Parameters,
+ execute: (params: Schema.Schema.Type<typeof Parameters>, 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 0211e33bc..539ad6320 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<unknown>((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 d86faec2b..8c41077be 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 { Ripgrep } from "../file/ripgrep"
import { Skill } from "../skill"
import * as Tool from "./tool"
import DESCRIPTION from "./skill.txt"
-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(
@@ -21,7 +20,7 @@ export const SkillTool = Tool.define(
return {
description: DESCRIPTION,
parameters: Parameters,
- execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
+ execute: (params: Schema.Schema.Type<typeof Parameters>, 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 3da0664f3..98f6bdd98 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"
-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<typeof parameters>, ctx: Tool.Context) {
+ const run = Effect.fn("TaskTool.execute")(function* (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) {
const cfg = yield* config.get()
if (!ctx.extra?.bypassAgentCheck) {
@@ -168,8 +165,8 @@ export const TaskTool = Tool.define(
return {
description: DESCRIPTION,
- parameters,
- execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
+ parameters: Parameters,
+ execute: (params: Schema.Schema.Type<typeof Parameters>, 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 c08fb0411..c493d3a71 100644
--- a/packages/opencode/src/tool/todo.ts
+++ b/packages/opencode/src/tool/todo.ts
@@ -1,39 +1,34 @@
-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"
-// Parameters are kept inline rather than derived from Todo.Info because
-// Tool.define requires z.ZodObject-typed parameters for execute() inference,
-// and zodObject(Todo.Info) returns ZodObject<any> — reaching into .shape would
-// erase field types. Tool schemas migrate to Effect Schema as a separate slice
-// per specs/effect/schema.md.
-const parameters = z.object({
- todos: z
- .array(
- z.object({
- content: z.string().describe("Brief description of the task"),
- status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
- priority: z.string().describe("Priority level of the task: high, medium, low"),
- }),
- )
- .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 = {
todos: Todo.Info[]
}
-export const TodoWriteTool = Tool.define<typeof parameters, Metadata, Todo.Service>(
+export const TodoWriteTool = Tool.define<typeof Parameters, Metadata, Todo.Service>(
"todowrite",
Effect.gen(function* () {
const todo = yield* Todo.Service
return {
description: DESCRIPTION_WRITE,
- parameters,
- execute: (params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
+ parameters: Parameters,
+ execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
Effect.gen(function* () {
yield* ctx.ask({
permission: "todowrite",
@@ -55,6 +50,6 @@ export const TodoWriteTool = Tool.define<typeof parameters, Metadata, Todo.Servi
},
}
}),
- } satisfies Tool.DefWithoutID<typeof parameters, Metadata>
+ } satisfies Tool.DefWithoutID<typeof Parameters, Metadata>
}),
)
diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts
index 179149afd..c9115e9ff 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<M extends Metadata = Metadata> {
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
}
-export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
+export interface Def<Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>, M extends Metadata = Metadata> {
id: string
description: string
parameters: Parameters
- execute(args: z.infer<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
- formatValidationError?(error: z.ZodError): string
+ execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
+ formatValidationError?(error: unknown): string
}
-export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
+export type DefWithoutID<Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>, M extends Metadata = Metadata> = Omit<
Def<Parameters, M>,
"id"
>
-export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
+export interface Info<Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>, M extends Metadata = Metadata> {
id: string
init: () => Effect.Effect<DefWithoutID<Parameters, M>>
}
-type Init<Parameters extends z.ZodType, M extends Metadata> =
+type Init<Parameters extends Schema.Decoder<unknown>, M extends Metadata> =
| DefWithoutID<Parameters, M>
| (() => Effect.Effect<DefWithoutID<Parameters, M>>)
export type InferParameters<T> =
- T extends Info<infer P, any> ? z.infer<P> : T extends Effect.Effect<Info<infer P, any>, any, any> ? z.infer<P> : never
+ T extends Info<infer P, any>
+ ? Schema.Schema.Type<P>
+ : T extends Effect.Effect<Info<infer P, any>, any, any>
+ ? Schema.Schema.Type<P>
+ : never
export type InferMetadata<T> =
T extends Info<any, infer M> ? M : T extends Effect.Effect<Info<any, infer M>, any, any> ? M : never
@@ -65,7 +68,7 @@ export type InferDef<T> =
? Def<P, M>
: never
-function wrap<Parameters extends z.ZodType, Result extends Metadata>(
+function wrap<Parameters extends Schema.Decoder<unknown>, Result extends Metadata>(
id: string,
init: Init<Parameters, Result>,
truncate: Truncate.Interface,
@@ -74,6 +77,10 @@ function wrap<Parameters extends z.ZodType, Result extends Metadata>(
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<Parameters extends z.ZodType, Result extends Metadata>(
...(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<Parameters>, ctx)
if (result.metadata.truncated !== undefined) {
return result
}
@@ -116,7 +121,7 @@ function wrap<Parameters extends z.ZodType, Result extends Metadata>(
})
}
-export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
+export function define<Parameters extends Schema.Decoder<unknown>, Result extends Metadata, R, ID extends string = string>(
id: ID,
init: Effect.Effect<Init<Parameters, Result>, never, R>,
): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service> & { id: ID } {
@@ -131,7 +136,7 @@ export function define<Parameters extends z.ZodType, Result extends Metadata, R,
)
}
-export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
+export function init<P extends Schema.Decoder<unknown>, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
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 1d988b8d4..d2561a130 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"
@@ -10,13 +9,14 @@ 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({
- 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,8 +27,8 @@ export const WebFetchTool = Tool.define(
return {
description: DESCRIPTION,
- parameters,
- execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) =>
+ parameters: Parameters,
+ execute: (params: Schema.Schema.Type<typeof Parameters>, 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 34cefd031..ff4c696a2 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"
-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<typeof Parameters>, ctx: Tool.Context) =>
+ execute: (params: Schema.Schema.Type<typeof Parameters>, 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 80198f455..b52f4a164 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"
@@ -17,6 +17,13 @@ import * as Bom from "@/util/bom"
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
+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(
"write",
Effect.gen(function* () {
@@ -27,10 +34,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/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts
index 24ff9a10e..2bbad2dd7 100644
--- a/packages/opencode/src/util/effect-zod.ts
+++ b/packages/opencode/src/util/effect-zod.ts
@@ -49,6 +49,17 @@ function isZodType(value: unknown): value is z.ZodTypeAny {
return typeof value === "object" && value !== null && "_zod" in value
}
+/**
+ * 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<S extends Schema.Top>(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/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap
new file mode 100644
index 000000000..eb3fe6cce
--- /dev/null
+++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap
@@ -0,0 +1,495 @@
+// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
+
+exports[`tool parameters JSON Schema (wire shape) apply_patch 1`] = `
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "patchText": {
+ "description": "The full patch text that describes all changes to be made",
+ "type": "string",
+ },
+ },
+ "required": [
+ "patchText",
+ ],
+ "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) bash 1`] = `
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "command": {
+ "description": "The command to execute",
+ "type": "string",
+ },
+ "description": {
+ "description":
+"Clear, concise description of what this command does in 5-10 words. Examples:
+Input: ls
+Output: Lists files in current directory
+
+Input: git status
+Output: Shows working tree status
+
+Input: npm install
+Output: Installs package dependencies
+
+Input: mkdir foo
+Output: Creates directory 'foo'"
+,
+ "type": "string",
+ },
+ "timeout": {
+ "description": "Optional timeout in milliseconds",
+ "type": "number",
+ },
+ "workdir": {
+ "description": "The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.",
+ "type": "string",
+ },
+ },
+ "required": [
+ "command",
+ "description",
+ ],
+ "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) codesearch 1`] = `
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "query": {
+ "description": "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
+ "type": "string",
+ },
+ "tokensNum": {
+ "default": 5000,
+ "description": "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
+ "maximum": 50000,
+ "minimum": 1000,
+ "type": "number",
+ },
+ },
+ "required": [
+ "query",
+ ],
+ "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) edit 1`] = `
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "filePath": {
+ "description": "The absolute path to the file to modify",
+ "type": "string",
+ },
+ "newString": {
+ "description": "The text to replace it with (must be different from oldString)",
+ "type": "string",
+ },
+ "oldString": {
+ "description": "The text to replace",
+ "type": "string",
+ },
+ "replaceAll": {
+ "description": "Replace all occurrences of oldString (default false)",
+ "type": "boolean",
+ },
+ },
+ "required": [
+ "filePath",
+ "oldString",
+ "newString",
+ ],
+ "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) glob 1`] = `
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "path": {
+ "description": "The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.",
+ "type": "string",
+ },
+ "pattern": {
+ "description": "The glob pattern to match files against",
+ "type": "string",
+ },
+ },
+ "required": [
+ "pattern",
+ ],
+ "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) grep 1`] = `
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "include": {
+ "description": "File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")",
+ "type": "string",
+ },
+ "path": {
+ "description": "The directory to search in. Defaults to the current working directory.",
+ "type": "string",
+ },
+ "pattern": {
+ "description": "The regex pattern to search for in file contents",
+ "type": "string",
+ },
+ },
+ "required": [
+ "pattern",
+ ],
+ "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) invalid 1`] = `
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "error": {
+ "type": "string",
+ },
+ "tool": {
+ "type": "string",
+ },
+ },
+ "required": [
+ "tool",
+ "error",
+ ],
+ "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) lsp 1`] = `
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "character": {
+ "description": "The character offset (1-based, as shown in editors)",
+ "maximum": 9007199254740991,
+ "minimum": 1,
+ "type": "integer",
+ },
+ "filePath": {
+ "description": "The absolute or relative path to the file",
+ "type": "string",
+ },
+ "line": {
+ "description": "The line number (1-based, as shown in editors)",
+ "maximum": 9007199254740991,
+ "minimum": 1,
+ "type": "integer",
+ },
+ "operation": {
+ "description": "The LSP operation to perform",
+ "enum": [
+ "goToDefinition",
+ "findReferences",
+ "hover",
+ "documentSymbol",
+ "workspaceSymbol",
+ "goToImplementation",
+ "prepareCallHierarchy",
+ "incomingCalls",
+ "outgoingCalls",
+ ],
+ "type": "string",
+ },
+ },
+ "required": [
+ "operation",
+ "filePath",
+ "line",
+ "character",
+ ],
+ "type": "object",
+}
+`;
+
+
+exports[`tool parameters JSON Schema (wire shape) plan 1`] = `
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {},
+ "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) question 1`] = `
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "questions": {
+ "description": "Questions to ask",
+ "items": {
+ "properties": {
+ "header": {
+ "description": "Very short label (max 30 chars)",
+ "type": "string",
+ },
+ "multiple": {
+ "description": "Allow selecting multiple choices",
+ "type": "boolean",
+ },
+ "options": {
+ "description": "Available choices",
+ "items": {
+ "properties": {
+ "description": {
+ "description": "Explanation of choice",
+ "type": "string",
+ },
+ "label": {
+ "description": "Display text (1-5 words, concise)",
+ "type": "string",
+ },
+ },
+ "ref": "QuestionOption",
+ "required": [
+ "label",
+ "description",
+ ],
+ "type": "object",
+ },
+ "type": "array",
+ },
+ "question": {
+ "description": "Complete question",
+ "type": "string",
+ },
+ },
+ "ref": "QuestionPrompt",
+ "required": [
+ "question",
+ "header",
+ "options",
+ ],
+ "type": "object",
+ },
+ "type": "array",
+ },
+ },
+ "required": [
+ "questions",
+ ],
+ "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) read 1`] = `
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "filePath": {
+ "description": "The absolute path to the file or directory to read",
+ "type": "string",
+ },
+ "limit": {
+ "description": "The maximum number of lines to read (defaults to 2000)",
+ "type": "number",
+ },
+ "offset": {
+ "description": "The line number to start reading from (1-indexed)",
+ "type": "number",
+ },
+ },
+ "required": [
+ "filePath",
+ ],
+ "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) skill 1`] = `
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "name": {
+ "description": "The name of the skill from available_skills",
+ "type": "string",
+ },
+ },
+ "required": [
+ "name",
+ ],
+ "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) task 1`] = `
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "command": {
+ "description": "The command that triggered this task",
+ "type": "string",
+ },
+ "description": {
+ "description": "A short (3-5 words) description of the task",
+ "type": "string",
+ },
+ "prompt": {
+ "description": "The task for the agent to perform",
+ "type": "string",
+ },
+ "subagent_type": {
+ "description": "The type of specialized agent to use for this task",
+ "type": "string",
+ },
+ "task_id": {
+ "description": "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
+ "type": "string",
+ },
+ },
+ "required": [
+ "description",
+ "prompt",
+ "subagent_type",
+ ],
+ "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) todo 1`] = `
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "todos": {
+ "description": "The updated todo list",
+ "items": {
+ "properties": {
+ "content": {
+ "description": "Brief description of the task",
+ "type": "string",
+ },
+ "priority": {
+ "description": "Priority level of the task: high, medium, low",
+ "type": "string",
+ },
+ "status": {
+ "description": "Current status of the task: pending, in_progress, completed, cancelled",
+ "type": "string",
+ },
+ },
+ "required": [
+ "content",
+ "status",
+ "priority",
+ ],
+ "type": "object",
+ },
+ "type": "array",
+ },
+ },
+ "required": [
+ "todos",
+ ],
+ "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) webfetch 1`] = `
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "format": {
+ "default": "markdown",
+ "description": "The format to return the content in (text, markdown, or html). Defaults to markdown.",
+ "enum": [
+ "text",
+ "markdown",
+ "html",
+ ],
+ "type": "string",
+ },
+ "timeout": {
+ "description": "Optional timeout in seconds (max 120)",
+ "type": "number",
+ },
+ "url": {
+ "description": "The URL to fetch content from",
+ "type": "string",
+ },
+ },
+ "required": [
+ "url",
+ ],
+ "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) websearch 1`] = `
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "contextMaxCharacters": {
+ "description": "Maximum characters for context string optimized for LLMs (default: 10000)",
+ "type": "number",
+ },
+ "livecrawl": {
+ "description": "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
+ "enum": [
+ "fallback",
+ "preferred",
+ ],
+ "type": "string",
+ },
+ "numResults": {
+ "description": "Number of search results to return (default: 8)",
+ "type": "number",
+ },
+ "query": {
+ "description": "Websearch query",
+ "type": "string",
+ },
+ "type": {
+ "description": "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
+ "enum": [
+ "auto",
+ "fast",
+ "deep",
+ ],
+ "type": "string",
+ },
+ },
+ "required": [
+ "query",
+ ],
+ "type": "object",
+}
+`;
+
+exports[`tool parameters JSON Schema (wire shape) write 1`] = `
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "content": {
+ "description": "The content to write to the file",
+ "type": "string",
+ },
+ "filePath": {
+ "description": "The absolute path to the file to write (must be absolute, not relative)",
+ "type": "string",
+ },
+ },
+ "required": [
+ "content",
+ "filePath",
+ ],
+ "type": "object",
+}
+`;
diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts
new file mode 100644
index 000000000..8ea008a45
--- /dev/null
+++ b/packages/opencode/test/tool/parameters.test.ts
@@ -0,0 +1,260 @@
+import { describe, expect, test } from "bun:test"
+import { Result, Schema } from "effect"
+import { toJsonSchema } from "../../src/util/effect-zod"
+
+// Each tool exports its parameters schema at module scope so this test can
+// import them without running the tool's Effect-based init. The JSON Schema
+// snapshot captures what the LLM sees; the parse assertions pin down the
+// accepts/rejects contract. `toJsonSchema` is the same helper `session/
+// prompt.ts` uses to emit tool schemas to the LLM, so the snapshots stay
+// byte-identical regardless of whether a tool has migrated from zod to Schema.
+
+import { Parameters as ApplyPatch } from "../../src/tool/apply_patch"
+import { Parameters as Bash } from "../../src/tool/bash"
+import { Parameters as CodeSearch } from "../../src/tool/codesearch"
+import { Parameters as Edit } from "../../src/tool/edit"
+import { Parameters as Glob } from "../../src/tool/glob"
+import { Parameters as Grep } from "../../src/tool/grep"
+import { Parameters as Invalid } from "../../src/tool/invalid"
+import { Parameters as Lsp } from "../../src/tool/lsp"
+import { Parameters as Plan } from "../../src/tool/plan"
+import { Parameters as Question } from "../../src/tool/question"
+import { Parameters as Read } from "../../src/tool/read"
+import { Parameters as Skill } from "../../src/tool/skill"
+import { Parameters as Task } from "../../src/tool/task"
+import { Parameters as Todo } from "../../src/tool/todo"
+import { Parameters as WebFetch } from "../../src/tool/webfetch"
+import { Parameters as WebSearch } from "../../src/tool/websearch"
+import { Parameters as Write } from "../../src/tool/write"
+
+const parse = <S extends Schema.Decoder<unknown>>(schema: S, input: unknown): S["Type"] =>
+ Schema.decodeUnknownSync(schema)(input)
+
+const accepts = (schema: Schema.Decoder<unknown>, input: unknown): boolean =>
+ Result.isSuccess(Schema.decodeUnknownResult(schema)(input))
+
+describe("tool parameters", () => {
+ describe("JSON Schema (wire shape)", () => {
+ test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot())
+ test("bash", () => expect(toJsonSchema(Bash)).toMatchSnapshot())
+ test("codesearch", () => expect(toJsonSchema(CodeSearch)).toMatchSnapshot())
+ test("edit", () => expect(toJsonSchema(Edit)).toMatchSnapshot())
+ test("glob", () => expect(toJsonSchema(Glob)).toMatchSnapshot())
+ test("grep", () => expect(toJsonSchema(Grep)).toMatchSnapshot())
+ test("invalid", () => expect(toJsonSchema(Invalid)).toMatchSnapshot())
+ test("lsp", () => expect(toJsonSchema(Lsp)).toMatchSnapshot())
+ test("plan", () => expect(toJsonSchema(Plan)).toMatchSnapshot())
+ test("question", () => expect(toJsonSchema(Question)).toMatchSnapshot())
+ test("read", () => expect(toJsonSchema(Read)).toMatchSnapshot())
+ test("skill", () => expect(toJsonSchema(Skill)).toMatchSnapshot())
+ test("task", () => expect(toJsonSchema(Task)).toMatchSnapshot())
+ test("todo", () => expect(toJsonSchema(Todo)).toMatchSnapshot())
+ test("webfetch", () => expect(toJsonSchema(WebFetch)).toMatchSnapshot())
+ test("websearch", () => expect(toJsonSchema(WebSearch)).toMatchSnapshot())
+ test("write", () => expect(toJsonSchema(Write)).toMatchSnapshot())
+ })
+
+ describe("apply_patch", () => {
+ test("accepts patchText", () => {
+ expect(parse(ApplyPatch, { patchText: "*** Begin Patch\n*** End Patch" })).toEqual({
+ patchText: "*** Begin Patch\n*** End Patch",
+ })
+ })
+ test("rejects missing patchText", () => {
+ expect(accepts(ApplyPatch, {})).toBe(false)
+ })
+ test("rejects non-string patchText", () => {
+ expect(accepts(ApplyPatch, { patchText: 123 })).toBe(false)
+ })
+ })
+
+ describe("bash", () => {
+ test("accepts minimum: command + description", () => {
+ expect(parse(Bash, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" })
+ })
+ test("accepts optional timeout + workdir", () => {
+ const parsed = parse(Bash, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" })
+ expect(parsed.timeout).toBe(5000)
+ expect(parsed.workdir).toBe("/tmp")
+ })
+ test("rejects missing description (required by zod)", () => {
+ expect(accepts(Bash, { command: "ls" })).toBe(false)
+ })
+ test("rejects missing command", () => {
+ expect(accepts(Bash, { description: "list" })).toBe(false)
+ })
+ })
+
+ describe("codesearch", () => {
+ test("accepts query; tokensNum defaults to 5000", () => {
+ expect(parse(CodeSearch, { query: "hooks" })).toEqual({ query: "hooks", tokensNum: 5000 })
+ })
+ test("accepts override tokensNum", () => {
+ expect(parse(CodeSearch, { query: "hooks", tokensNum: 10000 }).tokensNum).toBe(10000)
+ })
+ test("rejects tokensNum under 1000", () => {
+ expect(accepts(CodeSearch, { query: "x", tokensNum: 500 })).toBe(false)
+ })
+ test("rejects tokensNum over 50000", () => {
+ expect(accepts(CodeSearch, { query: "x", tokensNum: 60000 })).toBe(false)
+ })
+ })
+
+ describe("edit", () => {
+ test("accepts all four fields", () => {
+ expect(parse(Edit, { filePath: "/a", oldString: "x", newString: "y", replaceAll: true })).toEqual({
+ filePath: "/a",
+ oldString: "x",
+ newString: "y",
+ replaceAll: true,
+ })
+ })
+ test("replaceAll is optional", () => {
+ const parsed = parse(Edit, { filePath: "/a", oldString: "x", newString: "y" })
+ expect(parsed.replaceAll).toBeUndefined()
+ })
+ test("rejects missing filePath", () => {
+ expect(accepts(Edit, { oldString: "x", newString: "y" })).toBe(false)
+ })
+ })
+
+ describe("glob", () => {
+ test("accepts pattern-only", () => {
+ expect(parse(Glob, { pattern: "**/*.ts" })).toEqual({ pattern: "**/*.ts" })
+ })
+ test("accepts optional path", () => {
+ expect(parse(Glob, { pattern: "**/*.ts", path: "/tmp" }).path).toBe("/tmp")
+ })
+ test("rejects missing pattern", () => {
+ expect(accepts(Glob, {})).toBe(false)
+ })
+ })
+
+ describe("grep", () => {
+ test("accepts pattern-only", () => {
+ expect(parse(Grep, { pattern: "TODO" })).toEqual({ pattern: "TODO" })
+ })
+ test("accepts optional path + include", () => {
+ const parsed = parse(Grep, { pattern: "TODO", path: "/tmp", include: "*.ts" })
+ expect(parsed.path).toBe("/tmp")
+ expect(parsed.include).toBe("*.ts")
+ })
+ test("rejects missing pattern", () => {
+ expect(accepts(Grep, {})).toBe(false)
+ })
+ })
+
+ describe("invalid", () => {
+ test("accepts tool + error", () => {
+ expect(parse(Invalid, { tool: "foo", error: "bar" })).toEqual({ tool: "foo", error: "bar" })
+ })
+ test("rejects missing fields", () => {
+ expect(accepts(Invalid, { tool: "foo" })).toBe(false)
+ expect(accepts(Invalid, { error: "bar" })).toBe(false)
+ })
+ })
+
+ describe("lsp", () => {
+ test("accepts all fields", () => {
+ const parsed = parse(Lsp, { operation: "hover", filePath: "/a.ts", line: 1, character: 1 })
+ expect(parsed.operation).toBe("hover")
+ })
+ test("rejects line < 1", () => {
+ expect(accepts(Lsp, { operation: "hover", filePath: "/a.ts", line: 0, character: 1 })).toBe(false)
+ })
+ test("rejects character < 1", () => {
+ expect(accepts(Lsp, { operation: "hover", filePath: "/a.ts", line: 1, character: 0 })).toBe(false)
+ })
+ test("rejects unknown operation", () => {
+ expect(accepts(Lsp, { operation: "bogus", filePath: "/a.ts", line: 1, character: 1 })).toBe(false)
+ })
+ })
+
+ describe("plan", () => {
+ test("accepts empty object", () => {
+ expect(parse(Plan, {})).toEqual({})
+ })
+ })
+
+ describe("question", () => {
+ test("accepts questions array", () => {
+ const parsed = parse(Question, {
+ questions: [
+ {
+ question: "pick one",
+ header: "Header",
+ custom: false,
+ options: [{ label: "a", description: "desc" }],
+ },
+ ],
+ })
+ expect(parsed.questions.length).toBe(1)
+ })
+ test("rejects missing questions", () => {
+ expect(accepts(Question, {})).toBe(false)
+ })
+ })
+
+ describe("read", () => {
+ test("accepts filePath-only", () => {
+ expect(parse(Read, { filePath: "/a" }).filePath).toBe("/a")
+ })
+ test("accepts optional offset + limit", () => {
+ const parsed = parse(Read, { filePath: "/a", offset: 10, limit: 100 })
+ expect(parsed.offset).toBe(10)
+ expect(parsed.limit).toBe(100)
+ })
+ })
+
+ describe("skill", () => {
+ test("accepts name", () => {
+ expect(parse(Skill, { name: "foo" }).name).toBe("foo")
+ })
+ test("rejects missing name", () => {
+ expect(accepts(Skill, {})).toBe(false)
+ })
+ })
+
+ describe("task", () => {
+ test("accepts description + prompt + subagent_type", () => {
+ const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general" })
+ expect(parsed.subagent_type).toBe("general")
+ })
+ test("rejects missing prompt", () => {
+ expect(accepts(Task, { description: "d", subagent_type: "general" })).toBe(false)
+ })
+ })
+
+ describe("todo", () => {
+ test("accepts todos array", () => {
+ const parsed = parse(Todo, {
+ todos: [{ id: "t1", content: "do x", status: "pending", priority: "medium" }],
+ })
+ expect(parsed.todos.length).toBe(1)
+ })
+ test("rejects missing todos", () => {
+ expect(accepts(Todo, {})).toBe(false)
+ })
+ })
+
+ describe("webfetch", () => {
+ test("accepts url-only", () => {
+ expect(parse(WebFetch, { url: "https://example.com" }).url).toBe("https://example.com")
+ })
+ })
+
+ describe("websearch", () => {
+ test("accepts query", () => {
+ expect(parse(WebSearch, { query: "opencode" }).query).toBe("opencode")
+ })
+ })
+
+ describe("write", () => {
+ test("accepts content + filePath", () => {
+ expect(parse(Write, { content: "hi", filePath: "/a" })).toEqual({ content: "hi", filePath: "/a" })
+ })
+ test("rejects missing filePath", () => {
+ expect(accepts(Write, { content: "hi" })).toBe(false)
+ })
+ })
+})
diff --git a/packages/opencode/test/tool/tool-define.test.ts b/packages/opencode/test/tool/tool-define.test.ts
index 00d1e039a..283708767 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<Schema.Schema.Type<typeof parameters>> = []
+ const info = await runtime.runPromise(
+ Tool.define(
+ "test-decoded",
+ Effect.succeed({
+ description: "test tool",
+ parameters,
+ execute(args: Schema.Schema.Type<typeof parameters>) {
+ calls.push(args)
+ return Effect.succeed({ title: "test", output: "ok", metadata: { truncated: false } })
+ },
+ }),
+ ),
+ )
+ const ctx: Tool.Context = {
+ sessionID: SessionID.descending(),
+ messageID: MessageID.ascending(),
+ agent: "build",
+ abort: new AbortController().signal,
+ messages: [],
+ metadata() {
+ return Effect.void
+ },
+ ask() {
+ return Effect.void
+ },
+ }
+ const tool = await Effect.runPromise(info.init())
+ const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType<typeof tool.execute>
+
+ await Effect.runPromise(execute({}, ctx))
+ await Effect.runPromise(execute({ count: "7" }, ctx))
+
+ expect(calls).toEqual([{ count: 5 }, { count: 7 }])
+ })
})