diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts index 9c86494987..e29da4cbd0 100644 --- a/packages/opencode/src/server/routes/instance/experimental.ts +++ b/packages/opencode/src/server/routes/instance/experimental.ts @@ -1,6 +1,7 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" +import * as EffectZod from "@/util/effect-zod" import { ProviderID, ModelID } from "@/provider/schema" import { ToolRegistry } from "@/tool" import { Worktree } from "@/worktree" @@ -213,7 +214,7 @@ export const ExperimentalRoutes = lazy(() => tools.map((t) => ({ id: t.id, description: t.description, - parameters: z.toJSONSchema(t.parameters), + parameters: EffectZod.toJsonSchema(t.parameters), })), ) }, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 431189d19c..fb90f29be4 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,6 +1,7 @@ import path from "path" import os from "os" import z from "zod" +import * as EffectZod from "@/util/effect-zod" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" import { Log } from "../util" @@ -403,7 +404,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the providerID: input.model.providerID, agent: input.agent, })) { - const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) + const schema = ProviderTransform.schema(input.model, EffectZod.toJsonSchema(item.parameters)) tools[item.id] = tool({ description: item.description, inputSchema: jsonSchema(schema), diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 3f52d9ac1a..e56777f167 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -1,6 +1,5 @@ -import z from "zod" import * as path from "path" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" import { Bus } from "../bus" import { FileWatcher } from "../file/watcher" @@ -15,8 +14,8 @@ import DESCRIPTION from "./apply_patch.txt" import { File } from "../file" import { Format } from "../format" -export const Parameters = z.object({ - patchText: z.string().describe("The full patch text that describes all changes to be made"), +export const Parameters = Schema.Struct({ + patchText: Schema.String.annotate({ description: "The full patch text that describes all changes to be made" }), }) export const ApplyPatchTool = Tool.define( @@ -27,7 +26,7 @@ export const ApplyPatchTool = Tool.define( const format = yield* Format.Service const bus = yield* Bus.Service - const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer, ctx: Tool.Context) { + const run = Effect.fn("ApplyPatchTool.execute")(function* (params: Schema.Schema.Type, ctx: Tool.Context) { if (!params.patchText) { return yield* Effect.fail(new Error("patchText is required")) } @@ -288,7 +287,7 @@ export const ApplyPatchTool = Tool.define( return { description: DESCRIPTION, parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index a3a56820e9..2d4d59b1bf 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -1,4 +1,4 @@ -import z from "zod" +import { Schema } from "effect" import os from "os" import { createWriteStream } from "node:fs" import * as Tool from "./tool" @@ -50,20 +50,16 @@ const FILES = new Set([ const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) -export const Parameters = z.object({ - command: z.string().describe("The command to execute"), - timeout: z.number().describe("Optional timeout in milliseconds").optional(), - workdir: z - .string() - .describe( - `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, - ) - .optional(), - description: z - .string() - .describe( +export const Parameters = Schema.Struct({ + command: Schema.String.annotate({ description: "The command to execute" }), + timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in milliseconds" }), + workdir: Schema.optional(Schema.String).annotate({ + description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, + }), + description: Schema.String.annotate({ + description: "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - ), + }), }) type Part = { @@ -587,7 +583,7 @@ export const BashTool = Tool.define( .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { const cwd = params.workdir ? yield* resolvePath(params.workdir, Instance.directory, shell) diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts index b0d6fab625..e10d21175e 100644 --- a/packages/opencode/src/tool/codesearch.ts +++ b/packages/opencode/src/tool/codesearch.ts @@ -1,24 +1,21 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import { HttpClient } from "effect/unstable/http" import * as Tool from "./tool" import * as McpExa from "./mcp-exa" import DESCRIPTION from "./codesearch.txt" -export const Parameters = z.object({ - query: z - .string() - .describe( +export const Parameters = Schema.Struct({ + query: Schema.String.annotate({ + description: "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'", - ), - tokensNum: z - .number() - .min(1000) - .max(50000) - .default(5000) - .describe( - "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.", - ), + }), + tokensNum: Schema.Number.check(Schema.isGreaterThanOrEqualTo(1000)) + .check(Schema.isLessThanOrEqualTo(50000)) + .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000))) + .annotate({ + description: + "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.", + }), }) export const CodeSearchTool = Tool.define( @@ -47,7 +44,7 @@ export const CodeSearchTool = Tool.define( McpExa.CodeArgs, { query: params.query, - tokensNum: params.tokensNum || 5000, + tokensNum: params.tokensNum, }, "30 seconds", ) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 20b9db25df..36feb3d414 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -3,9 +3,8 @@ // https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts // https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts -import z from "zod" import * as path from "path" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch, diffLines } from "diff" @@ -32,11 +31,15 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string { return text.replaceAll("\n", "\r\n") } -export const Parameters = z.object({ - filePath: z.string().describe("The absolute path to the file to modify"), - oldString: z.string().describe("The text to replace"), - newString: z.string().describe("The text to replace it with (must be different from oldString)"), - replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), +export const Parameters = Schema.Struct({ + filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }), + oldString: Schema.String.annotate({ description: "The text to replace" }), + newString: Schema.String.annotate({ + description: "The text to replace it with (must be different from oldString)", + }), + replaceAll: Schema.optional(Schema.Boolean).annotate({ + description: "Replace all occurrences of oldString (default false)", + }), }) export const EditTool = Tool.define( @@ -50,7 +53,7 @@ export const EditTool = Tool.define( return { description: DESCRIPTION, parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { if (!params.filePath) { throw new Error("filePath is required") diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 208bb6913c..aeecfecb72 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -1,6 +1,5 @@ import path from "path" -import z from "zod" -import { Effect, Option } from "effect" +import { Effect, Option, Schema } from "effect" import * as Stream from "effect/Stream" import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" @@ -9,14 +8,11 @@ import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./glob.txt" import * as Tool from "./tool" -export const Parameters = z.object({ - pattern: z.string().describe("The glob pattern to match files against"), - path: z - .string() - .optional() - .describe( - `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`, - ), +export const Parameters = Schema.Struct({ + pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }), + path: Schema.optional(Schema.String).annotate({ + description: `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`, + }), }) export const GlobTool = Tool.define( diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 5fc0c02246..4160054311 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -1,5 +1,5 @@ import path from "path" -import z from "zod" +import { Schema } from "effect" import { Effect, Option } from "effect" import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" @@ -10,10 +10,14 @@ import * as Tool from "./tool" const MAX_LINE_LENGTH = 2000 -export const Parameters = z.object({ - pattern: z.string().describe("The regex pattern to search for in file contents"), - path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."), - include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'), +export const Parameters = Schema.Struct({ + pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }), + path: Schema.optional(Schema.String).annotate({ + description: "The directory to search in. Defaults to the current working directory.", + }), + include: Schema.optional(Schema.String).annotate({ + description: 'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")', + }), }) export const GrepTool = Tool.define( diff --git a/packages/opencode/src/tool/invalid.ts b/packages/opencode/src/tool/invalid.ts index e080d820d6..b8d145d0be 100644 --- a/packages/opencode/src/tool/invalid.ts +++ b/packages/opencode/src/tool/invalid.ts @@ -1,10 +1,9 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" -export const Parameters = z.object({ - tool: z.string(), - error: z.string(), +export const Parameters = Schema.Struct({ + tool: Schema.String, + error: Schema.String, }) export const InvalidTool = Tool.define( diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index fe25f661af..f50d8a9407 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -1,5 +1,4 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" import path from "path" import { LSP } from "../lsp" @@ -21,11 +20,15 @@ const operations = [ "outgoingCalls", ] as const -export const Parameters = z.object({ - operation: z.enum(operations).describe("The LSP operation to perform"), - filePath: z.string().describe("The absolute or relative path to the file"), - line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"), - character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"), +export const Parameters = Schema.Struct({ + operation: Schema.Literals(operations).annotate({ description: "The LSP operation to perform" }), + filePath: Schema.String.annotate({ description: "The absolute or relative path to the file" }), + line: Schema.Number.check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(1)) + .annotate({ description: "The line number (1-based, as shown in editors)" }), + character: Schema.Number.check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(1)) + .annotate({ description: "The character offset (1-based, as shown in editors)" }), }) export const LspTool = Tool.define( diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts index 16a4c86767..7454556cd6 100644 --- a/packages/opencode/src/tool/multiedit.ts +++ b/packages/opencode/src/tool/multiedit.ts @@ -1,23 +1,26 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" import { EditTool } from "./edit" import DESCRIPTION from "./multiedit.txt" import path from "path" import { Instance } from "../project/instance" -export const Parameters = z.object({ - filePath: z.string().describe("The absolute path to the file to modify"), - edits: z - .array( - z.object({ - filePath: z.string().describe("The absolute path to the file to modify"), - oldString: z.string().describe("The text to replace"), - newString: z.string().describe("The text to replace it with (must be different from oldString)"), - replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), +export const Parameters = Schema.Struct({ + filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }), + edits: Schema.mutable( + Schema.Array( + Schema.Struct({ + filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }), + oldString: Schema.String.annotate({ description: "The text to replace" }), + newString: Schema.String.annotate({ + description: "The text to replace it with (must be different from oldString)", + }), + replaceAll: Schema.optional(Schema.Boolean).annotate({ + description: "Replace all occurrences of oldString (default false)", + }), }), - ) - .describe("Array of edit operations to perform sequentially on the file"), + ), + ).annotate({ description: "Array of edit operations to perform sequentially on the file" }), }) export const MultiEditTool = Tool.define( diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index 4afd4088ba..8e2f11360e 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -1,6 +1,5 @@ -import z from "zod" import path from "path" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" import { Question } from "../question" import { Session } from "../session" @@ -17,7 +16,7 @@ function getLastModel(sessionID: SessionID) { return undefined } -export const Parameters = z.object({}) +export const Parameters = Schema.Struct({}) export const PlanExitTool = Tool.define( "plan_exit", diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index fed69784d0..51f1e71e28 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -1,11 +1,10 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" import { Question } from "../question" import DESCRIPTION from "./question.txt" -export const Parameters = z.object({ - questions: z.array(Question.Prompt.zod).describe("Questions to ask"), +export const Parameters = Schema.Struct({ + questions: Schema.mutable(Schema.Array(Question.Prompt)).annotate({ description: "Questions to ask" }), }) type Metadata = { @@ -20,7 +19,7 @@ export const QuestionTool = Tool.define, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { const answers = yield* question.ask({ sessionID: ctx.sessionID, diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 23524ee5eb..4dd5cb3ea6 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -1,5 +1,4 @@ -import z from "zod" -import { Effect, Scope } from "effect" +import { Effect, Schema, Scope } from "effect" import { createReadStream } from "fs" import { open } from "fs/promises" import * as path from "path" @@ -18,10 +17,19 @@ const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` const MAX_BYTES = 50 * 1024 const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB` -export const Parameters = z.object({ - filePath: z.string().describe("The absolute path to the file or directory to read"), - offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(), - limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(), +// `offset` and `limit` were originally `z.coerce.number()` — the runtime +// coercion was useful when the tool was called from a shell but serves no +// purpose in the LLM tool-call path (the model emits typed JSON). The JSON +// Schema output is identical (`type: "number"`), so the LLM view is +// unchanged; purely CLI-facing uses must now send numbers rather than strings. +export const Parameters = Schema.Struct({ + filePath: Schema.String.annotate({ description: "The absolute path to the file or directory to read" }), + offset: Schema.optional(Schema.Number).annotate({ + description: "The line number to start reading from (1-indexed)", + }), + limit: Schema.optional(Schema.Number).annotate({ + description: "The maximum number of lines to read (defaults to 2000)", + }), }) export const ReadTool = Tool.define( @@ -77,7 +85,7 @@ export const ReadTool = Tool.define( yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope)) }) - const run = Effect.fn("ReadTool.execute")(function* (params: z.infer, ctx: Tool.Context) { + const run = Effect.fn("ReadTool.execute")(function* (params: Schema.Schema.Type, ctx: Tool.Context) { if (params.offset !== undefined && params.offset < 1) { return yield* Effect.fail(new Error("offset must be greater than or equal to 1")) } @@ -213,7 +221,7 @@ export const ReadTool = Tool.define( return { description: DESCRIPTION, parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index e27593e597..89aa5c99b0 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -15,7 +15,9 @@ import { SkillTool } from "./skill" import * as Tool from "./tool" import { Config } from "../config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" +import { Schema } from "effect" import z from "zod" +import { ZodOverride } from "@/util/effect-zod" import { Plugin } from "../plugin" import { Provider } from "../provider" import { ProviderID, type ModelID } from "../provider/schema" @@ -120,9 +122,17 @@ export const layer: Layer.Layer< const custom: Tool.Def[] = [] function fromPlugin(id: string, def: ToolDefinition): Tool.Def { + // Plugin tools define their args as a raw Zod shape. Wrap the + // derived Zod object in a `Schema.declare` so it slots into the + // Schema-typed framework, and annotate with `ZodOverride` so the + // walker emits the original Zod object for LLM JSON Schema. + const zodParams = z.object(def.args) + const parameters = Schema.declare((u): u is unknown => zodParams.safeParse(u).success).annotate({ + [ZodOverride]: zodParams, + }) return { id, - parameters: z.object(def.args), + parameters, description: def.description, execute: (args, toolCtx) => Effect.gen(function* () { diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 8b9c411a31..b7046b600e 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -1,15 +1,14 @@ import path from "path" import { pathToFileURL } from "url" -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Stream from "effect/Stream" import { EffectLogger } from "@/effect" import { Ripgrep } from "../file/ripgrep" import { Skill } from "../skill" import * as Tool from "./tool" -export const Parameters = z.object({ - name: z.string().describe("The name of the skill from available_skills"), +export const Parameters = Schema.Struct({ + name: Schema.String.annotate({ description: "The name of the skill from available_skills" }), }) export const SkillTool = Tool.define( @@ -43,7 +42,7 @@ export const SkillTool = Tool.define( return { description, parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { const info = yield* skill.get(params.name) if (!info) { diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index c77a15bcb9..98f6bdd98f 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -1,13 +1,12 @@ import * as Tool from "./tool" import DESCRIPTION from "./task.txt" -import z from "zod" import { Session } from "../session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" import type { SessionPrompt } from "../session/prompt" import { Config } from "../config" -import { Effect } from "effect" +import { Effect, Schema } from "effect" export interface TaskPromptOps { cancel(sessionID: SessionID): void @@ -17,17 +16,15 @@ export interface TaskPromptOps { const id = "task" -export const Parameters = z.object({ - description: z.string().describe("A short (3-5 words) description of the task"), - prompt: z.string().describe("The task for the agent to perform"), - subagent_type: z.string().describe("The type of specialized agent to use for this task"), - task_id: z - .string() - .describe( +export const Parameters = Schema.Struct({ + description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }), + prompt: Schema.String.annotate({ description: "The task for the agent to perform" }), + subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }), + task_id: Schema.optional(Schema.String).annotate({ + description: "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)", - ) - .optional(), - command: z.string().describe("The command that triggered this task").optional(), + }), + command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }), }) export const TaskTool = Tool.define( @@ -37,7 +34,7 @@ export const TaskTool = Tool.define( const config = yield* Config.Service const sessions = yield* Session.Service - const run = Effect.fn("TaskTool.execute")(function* (params: z.infer, ctx: Tool.Context) { + const run = Effect.fn("TaskTool.execute")(function* (params: Schema.Schema.Type, ctx: Tool.Context) { const cfg = yield* config.get() if (!ctx.extra?.bypassAgentCheck) { @@ -169,7 +166,7 @@ export const TaskTool = Tool.define( return { description: DESCRIPTION, parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index db041d4ee1..c493d3a71a 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -1,11 +1,19 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" import DESCRIPTION_WRITE from "./todowrite.txt" import { Todo } from "../session/todo" -export const Parameters = z.object({ - todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"), +// Todo.Info is still a zod schema (session/todo.ts). Inline the field shape +// here rather than referencing its `.shape` — the LLM-visible JSON Schema is +// identical, and it removes the last zod dependency from this tool. +const TodoItem = Schema.Struct({ + content: Schema.String.annotate({ description: "Brief description of the task" }), + status: Schema.String.annotate({ description: "Current status of the task: pending, in_progress, completed, cancelled" }), + priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }), +}) + +export const Parameters = Schema.Struct({ + todos: Schema.mutable(Schema.Array(TodoItem)).annotate({ description: "The updated todo list" }), }) type Metadata = { @@ -20,7 +28,7 @@ export const TodoWriteTool = Tool.define, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { yield* ctx.ask({ permission: "todowrite", diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 179149afd2..c9115e9ff1 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -1,5 +1,4 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import type { MessageV2 } from "../session/message-v2" import type { Permission } from "../permission" import type { SessionID, MessageID } from "../session/schema" @@ -32,29 +31,33 @@ export interface ExecuteResult { attachments?: Omit[] } -export interface Def { +export interface Def = Schema.Decoder, M extends Metadata = Metadata> { id: string description: string parameters: Parameters - execute(args: z.infer, ctx: Context): Effect.Effect> - formatValidationError?(error: z.ZodError): string + execute(args: Schema.Schema.Type, ctx: Context): Effect.Effect> + formatValidationError?(error: unknown): string } -export type DefWithoutID = Omit< +export type DefWithoutID = Schema.Decoder, M extends Metadata = Metadata> = Omit< Def, "id" > -export interface Info { +export interface Info = Schema.Decoder, M extends Metadata = Metadata> { id: string init: () => Effect.Effect> } -type Init = +type Init, M extends Metadata> = | DefWithoutID | (() => Effect.Effect>) export type InferParameters = - T extends Info ? z.infer

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

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

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

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

, M extends Metadata>(info: Info): Effect.Effect> { return Effect.gen(function* () { const init = yield* info.init() return { diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index ba80330516..9b39dedca5 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -1,5 +1,4 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" import * as Tool from "./tool" import TurndownService from "turndown" @@ -9,13 +8,14 @@ const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds const MAX_TIMEOUT = 120 * 1000 // 2 minutes -export const Parameters = z.object({ - url: z.string().describe("The URL to fetch content from"), - format: z - .enum(["text", "markdown", "html"]) - .default("markdown") - .describe("The format to return the content in (text, markdown, or html). Defaults to markdown."), - timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(), +export const Parameters = Schema.Struct({ + url: Schema.String.annotate({ description: "The URL to fetch content from" }), + format: Schema.Literals(["text", "markdown", "html"]) + .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const))) + .annotate({ + description: "The format to return the content in (text, markdown, or html). Defaults to markdown.", + }), + timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in seconds (max 120)" }), }) export const WebFetchTool = Tool.define( @@ -27,7 +27,7 @@ export const WebFetchTool = Tool.define( return { description: DESCRIPTION, parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) { throw new Error("URL must start with http:// or https://") diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index 047df79a3d..ff4c696a25 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -1,27 +1,24 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import { HttpClient } from "effect/unstable/http" import * as Tool from "./tool" import * as McpExa from "./mcp-exa" import DESCRIPTION from "./websearch.txt" -export const Parameters = z.object({ - query: z.string().describe("Websearch query"), - numResults: z.number().optional().describe("Number of search results to return (default: 8)"), - livecrawl: z - .enum(["fallback", "preferred"]) - .optional() - .describe( +export const Parameters = Schema.Struct({ + query: Schema.String.annotate({ description: "Websearch query" }), + numResults: Schema.optional(Schema.Number).annotate({ + description: "Number of search results to return (default: 8)", + }), + livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({ + description: "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')", - ), - type: z - .enum(["auto", "fast", "deep"]) - .optional() - .describe("Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search"), - contextMaxCharacters: z - .number() - .optional() - .describe("Maximum characters for context string optimized for LLMs (default: 10000)"), + }), + type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({ + description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search", + }), + contextMaxCharacters: Schema.optional(Schema.Number).annotate({ + description: "Maximum characters for context string optimized for LLMs (default: 10000)", + }), }) export const WebSearchTool = Tool.define( @@ -34,7 +31,7 @@ export const WebSearchTool = Tool.define( return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString()) }, parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { yield* ctx.ask({ permission: "websearch", diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 843e4e7e5f..a676488446 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -1,4 +1,4 @@ -import z from "zod" +import { Schema } from "effect" import * as path from "path" import { Effect } from "effect" import * as Tool from "./tool" @@ -16,9 +16,11 @@ import { assertExternalDirectoryEffect } from "./external-directory" const MAX_PROJECT_DIAGNOSTICS_FILES = 5 -export const Parameters = z.object({ - content: z.string().describe("The content to write to the file"), - filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"), +export const Parameters = Schema.Struct({ + content: Schema.String.annotate({ description: "The content to write to the file" }), + filePath: Schema.String.annotate({ + description: "The absolute path to the file to write (must be absolute, not relative)", + }), }) export const WriteTool = Tool.define( diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index bf1caa035b..6a1105edd2 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -59,6 +59,16 @@ export function zod(schema: S): z.ZodType> } +/** + * Emit a JSON Schema for a tool/route parameter schema — derives the zod form + * via the walker so Effect Schema inputs flow through the same zod-openapi + * pipeline the LLM/SDK layer already depends on. `io: "input"` mirrors what + * `session/prompt.ts` has always passed to `ai`'s `jsonSchema()` helper. + */ +export function toJsonSchema(schema: S) { + return z.toJSONSchema(zod(schema), { io: "input" }) +} + function walk(ast: SchemaAST.AST): z.ZodTypeAny { const cached = walkCache.get(ast) if (cached) return cached diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 2f2c384bbf..92ef21a2f9 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -1,11 +1,13 @@ import { describe, expect, test } from "bun:test" -import z from "zod" +import { Result, Schema } from "effect" +import { toJsonSchema } from "../../src/util/effect-zod" // Each tool exports its parameters schema at module scope so this test can // import them without running the tool's Effect-based init. The JSON Schema // snapshot captures what the LLM sees; the parse assertions pin down the -// accepts/rejects contract. Both must survive any future migration (e.g. from -// zod to Effect Schema via the effect-zod walker) byte-for-byte. +// accepts/rejects contract. `toJsonSchema` is the same helper `session/ +// prompt.ts` uses to emit tool schemas to the LLM, so the snapshots stay +// byte-identical regardless of whether a tool has migrated from zod to Schema. import { Parameters as ApplyPatch } from "../../src/tool/apply_patch" import { Parameters as Bash } from "../../src/tool/bash" @@ -26,10 +28,11 @@ import { Parameters as WebFetch } from "../../src/tool/webfetch" import { Parameters as WebSearch } from "../../src/tool/websearch" import { Parameters as Write } from "../../src/tool/write" -// Helper: the JSON Schema the LLM sees at tool registration time -// (session/prompt.ts runs `z.toJSONSchema(tool.parameters)` with the AI SDK's -// default `io` mode). Snapshots pin the exact wire shape. -const toJsonSchema = (schema: z.ZodType) => z.toJSONSchema(schema, { io: "input" }) +const parse = >(schema: S, input: unknown): S["Type"] => + Schema.decodeUnknownSync(schema)(input) + +const accepts = (schema: Schema.Decoder, input: unknown): boolean => + Result.isSuccess(Schema.decodeUnknownResult(schema)(input)) describe("tool parameters", () => { describe("JSON Schema (wire shape)", () => { @@ -55,53 +58,53 @@ describe("tool parameters", () => { describe("apply_patch", () => { test("accepts patchText", () => { - expect(ApplyPatch.parse({ patchText: "*** Begin Patch\n*** End Patch" })).toEqual({ + expect(parse(ApplyPatch, { patchText: "*** Begin Patch\n*** End Patch" })).toEqual({ patchText: "*** Begin Patch\n*** End Patch", }) }) test("rejects missing patchText", () => { - expect(ApplyPatch.safeParse({}).success).toBe(false) + expect(accepts(ApplyPatch, {})).toBe(false) }) test("rejects non-string patchText", () => { - expect(ApplyPatch.safeParse({ patchText: 123 }).success).toBe(false) + expect(accepts(ApplyPatch, { patchText: 123 })).toBe(false) }) }) describe("bash", () => { test("accepts minimum: command + description", () => { - expect(Bash.parse({ command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) + expect(parse(Bash, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) }) test("accepts optional timeout + workdir", () => { - const parsed = Bash.parse({ command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) + const parsed = parse(Bash, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) expect(parsed.timeout).toBe(5000) expect(parsed.workdir).toBe("/tmp") }) test("rejects missing description (required by zod)", () => { - expect(Bash.safeParse({ command: "ls" }).success).toBe(false) + expect(accepts(Bash, { command: "ls" })).toBe(false) }) test("rejects missing command", () => { - expect(Bash.safeParse({ description: "list" }).success).toBe(false) + expect(accepts(Bash, { description: "list" })).toBe(false) }) }) describe("codesearch", () => { test("accepts query; tokensNum defaults to 5000", () => { - expect(CodeSearch.parse({ query: "hooks" })).toEqual({ query: "hooks", tokensNum: 5000 }) + expect(parse(CodeSearch, { query: "hooks" })).toEqual({ query: "hooks", tokensNum: 5000 }) }) test("accepts override tokensNum", () => { - expect(CodeSearch.parse({ query: "hooks", tokensNum: 10000 }).tokensNum).toBe(10000) + expect(parse(CodeSearch, { query: "hooks", tokensNum: 10000 }).tokensNum).toBe(10000) }) test("rejects tokensNum under 1000", () => { - expect(CodeSearch.safeParse({ query: "x", tokensNum: 500 }).success).toBe(false) + expect(accepts(CodeSearch, { query: "x", tokensNum: 500 })).toBe(false) }) test("rejects tokensNum over 50000", () => { - expect(CodeSearch.safeParse({ query: "x", tokensNum: 60000 }).success).toBe(false) + expect(accepts(CodeSearch, { query: "x", tokensNum: 60000 })).toBe(false) }) }) describe("edit", () => { test("accepts all four fields", () => { - expect(Edit.parse({ filePath: "/a", oldString: "x", newString: "y", replaceAll: true })).toEqual({ + expect(parse(Edit, { filePath: "/a", oldString: "x", newString: "y", replaceAll: true })).toEqual({ filePath: "/a", oldString: "x", newString: "y", @@ -109,72 +112,72 @@ describe("tool parameters", () => { }) }) test("replaceAll is optional", () => { - const parsed = Edit.parse({ filePath: "/a", oldString: "x", newString: "y" }) + const parsed = parse(Edit, { filePath: "/a", oldString: "x", newString: "y" }) expect(parsed.replaceAll).toBeUndefined() }) test("rejects missing filePath", () => { - expect(Edit.safeParse({ oldString: "x", newString: "y" }).success).toBe(false) + expect(accepts(Edit, { oldString: "x", newString: "y" })).toBe(false) }) }) describe("glob", () => { test("accepts pattern-only", () => { - expect(Glob.parse({ pattern: "**/*.ts" })).toEqual({ pattern: "**/*.ts" }) + expect(parse(Glob, { pattern: "**/*.ts" })).toEqual({ pattern: "**/*.ts" }) }) test("accepts optional path", () => { - expect(Glob.parse({ pattern: "**/*.ts", path: "/tmp" }).path).toBe("/tmp") + expect(parse(Glob, { pattern: "**/*.ts", path: "/tmp" }).path).toBe("/tmp") }) test("rejects missing pattern", () => { - expect(Glob.safeParse({}).success).toBe(false) + expect(accepts(Glob, {})).toBe(false) }) }) describe("grep", () => { test("accepts pattern-only", () => { - expect(Grep.parse({ pattern: "TODO" })).toEqual({ pattern: "TODO" }) + expect(parse(Grep, { pattern: "TODO" })).toEqual({ pattern: "TODO" }) }) test("accepts optional path + include", () => { - const parsed = Grep.parse({ pattern: "TODO", path: "/tmp", include: "*.ts" }) + const parsed = parse(Grep, { pattern: "TODO", path: "/tmp", include: "*.ts" }) expect(parsed.path).toBe("/tmp") expect(parsed.include).toBe("*.ts") }) test("rejects missing pattern", () => { - expect(Grep.safeParse({}).success).toBe(false) + expect(accepts(Grep, {})).toBe(false) }) }) describe("invalid", () => { test("accepts tool + error", () => { - expect(Invalid.parse({ tool: "foo", error: "bar" })).toEqual({ tool: "foo", error: "bar" }) + expect(parse(Invalid, { tool: "foo", error: "bar" })).toEqual({ tool: "foo", error: "bar" }) }) test("rejects missing fields", () => { - expect(Invalid.safeParse({ tool: "foo" }).success).toBe(false) - expect(Invalid.safeParse({ error: "bar" }).success).toBe(false) + expect(accepts(Invalid, { tool: "foo" })).toBe(false) + expect(accepts(Invalid, { error: "bar" })).toBe(false) }) }) describe("lsp", () => { test("accepts all fields", () => { - const parsed = Lsp.parse({ operation: "hover", filePath: "/a.ts", line: 1, character: 1 }) + const parsed = parse(Lsp, { operation: "hover", filePath: "/a.ts", line: 1, character: 1 }) expect(parsed.operation).toBe("hover") }) test("rejects line < 1", () => { - expect(Lsp.safeParse({ operation: "hover", filePath: "/a.ts", line: 0, character: 1 }).success).toBe(false) + expect(accepts(Lsp, { operation: "hover", filePath: "/a.ts", line: 0, character: 1 })).toBe(false) }) test("rejects character < 1", () => { - expect(Lsp.safeParse({ operation: "hover", filePath: "/a.ts", line: 1, character: 0 }).success).toBe(false) + expect(accepts(Lsp, { operation: "hover", filePath: "/a.ts", line: 1, character: 0 })).toBe(false) }) test("rejects unknown operation", () => { - expect(Lsp.safeParse({ operation: "bogus", filePath: "/a.ts", line: 1, character: 1 }).success).toBe(false) + expect(accepts(Lsp, { operation: "bogus", filePath: "/a.ts", line: 1, character: 1 })).toBe(false) }) }) describe("multiedit", () => { test("accepts empty edits array", () => { - expect(MultiEdit.parse({ filePath: "/a", edits: [] }).edits).toEqual([]) + expect(parse(MultiEdit, { filePath: "/a", edits: [] }).edits).toEqual([]) }) test("accepts an edit entry", () => { - const parsed = MultiEdit.parse({ + const parsed = parse(MultiEdit, { filePath: "/a", edits: [{ filePath: "/a", oldString: "x", newString: "y" }], }) @@ -184,13 +187,13 @@ describe("tool parameters", () => { describe("plan", () => { test("accepts empty object", () => { - expect(Plan.parse({})).toEqual({}) + expect(parse(Plan, {})).toEqual({}) }) }) describe("question", () => { test("accepts questions array", () => { - const parsed = Question.parse({ + const parsed = parse(Question, { questions: [ { question: "pick one", @@ -203,16 +206,16 @@ describe("tool parameters", () => { expect(parsed.questions.length).toBe(1) }) test("rejects missing questions", () => { - expect(Question.safeParse({}).success).toBe(false) + expect(accepts(Question, {})).toBe(false) }) }) describe("read", () => { test("accepts filePath-only", () => { - expect(Read.parse({ filePath: "/a" }).filePath).toBe("/a") + expect(parse(Read, { filePath: "/a" }).filePath).toBe("/a") }) test("accepts optional offset + limit", () => { - const parsed = Read.parse({ filePath: "/a", offset: 10, limit: 100 }) + const parsed = parse(Read, { filePath: "/a", offset: 10, limit: 100 }) expect(parsed.offset).toBe(10) expect(parsed.limit).toBe(100) }) @@ -220,53 +223,53 @@ describe("tool parameters", () => { describe("skill", () => { test("accepts name", () => { - expect(Skill.parse({ name: "foo" }).name).toBe("foo") + expect(parse(Skill, { name: "foo" }).name).toBe("foo") }) test("rejects missing name", () => { - expect(Skill.safeParse({}).success).toBe(false) + expect(accepts(Skill, {})).toBe(false) }) }) describe("task", () => { test("accepts description + prompt + subagent_type", () => { - const parsed = Task.parse({ description: "d", prompt: "p", subagent_type: "general" }) + const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general" }) expect(parsed.subagent_type).toBe("general") }) test("rejects missing prompt", () => { - expect(Task.safeParse({ description: "d", subagent_type: "general" }).success).toBe(false) + expect(accepts(Task, { description: "d", subagent_type: "general" })).toBe(false) }) }) describe("todo", () => { test("accepts todos array", () => { - const parsed = Todo.parse({ + const parsed = parse(Todo, { todos: [{ id: "t1", content: "do x", status: "pending", priority: "medium" }], }) expect(parsed.todos.length).toBe(1) }) test("rejects missing todos", () => { - expect(Todo.safeParse({}).success).toBe(false) + expect(accepts(Todo, {})).toBe(false) }) }) describe("webfetch", () => { test("accepts url-only", () => { - expect(WebFetch.parse({ url: "https://example.com" }).url).toBe("https://example.com") + expect(parse(WebFetch, { url: "https://example.com" }).url).toBe("https://example.com") }) }) describe("websearch", () => { test("accepts query", () => { - expect(WebSearch.parse({ query: "opencode" }).query).toBe("opencode") + expect(parse(WebSearch, { query: "opencode" }).query).toBe("opencode") }) }) describe("write", () => { test("accepts content + filePath", () => { - expect(Write.parse({ content: "hi", filePath: "/a" })).toEqual({ content: "hi", filePath: "/a" }) + expect(parse(Write, { content: "hi", filePath: "/a" })).toEqual({ content: "hi", filePath: "/a" }) }) test("rejects missing filePath", () => { - expect(Write.safeParse({ content: "hi" }).success).toBe(false) + expect(accepts(Write, { content: "hi" })).toBe(false) }) }) }) diff --git a/packages/opencode/test/tool/tool-define.test.ts b/packages/opencode/test/tool/tool-define.test.ts index 00d1e039a7..283708767d 100644 --- a/packages/opencode/test/tool/tool-define.test.ts +++ b/packages/opencode/test/tool/tool-define.test.ts @@ -1,13 +1,13 @@ import { describe, test, expect } from "bun:test" -import { Effect, Layer, ManagedRuntime } from "effect" -import z from "zod" +import { Effect, Layer, ManagedRuntime, Schema } from "effect" import { Agent } from "../../src/agent/agent" +import { MessageID, SessionID } from "../../src/session/schema" import { Tool } from "../../src/tool" import { Truncate } from "../../src/tool" const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)) -const params = z.object({ input: z.string() }) +const params = Schema.Struct({ input: Schema.String }) function makeTool(id: string, executeFn?: () => void) { return { @@ -56,4 +56,44 @@ describe("Tool.define", () => { expect(first).not.toBe(second) }) + + test("execute receives decoded parameters", async () => { + const parameters = Schema.Struct({ + count: Schema.NumberFromString.pipe(Schema.optional, Schema.withDecodingDefaultType(Effect.succeed(5))), + }) + const calls: Array> = [] + const info = await runtime.runPromise( + Tool.define( + "test-decoded", + Effect.succeed({ + description: "test tool", + parameters, + execute(args: Schema.Schema.Type) { + calls.push(args) + return Effect.succeed({ title: "test", output: "ok", metadata: { truncated: false } }) + }, + }), + ), + ) + const ctx: Tool.Context = { + sessionID: SessionID.descending(), + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata() { + return Effect.void + }, + ask() { + return Effect.void + }, + } + const tool = await Effect.runPromise(info.init()) + const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType + + await Effect.runPromise(execute({}, ctx)) + await Effect.runPromise(execute({ count: "7" }, ctx)) + + expect(calls).toEqual([{ count: 5 }, { count: 7 }]) + }) })