Explorar el Código

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

Kit Langton hace 2 días
padre
commit
a49b5adfbd

+ 2 - 1
packages/opencode/src/server/routes/instance/experimental.ts

@@ -1,6 +1,7 @@
 import { Hono } from "hono"
 import { Hono } from "hono"
 import { describeRoute, validator, resolver } from "hono-openapi"
 import { describeRoute, validator, resolver } from "hono-openapi"
 import z from "zod"
 import z from "zod"
+import * as EffectZod from "@/util/effect-zod"
 import { ProviderID, ModelID } from "@/provider/schema"
 import { ProviderID, ModelID } from "@/provider/schema"
 import { ToolRegistry } from "@/tool"
 import { ToolRegistry } from "@/tool"
 import { Worktree } from "@/worktree"
 import { Worktree } from "@/worktree"
@@ -213,7 +214,7 @@ export const ExperimentalRoutes = lazy(() =>
           tools.map((t) => ({
           tools.map((t) => ({
             id: t.id,
             id: t.id,
             description: t.description,
             description: t.description,
-            parameters: z.toJSONSchema(t.parameters),
+            parameters: EffectZod.toJsonSchema(t.parameters),
           })),
           })),
         )
         )
       },
       },

+ 2 - 1
packages/opencode/src/session/prompt.ts

@@ -1,6 +1,7 @@
 import path from "path"
 import path from "path"
 import os from "os"
 import os from "os"
 import z from "zod"
 import z from "zod"
+import * as EffectZod from "@/util/effect-zod"
 import { SessionID, MessageID, PartID } from "./schema"
 import { SessionID, MessageID, PartID } from "./schema"
 import { MessageV2 } from "./message-v2"
 import { MessageV2 } from "./message-v2"
 import { Log } from "../util"
 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,
         providerID: input.model.providerID,
         agent: input.agent,
         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({
         tools[item.id] = tool({
           description: item.description,
           description: item.description,
           inputSchema: jsonSchema(schema),
           inputSchema: jsonSchema(schema),

+ 5 - 6
packages/opencode/src/tool/apply_patch.ts

@@ -1,6 +1,5 @@
-import z from "zod"
 import * as path from "path"
 import * as path from "path"
-import { Effect } from "effect"
+import { Effect, Schema } from "effect"
 import * as Tool from "./tool"
 import * as Tool from "./tool"
 import { Bus } from "../bus"
 import { Bus } from "../bus"
 import { FileWatcher } from "../file/watcher"
 import { FileWatcher } from "../file/watcher"
@@ -15,8 +14,8 @@ import DESCRIPTION from "./apply_patch.txt"
 import { File } from "../file"
 import { File } from "../file"
 import { Format } from "../format"
 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(
 export const ApplyPatchTool = Tool.define(
@@ -27,7 +26,7 @@ export const ApplyPatchTool = Tool.define(
     const format = yield* Format.Service
     const format = yield* Format.Service
     const bus = yield* Bus.Service
     const bus = yield* Bus.Service
 
 
-    const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer<typeof Parameters>, ctx: Tool.Context) {
+    const run = Effect.fn("ApplyPatchTool.execute")(function* (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) {
       if (!params.patchText) {
       if (!params.patchText) {
         return yield* Effect.fail(new Error("patchText is required"))
         return yield* Effect.fail(new Error("patchText is required"))
       }
       }
@@ -288,7 +287,7 @@ export const ApplyPatchTool = Tool.define(
     return {
     return {
       description: DESCRIPTION,
       description: DESCRIPTION,
       parameters: Parameters,
       parameters: Parameters,
-      execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
+      execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
     }
     }
   }),
   }),
 )
 )

+ 11 - 15
packages/opencode/src/tool/bash.ts

@@ -1,4 +1,4 @@
-import z from "zod"
+import { Schema } from "effect"
 import os from "os"
 import os from "os"
 import { createWriteStream } from "node:fs"
 import { createWriteStream } from "node:fs"
 import * as Tool from "./tool"
 import * as Tool from "./tool"
@@ -50,20 +50,16 @@ const FILES = new Set([
 const FLAGS = new Set(["-destination", "-literalpath", "-path"])
 const FLAGS = new Set(["-destination", "-literalpath", "-path"])
 const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
 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'",
       "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 = {
 type Part = {
@@ -587,7 +583,7 @@ export const BashTool = Tool.define(
             .replaceAll("${maxLines}", String(Truncate.MAX_LINES))
             .replaceAll("${maxLines}", String(Truncate.MAX_LINES))
             .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
             .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
           parameters: Parameters,
           parameters: Parameters,
-          execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
+          execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
             Effect.gen(function* () {
             Effect.gen(function* () {
               const cwd = params.workdir
               const cwd = params.workdir
                 ? yield* resolvePath(params.workdir, Instance.directory, shell)
                 ? yield* resolvePath(params.workdir, Instance.directory, shell)

+ 13 - 16
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 { HttpClient } from "effect/unstable/http"
 import * as Tool from "./tool"
 import * as Tool from "./tool"
 import * as McpExa from "./mcp-exa"
 import * as McpExa from "./mcp-exa"
 import DESCRIPTION from "./codesearch.txt"
 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'",
       "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(
 export const CodeSearchTool = Tool.define(
@@ -47,7 +44,7 @@ export const CodeSearchTool = Tool.define(
             McpExa.CodeArgs,
             McpExa.CodeArgs,
             {
             {
               query: params.query,
               query: params.query,
-              tokensNum: params.tokensNum || 5000,
+              tokensNum: params.tokensNum,
             },
             },
             "30 seconds",
             "30 seconds",
           )
           )

+ 11 - 8
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/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
 // 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 * as path from "path"
-import { Effect } from "effect"
+import { Effect, Schema } from "effect"
 import * as Tool from "./tool"
 import * as Tool from "./tool"
 import { LSP } from "../lsp"
 import { LSP } from "../lsp"
 import { createTwoFilesPatch, diffLines } from "diff"
 import { createTwoFilesPatch, diffLines } from "diff"
@@ -32,11 +31,15 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
   return text.replaceAll("\n", "\r\n")
   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(
 export const EditTool = Tool.define(
@@ -50,7 +53,7 @@ export const EditTool = Tool.define(
     return {
     return {
       description: DESCRIPTION,
       description: DESCRIPTION,
       parameters: Parameters,
       parameters: Parameters,
-      execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
+      execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
         Effect.gen(function* () {
         Effect.gen(function* () {
           if (!params.filePath) {
           if (!params.filePath) {
             throw new Error("filePath is required")
             throw new Error("filePath is required")

+ 6 - 10
packages/opencode/src/tool/glob.ts

@@ -1,6 +1,5 @@
 import path from "path"
 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 * as Stream from "effect/Stream"
 import { InstanceState } from "@/effect"
 import { InstanceState } from "@/effect"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
@@ -9,14 +8,11 @@ import { assertExternalDirectoryEffect } from "./external-directory"
 import DESCRIPTION from "./glob.txt"
 import DESCRIPTION from "./glob.txt"
 import * as Tool from "./tool"
 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(
 export const GlobTool = Tool.define(

+ 9 - 5
packages/opencode/src/tool/grep.ts

@@ -1,5 +1,5 @@
 import path from "path"
 import path from "path"
-import z from "zod"
+import { Schema } from "effect"
 import { Effect, Option } from "effect"
 import { Effect, Option } from "effect"
 import { InstanceState } from "@/effect"
 import { InstanceState } from "@/effect"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
@@ -10,10 +10,14 @@ import * as Tool from "./tool"
 
 
 const MAX_LINE_LENGTH = 2000
 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(
 export const GrepTool = Tool.define(

+ 4 - 5
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"
 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(
 export const InvalidTool = Tool.define(

+ 10 - 7
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 * as Tool from "./tool"
 import path from "path"
 import path from "path"
 import { LSP } from "../lsp"
 import { LSP } from "../lsp"
@@ -21,11 +20,15 @@ const operations = [
   "outgoingCalls",
   "outgoingCalls",
 ] as const
 ] 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(
 export const LspTool = Tool.define(

+ 16 - 13
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 * as Tool from "./tool"
 import { EditTool } from "./edit"
 import { EditTool } from "./edit"
 import DESCRIPTION from "./multiedit.txt"
 import DESCRIPTION from "./multiedit.txt"
 import path from "path"
 import path from "path"
 import { Instance } from "../project/instance"
 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(
 export const MultiEditTool = Tool.define(

+ 2 - 3
packages/opencode/src/tool/plan.ts

@@ -1,6 +1,5 @@
-import z from "zod"
 import path from "path"
 import path from "path"
-import { Effect } from "effect"
+import { Effect, Schema } from "effect"
 import * as Tool from "./tool"
 import * as Tool from "./tool"
 import { Question } from "../question"
 import { Question } from "../question"
 import { Session } from "../session"
 import { Session } from "../session"
@@ -17,7 +16,7 @@ function getLastModel(sessionID: SessionID) {
   return undefined
   return undefined
 }
 }
 
 
-export const Parameters = z.object({})
+export const Parameters = Schema.Struct({})
 
 
 export const PlanExitTool = Tool.define(
 export const PlanExitTool = Tool.define(
   "plan_exit",
   "plan_exit",

+ 4 - 5
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 * as Tool from "./tool"
 import { Question } from "../question"
 import { Question } from "../question"
 import DESCRIPTION from "./question.txt"
 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 = {
 type Metadata = {
@@ -20,7 +19,7 @@ export const QuestionTool = Tool.define<typeof Parameters, Metadata, Question.Se
     return {
     return {
       description: DESCRIPTION,
       description: DESCRIPTION,
       parameters: Parameters,
       parameters: Parameters,
-      execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
+      execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
         Effect.gen(function* () {
         Effect.gen(function* () {
           const answers = yield* question.ask({
           const answers = yield* question.ask({
             sessionID: ctx.sessionID,
             sessionID: ctx.sessionID,

+ 16 - 8
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 { createReadStream } from "fs"
 import { open } from "fs/promises"
 import { open } from "fs/promises"
 import * as path from "path"
 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 = 50 * 1024
 const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
 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(
 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))
       yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope))
     })
     })
 
 
-    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) {
       if (params.offset !== undefined && params.offset < 1) {
         return yield* Effect.fail(new Error("offset must be greater than or equal to 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 {
     return {
       description: DESCRIPTION,
       description: DESCRIPTION,
       parameters: Parameters,
       parameters: Parameters,
-      execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
+      execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
     }
     }
   }),
   }),
 )
 )

+ 11 - 1
packages/opencode/src/tool/registry.ts

@@ -15,7 +15,9 @@ import { SkillTool } from "./skill"
 import * as Tool from "./tool"
 import * as Tool from "./tool"
 import { Config } from "../config"
 import { Config } from "../config"
 import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
 import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
+import { Schema } from "effect"
 import z from "zod"
 import z from "zod"
+import { ZodOverride } from "@/util/effect-zod"
 import { Plugin } from "../plugin"
 import { Plugin } from "../plugin"
 import { Provider } from "../provider"
 import { Provider } from "../provider"
 import { ProviderID, type ModelID } from "../provider/schema"
 import { ProviderID, type ModelID } from "../provider/schema"
@@ -120,9 +122,17 @@ export const layer: Layer.Layer<
         const custom: Tool.Def[] = []
         const custom: Tool.Def[] = []
 
 
         function fromPlugin(id: string, def: ToolDefinition): 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 {
           return {
             id,
             id,
-            parameters: z.object(def.args),
+            parameters,
             description: def.description,
             description: def.description,
             execute: (args, toolCtx) =>
             execute: (args, toolCtx) =>
               Effect.gen(function* () {
               Effect.gen(function* () {

+ 4 - 5
packages/opencode/src/tool/skill.ts

@@ -1,15 +1,14 @@
 import path from "path"
 import path from "path"
 import { pathToFileURL } from "url"
 import { pathToFileURL } from "url"
-import z from "zod"
-import { Effect } from "effect"
+import { Effect, Schema } from "effect"
 import * as Stream from "effect/Stream"
 import * as Stream from "effect/Stream"
 import { EffectLogger } from "@/effect"
 import { EffectLogger } from "@/effect"
 import { Ripgrep } from "../file/ripgrep"
 import { Ripgrep } from "../file/ripgrep"
 import { Skill } from "../skill"
 import { Skill } from "../skill"
 import * as Tool from "./tool"
 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(
 export const SkillTool = Tool.define(
@@ -43,7 +42,7 @@ export const SkillTool = Tool.define(
         return {
         return {
           description,
           description,
           parameters: Parameters,
           parameters: Parameters,
-          execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
+          execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
             Effect.gen(function* () {
             Effect.gen(function* () {
               const info = yield* skill.get(params.name)
               const info = yield* skill.get(params.name)
               if (!info) {
               if (!info) {

+ 11 - 14
packages/opencode/src/tool/task.ts

@@ -1,13 +1,12 @@
 import * as Tool from "./tool"
 import * as Tool from "./tool"
 import DESCRIPTION from "./task.txt"
 import DESCRIPTION from "./task.txt"
-import z from "zod"
 import { Session } from "../session"
 import { Session } from "../session"
 import { SessionID, MessageID } from "../session/schema"
 import { SessionID, MessageID } from "../session/schema"
 import { MessageV2 } from "../session/message-v2"
 import { MessageV2 } from "../session/message-v2"
 import { Agent } from "../agent/agent"
 import { Agent } from "../agent/agent"
 import type { SessionPrompt } from "../session/prompt"
 import type { SessionPrompt } from "../session/prompt"
 import { Config } from "../config"
 import { Config } from "../config"
-import { Effect } from "effect"
+import { Effect, Schema } from "effect"
 
 
 export interface TaskPromptOps {
 export interface TaskPromptOps {
   cancel(sessionID: SessionID): void
   cancel(sessionID: SessionID): void
@@ -17,17 +16,15 @@ export interface TaskPromptOps {
 
 
 const id = "task"
 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)",
       "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(
 export const TaskTool = Tool.define(
@@ -37,7 +34,7 @@ export const TaskTool = Tool.define(
     const config = yield* Config.Service
     const config = yield* Config.Service
     const sessions = yield* Session.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()
       const cfg = yield* config.get()
 
 
       if (!ctx.extra?.bypassAgentCheck) {
       if (!ctx.extra?.bypassAgentCheck) {
@@ -169,7 +166,7 @@ export const TaskTool = Tool.define(
     return {
     return {
       description: DESCRIPTION,
       description: DESCRIPTION,
       parameters: Parameters,
       parameters: Parameters,
-      execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
+      execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
     }
     }
   }),
   }),
 )
 )

+ 13 - 5
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 * as Tool from "./tool"
 import DESCRIPTION_WRITE from "./todowrite.txt"
 import DESCRIPTION_WRITE from "./todowrite.txt"
 import { Todo } from "../session/todo"
 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 = {
 type Metadata = {
@@ -20,7 +28,7 @@ export const TodoWriteTool = Tool.define<typeof Parameters, Metadata, Todo.Servi
     return {
     return {
       description: DESCRIPTION_WRITE,
       description: DESCRIPTION_WRITE,
       parameters: Parameters,
       parameters: Parameters,
-      execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
+      execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
         Effect.gen(function* () {
         Effect.gen(function* () {
           yield* ctx.ask({
           yield* ctx.ask({
             permission: "todowrite",
             permission: "todowrite",

+ 30 - 25
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 { MessageV2 } from "../session/message-v2"
 import type { Permission } from "../permission"
 import type { Permission } from "../permission"
 import type { SessionID, MessageID } from "../session/schema"
 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">[]
   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
   id: string
   description: string
   description: string
   parameters: Parameters
   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>,
   Def<Parameters, M>,
   "id"
   "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
   id: string
   init: () => Effect.Effect<DefWithoutID<Parameters, M>>
   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>
   | DefWithoutID<Parameters, M>
   | (() => Effect.Effect<DefWithoutID<Parameters, M>>)
   | (() => Effect.Effect<DefWithoutID<Parameters, M>>)
 
 
 export type InferParameters<T> =
 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> =
 export type InferMetadata<T> =
   T extends Info<any, infer M> ? M : T extends Effect.Effect<Info<any, infer M>, any, any> ? M : never
   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>
       ? Def<P, M>
       : never
       : never
 
 
-function wrap<Parameters extends z.ZodType, Result extends Metadata>(
+function wrap<Parameters extends Schema.Decoder<unknown>, Result extends Metadata>(
   id: string,
   id: string,
   init: Init<Parameters, Result>,
   init: Init<Parameters, Result>,
   truncate: Truncate.Interface,
   truncate: Truncate.Interface,
@@ -74,6 +77,10 @@ function wrap<Parameters extends z.ZodType, Result extends Metadata>(
   return () =>
   return () =>
     Effect.gen(function* () {
     Effect.gen(function* () {
       const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init }
       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
       const execute = toolInfo.execute
       toolInfo.execute = (args, ctx) => {
       toolInfo.execute = (args, ctx) => {
         const attrs = {
         const attrs = {
@@ -83,19 +90,17 @@ function wrap<Parameters extends z.ZodType, Result extends Metadata>(
           ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}),
           ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}),
         }
         }
         return Effect.gen(function* () {
         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) {
           if (result.metadata.truncated !== undefined) {
             return result
             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,
   id: ID,
   init: Effect.Effect<Init<Parameters, Result>, never, R>,
   init: Effect.Effect<Init<Parameters, Result>, never, R>,
 ): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service> & { id: ID } {
 ): 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* () {
   return Effect.gen(function* () {
     const init = yield* info.init()
     const init = yield* info.init()
     return {
     return {

+ 10 - 10
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 { HttpClient, HttpClientRequest } from "effect/unstable/http"
 import * as Tool from "./tool"
 import * as Tool from "./tool"
 import TurndownService from "turndown"
 import TurndownService from "turndown"
@@ -9,13 +8,14 @@ const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
 const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
 const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
 const MAX_TIMEOUT = 120 * 1000 // 2 minutes
 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(
 export const WebFetchTool = Tool.define(
@@ -27,7 +27,7 @@ export const WebFetchTool = Tool.define(
     return {
     return {
       description: DESCRIPTION,
       description: DESCRIPTION,
       parameters: Parameters,
       parameters: Parameters,
-      execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
+      execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
         Effect.gen(function* () {
         Effect.gen(function* () {
           if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
           if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
             throw new Error("URL must start with http:// or https://")
             throw new Error("URL must start with http:// or https://")

+ 16 - 19
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 { HttpClient } from "effect/unstable/http"
 import * as Tool from "./tool"
 import * as Tool from "./tool"
 import * as McpExa from "./mcp-exa"
 import * as McpExa from "./mcp-exa"
 import DESCRIPTION from "./websearch.txt"
 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')",
       "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(
 export const WebSearchTool = Tool.define(
@@ -34,7 +31,7 @@ export const WebSearchTool = Tool.define(
         return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
         return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
       },
       },
       parameters: Parameters,
       parameters: Parameters,
-      execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
+      execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
         Effect.gen(function* () {
         Effect.gen(function* () {
           yield* ctx.ask({
           yield* ctx.ask({
             permission: "websearch",
             permission: "websearch",

+ 6 - 4
packages/opencode/src/tool/write.ts

@@ -1,4 +1,4 @@
-import z from "zod"
+import { Schema } from "effect"
 import * as path from "path"
 import * as path from "path"
 import { Effect } from "effect"
 import { Effect } from "effect"
 import * as Tool from "./tool"
 import * as Tool from "./tool"
@@ -16,9 +16,11 @@ import { assertExternalDirectoryEffect } from "./external-directory"
 
 
 const MAX_PROJECT_DIAGNOSTICS_FILES = 5
 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(
 export const WriteTool = Tool.define(

+ 10 - 0
packages/opencode/src/util/effect-zod.ts

@@ -59,6 +59,16 @@ export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Ty
   return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
   return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
 }
 }
 
 
+/**
+ * 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 {
 function walk(ast: SchemaAST.AST): z.ZodTypeAny {
   const cached = walkCache.get(ast)
   const cached = walkCache.get(ast)
   if (cached) return cached
   if (cached) return cached

+ 54 - 51
packages/opencode/test/tool/parameters.test.ts

@@ -1,11 +1,13 @@
 import { describe, expect, test } from "bun:test"
 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
 // 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
 // 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
 // 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 ApplyPatch } from "../../src/tool/apply_patch"
 import { Parameters as Bash } from "../../src/tool/bash"
 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 WebSearch } from "../../src/tool/websearch"
 import { Parameters as Write } from "../../src/tool/write"
 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 = <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("tool parameters", () => {
   describe("JSON Schema (wire shape)", () => {
   describe("JSON Schema (wire shape)", () => {
@@ -55,53 +58,53 @@ describe("tool parameters", () => {
 
 
   describe("apply_patch", () => {
   describe("apply_patch", () => {
     test("accepts patchText", () => {
     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",
         patchText: "*** Begin Patch\n*** End Patch",
       })
       })
     })
     })
     test("rejects missing patchText", () => {
     test("rejects missing patchText", () => {
-      expect(ApplyPatch.safeParse({}).success).toBe(false)
+      expect(accepts(ApplyPatch, {})).toBe(false)
     })
     })
     test("rejects non-string patchText", () => {
     test("rejects non-string patchText", () => {
-      expect(ApplyPatch.safeParse({ patchText: 123 }).success).toBe(false)
+      expect(accepts(ApplyPatch, { patchText: 123 })).toBe(false)
     })
     })
   })
   })
 
 
   describe("bash", () => {
   describe("bash", () => {
     test("accepts minimum: command + description", () => {
     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", () => {
     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.timeout).toBe(5000)
       expect(parsed.workdir).toBe("/tmp")
       expect(parsed.workdir).toBe("/tmp")
     })
     })
     test("rejects missing description (required by zod)", () => {
     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", () => {
     test("rejects missing command", () => {
-      expect(Bash.safeParse({ description: "list" }).success).toBe(false)
+      expect(accepts(Bash, { description: "list" })).toBe(false)
     })
     })
   })
   })
 
 
   describe("codesearch", () => {
   describe("codesearch", () => {
     test("accepts query; tokensNum defaults to 5000", () => {
     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", () => {
     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", () => {
     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", () => {
     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", () => {
   describe("edit", () => {
     test("accepts all four fields", () => {
     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",
         filePath: "/a",
         oldString: "x",
         oldString: "x",
         newString: "y",
         newString: "y",
@@ -109,72 +112,72 @@ describe("tool parameters", () => {
       })
       })
     })
     })
     test("replaceAll is optional", () => {
     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()
       expect(parsed.replaceAll).toBeUndefined()
     })
     })
     test("rejects missing filePath", () => {
     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", () => {
   describe("glob", () => {
     test("accepts pattern-only", () => {
     test("accepts pattern-only", () => {
-      expect(Glob.parse({ pattern: "**/*.ts" })).toEqual({ pattern: "**/*.ts" })
+      expect(parse(Glob, { pattern: "**/*.ts" })).toEqual({ pattern: "**/*.ts" })
     })
     })
     test("accepts optional path", () => {
     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", () => {
     test("rejects missing pattern", () => {
-      expect(Glob.safeParse({}).success).toBe(false)
+      expect(accepts(Glob, {})).toBe(false)
     })
     })
   })
   })
 
 
   describe("grep", () => {
   describe("grep", () => {
     test("accepts pattern-only", () => {
     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", () => {
     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.path).toBe("/tmp")
       expect(parsed.include).toBe("*.ts")
       expect(parsed.include).toBe("*.ts")
     })
     })
     test("rejects missing pattern", () => {
     test("rejects missing pattern", () => {
-      expect(Grep.safeParse({}).success).toBe(false)
+      expect(accepts(Grep, {})).toBe(false)
     })
     })
   })
   })
 
 
   describe("invalid", () => {
   describe("invalid", () => {
     test("accepts tool + error", () => {
     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", () => {
     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", () => {
   describe("lsp", () => {
     test("accepts all fields", () => {
     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")
       expect(parsed.operation).toBe("hover")
     })
     })
     test("rejects line < 1", () => {
     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", () => {
     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", () => {
     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", () => {
   describe("multiedit", () => {
     test("accepts empty edits array", () => {
     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", () => {
     test("accepts an edit entry", () => {
-      const parsed = MultiEdit.parse({
+      const parsed = parse(MultiEdit, {
         filePath: "/a",
         filePath: "/a",
         edits: [{ filePath: "/a", oldString: "x", newString: "y" }],
         edits: [{ filePath: "/a", oldString: "x", newString: "y" }],
       })
       })
@@ -184,13 +187,13 @@ describe("tool parameters", () => {
 
 
   describe("plan", () => {
   describe("plan", () => {
     test("accepts empty object", () => {
     test("accepts empty object", () => {
-      expect(Plan.parse({})).toEqual({})
+      expect(parse(Plan, {})).toEqual({})
     })
     })
   })
   })
 
 
   describe("question", () => {
   describe("question", () => {
     test("accepts questions array", () => {
     test("accepts questions array", () => {
-      const parsed = Question.parse({
+      const parsed = parse(Question, {
         questions: [
         questions: [
           {
           {
             question: "pick one",
             question: "pick one",
@@ -203,16 +206,16 @@ describe("tool parameters", () => {
       expect(parsed.questions.length).toBe(1)
       expect(parsed.questions.length).toBe(1)
     })
     })
     test("rejects missing questions", () => {
     test("rejects missing questions", () => {
-      expect(Question.safeParse({}).success).toBe(false)
+      expect(accepts(Question, {})).toBe(false)
     })
     })
   })
   })
 
 
   describe("read", () => {
   describe("read", () => {
     test("accepts filePath-only", () => {
     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", () => {
     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.offset).toBe(10)
       expect(parsed.limit).toBe(100)
       expect(parsed.limit).toBe(100)
     })
     })
@@ -220,53 +223,53 @@ describe("tool parameters", () => {
 
 
   describe("skill", () => {
   describe("skill", () => {
     test("accepts name", () => {
     test("accepts name", () => {
-      expect(Skill.parse({ name: "foo" }).name).toBe("foo")
+      expect(parse(Skill, { name: "foo" }).name).toBe("foo")
     })
     })
     test("rejects missing name", () => {
     test("rejects missing name", () => {
-      expect(Skill.safeParse({}).success).toBe(false)
+      expect(accepts(Skill, {})).toBe(false)
     })
     })
   })
   })
 
 
   describe("task", () => {
   describe("task", () => {
     test("accepts description + prompt + subagent_type", () => {
     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")
       expect(parsed.subagent_type).toBe("general")
     })
     })
     test("rejects missing prompt", () => {
     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", () => {
   describe("todo", () => {
     test("accepts todos array", () => {
     test("accepts todos array", () => {
-      const parsed = Todo.parse({
+      const parsed = parse(Todo, {
         todos: [{ id: "t1", content: "do x", status: "pending", priority: "medium" }],
         todos: [{ id: "t1", content: "do x", status: "pending", priority: "medium" }],
       })
       })
       expect(parsed.todos.length).toBe(1)
       expect(parsed.todos.length).toBe(1)
     })
     })
     test("rejects missing todos", () => {
     test("rejects missing todos", () => {
-      expect(Todo.safeParse({}).success).toBe(false)
+      expect(accepts(Todo, {})).toBe(false)
     })
     })
   })
   })
 
 
   describe("webfetch", () => {
   describe("webfetch", () => {
     test("accepts url-only", () => {
     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", () => {
   describe("websearch", () => {
     test("accepts query", () => {
     test("accepts query", () => {
-      expect(WebSearch.parse({ query: "opencode" }).query).toBe("opencode")
+      expect(parse(WebSearch, { query: "opencode" }).query).toBe("opencode")
     })
     })
   })
   })
 
 
   describe("write", () => {
   describe("write", () => {
     test("accepts content + filePath", () => {
     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", () => {
     test("rejects missing filePath", () => {
-      expect(Write.safeParse({ content: "hi" }).success).toBe(false)
+      expect(accepts(Write, { content: "hi" })).toBe(false)
     })
     })
   })
   })
 })
 })

+ 43 - 3
packages/opencode/test/tool/tool-define.test.ts

@@ -1,13 +1,13 @@
 import { describe, test, expect } from "bun:test"
 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 { Agent } from "../../src/agent/agent"
+import { MessageID, SessionID } from "../../src/session/schema"
 import { Tool } from "../../src/tool"
 import { Tool } from "../../src/tool"
 import { Truncate } from "../../src/tool"
 import { Truncate } from "../../src/tool"
 
 
 const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))
 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) {
 function makeTool(id: string, executeFn?: () => void) {
   return {
   return {
@@ -56,4 +56,44 @@ describe("Tool.define", () => {
 
 
     expect(first).not.toBe(second)
     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 }])
+  })
 })
 })