Skip to content

Commit 3910a6e

Browse files
authored
refactor(tool): migrate tool framework + all 18 built-in tools to Effect Schema (#23244)
1 parent 2489255 commit 3910a6e

25 files changed

Lines changed: 1035 additions & 205 deletions

packages/opencode/src/server/routes/instance/experimental.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Hono } from "hono"
22
import { describeRoute, validator, resolver } from "hono-openapi"
33
import z from "zod"
4+
import * as EffectZod from "@/util/effect-zod"
45
import { ProviderID, ModelID } from "@/provider/schema"
56
import { ToolRegistry } from "@/tool"
67
import { Worktree } from "@/worktree"
@@ -213,7 +214,7 @@ export const ExperimentalRoutes = lazy(() =>
213214
tools.map((t) => ({
214215
id: t.id,
215216
description: t.description,
216-
parameters: z.toJSONSchema(t.parameters),
217+
parameters: EffectZod.toJsonSchema(t.parameters),
217218
})),
218219
)
219220
},

packages/opencode/src/session/prompt.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import path from "path"
22
import os from "os"
33
import z from "zod"
4+
import * as EffectZod from "@/util/effect-zod"
45
import { SessionID, MessageID, PartID } from "./schema"
56
import { MessageV2 } from "./message-v2"
67
import { Log } from "../util"
@@ -405,7 +406,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
405406
providerID: input.model.providerID,
406407
agent: input.agent,
407408
})) {
408-
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
409+
const schema = ProviderTransform.schema(input.model, EffectZod.toJsonSchema(item.parameters))
409410
tools[item.id] = tool({
410411
description: item.description,
411412
inputSchema: jsonSchema(schema),

packages/opencode/src/tool/apply_patch.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import z from "zod"
21
import * as path from "path"
3-
import { Effect } from "effect"
2+
import { Effect, Schema } from "effect"
43
import * as Tool from "./tool"
54
import { Bus } from "../bus"
65
import { FileWatcher } from "../file/watcher"
@@ -16,8 +15,8 @@ import { File } from "../file"
1615
import { Format } from "../format"
1716
import * as Bom from "@/util/bom"
1817

19-
const PatchParams = z.object({
20-
patchText: z.string().describe("The full patch text that describes all changes to be made"),
18+
export const Parameters = Schema.Struct({
19+
patchText: Schema.String.annotate({ description: "The full patch text that describes all changes to be made" }),
2120
})
2221

2322
export const ApplyPatchTool = Tool.define(
@@ -28,7 +27,7 @@ export const ApplyPatchTool = Tool.define(
2827
const format = yield* Format.Service
2928
const bus = yield* Bus.Service
3029

31-
const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer<typeof PatchParams>, ctx: Tool.Context) {
30+
const run = Effect.fn("ApplyPatchTool.execute")(function* (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) {
3231
if (!params.patchText) {
3332
return yield* Effect.fail(new Error("patchText is required"))
3433
}
@@ -297,8 +296,8 @@ export const ApplyPatchTool = Tool.define(
297296

298297
return {
299298
description: DESCRIPTION,
300-
parameters: PatchParams,
301-
execute: (params: z.infer<typeof PatchParams>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
299+
parameters: Parameters,
300+
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
302301
}
303302
}),
304303
)

packages/opencode/src/tool/bash.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import z from "zod"
1+
import { Schema } from "effect"
22
import os from "os"
33
import { createWriteStream } from "node:fs"
44
import * as Tool from "./tool"
@@ -50,20 +50,16 @@ const FILES = new Set([
5050
const FLAGS = new Set(["-destination", "-literalpath", "-path"])
5151
const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
5252

53-
const Parameters = z.object({
54-
command: z.string().describe("The command to execute"),
55-
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
56-
workdir: z
57-
.string()
58-
.describe(
59-
`The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
60-
)
61-
.optional(),
62-
description: z
63-
.string()
64-
.describe(
53+
export const Parameters = Schema.Struct({
54+
command: Schema.String.annotate({ description: "The command to execute" }),
55+
timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in milliseconds" }),
56+
workdir: Schema.optional(Schema.String).annotate({
57+
description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
58+
}),
59+
description: Schema.String.annotate({
60+
description:
6561
"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'",
66-
),
62+
}),
6763
})
6864

6965
type Part = {
@@ -587,7 +583,7 @@ export const BashTool = Tool.define(
587583
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
588584
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
589585
parameters: Parameters,
590-
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
586+
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
591587
Effect.gen(function* () {
592588
const cwd = params.workdir
593589
? yield* resolvePath(params.workdir, Instance.directory, shell)

packages/opencode/src/tool/codesearch.ts

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,31 @@
1-
import z from "zod"
2-
import { Effect } from "effect"
1+
import { Effect, Schema } from "effect"
32
import { HttpClient } from "effect/unstable/http"
43
import * as Tool from "./tool"
54
import * as McpExa from "./mcp-exa"
65
import DESCRIPTION from "./codesearch.txt"
76

7+
export const Parameters = Schema.Struct({
8+
query: Schema.String.annotate({
9+
description:
10+
"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'",
11+
}),
12+
tokensNum: Schema.Number.check(Schema.isGreaterThanOrEqualTo(1000))
13+
.check(Schema.isLessThanOrEqualTo(50000))
14+
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000)))
15+
.annotate({
16+
description:
17+
"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.",
18+
}),
19+
})
20+
821
export const CodeSearchTool = Tool.define(
922
"codesearch",
1023
Effect.gen(function* () {
1124
const http = yield* HttpClient.HttpClient
1225

1326
return {
1427
description: DESCRIPTION,
15-
parameters: z.object({
16-
query: z
17-
.string()
18-
.describe(
19-
"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'",
20-
),
21-
tokensNum: z
22-
.number()
23-
.min(1000)
24-
.max(50000)
25-
.default(5000)
26-
.describe(
27-
"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.",
28-
),
29-
}),
28+
parameters: Parameters,
3029
execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) =>
3130
Effect.gen(function* () {
3231
yield* ctx.ask({
@@ -45,7 +44,7 @@ export const CodeSearchTool = Tool.define(
4544
McpExa.CodeArgs,
4645
{
4746
query: params.query,
48-
tokensNum: params.tokensNum || 5000,
47+
tokensNum: params.tokensNum,
4948
},
5049
"30 seconds",
5150
)

packages/opencode/src/tool/edit.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@
33
// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
44
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts
55

6-
import z from "zod"
76
import * as path from "path"
8-
import { Effect, Semaphore } from "effect"
7+
import { Effect, Schema, Semaphore } from "effect"
98
import * as Tool from "./tool"
109
import { LSP } from "../lsp"
1110
import { createTwoFilesPatch, diffLines } from "diff"
@@ -45,11 +44,15 @@ function lock(filePath: string) {
4544
return next
4645
}
4746

48-
const Parameters = z.object({
49-
filePath: z.string().describe("The absolute path to the file to modify"),
50-
oldString: z.string().describe("The text to replace"),
51-
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
52-
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
47+
export const Parameters = Schema.Struct({
48+
filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }),
49+
oldString: Schema.String.annotate({ description: "The text to replace" }),
50+
newString: Schema.String.annotate({
51+
description: "The text to replace it with (must be different from oldString)",
52+
}),
53+
replaceAll: Schema.optional(Schema.Boolean).annotate({
54+
description: "Replace all occurrences of oldString (default false)",
55+
}),
5356
})
5457

5558
export const EditTool = Tool.define(
@@ -63,7 +66,7 @@ export const EditTool = Tool.define(
6366
return {
6467
description: DESCRIPTION,
6568
parameters: Parameters,
66-
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
69+
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
6770
Effect.gen(function* () {
6871
if (!params.filePath) {
6972
throw new Error("filePath is required")

packages/opencode/src/tool/glob.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import path from "path"
2-
import z from "zod"
3-
import { Effect, Option } from "effect"
2+
import { Effect, Option, Schema } from "effect"
43
import * as Stream from "effect/Stream"
54
import { InstanceState } from "@/effect"
65
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
@@ -9,6 +8,13 @@ import { assertExternalDirectoryEffect } from "./external-directory"
98
import DESCRIPTION from "./glob.txt"
109
import * as Tool from "./tool"
1110

11+
export const Parameters = Schema.Struct({
12+
pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }),
13+
path: Schema.optional(Schema.String).annotate({
14+
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.`,
15+
}),
16+
})
17+
1218
export const GlobTool = Tool.define(
1319
"glob",
1420
Effect.gen(function* () {
@@ -17,15 +23,7 @@ export const GlobTool = Tool.define(
1723

1824
return {
1925
description: DESCRIPTION,
20-
parameters: z.object({
21-
pattern: z.string().describe("The glob pattern to match files against"),
22-
path: z
23-
.string()
24-
.optional()
25-
.describe(
26-
`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.`,
27-
),
28-
}),
26+
parameters: Parameters,
2927
execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
3028
Effect.gen(function* () {
3129
const ins = yield* InstanceState.context

packages/opencode/src/tool/grep.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import path from "path"
2-
import z from "zod"
2+
import { Schema } from "effect"
33
import { Effect, Option } from "effect"
44
import { InstanceState } from "@/effect"
55
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
@@ -10,6 +10,16 @@ import * as Tool from "./tool"
1010

1111
const MAX_LINE_LENGTH = 2000
1212

13+
export const Parameters = Schema.Struct({
14+
pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }),
15+
path: Schema.optional(Schema.String).annotate({
16+
description: "The directory to search in. Defaults to the current working directory.",
17+
}),
18+
include: Schema.optional(Schema.String).annotate({
19+
description: 'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
20+
}),
21+
})
22+
1323
export const GrepTool = Tool.define(
1424
"grep",
1525
Effect.gen(function* () {
@@ -18,11 +28,7 @@ export const GrepTool = Tool.define(
1828

1929
return {
2030
description: DESCRIPTION,
21-
parameters: z.object({
22-
pattern: z.string().describe("The regex pattern to search for in file contents"),
23-
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
24-
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
25-
}),
31+
parameters: Parameters,
2632
execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
2733
Effect.gen(function* () {
2834
const empty = {

packages/opencode/src/tool/invalid.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
import z from "zod"
2-
import { Effect } from "effect"
1+
import { Effect, Schema } from "effect"
32
import * as Tool from "./tool"
43

4+
export const Parameters = Schema.Struct({
5+
tool: Schema.String,
6+
error: Schema.String,
7+
})
8+
59
export const InvalidTool = Tool.define(
610
"invalid",
711
Effect.succeed({
812
description: "Do not use",
9-
parameters: z.object({
10-
tool: z.string(),
11-
error: z.string(),
12-
}),
13+
parameters: Parameters,
1314
execute: (params: { tool: string; error: string }) =>
1415
Effect.succeed({
1516
title: "Invalid Tool",

packages/opencode/src/tool/lsp.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import z from "zod"
2-
import { Effect } from "effect"
1+
import { Effect, Schema } from "effect"
32
import * as Tool from "./tool"
43
import path from "path"
54
import { LSP } from "../lsp"
@@ -21,6 +20,17 @@ const operations = [
2120
"outgoingCalls",
2221
] as const
2322

23+
export const Parameters = Schema.Struct({
24+
operation: Schema.Literals(operations).annotate({ description: "The LSP operation to perform" }),
25+
filePath: Schema.String.annotate({ description: "The absolute or relative path to the file" }),
26+
line: Schema.Number.check(Schema.isInt())
27+
.check(Schema.isGreaterThanOrEqualTo(1))
28+
.annotate({ description: "The line number (1-based, as shown in editors)" }),
29+
character: Schema.Number.check(Schema.isInt())
30+
.check(Schema.isGreaterThanOrEqualTo(1))
31+
.annotate({ description: "The character offset (1-based, as shown in editors)" }),
32+
})
33+
2434
export const LspTool = Tool.define(
2535
"lsp",
2636
Effect.gen(function* () {
@@ -29,12 +39,7 @@ export const LspTool = Tool.define(
2939

3040
return {
3141
description: DESCRIPTION,
32-
parameters: z.object({
33-
operation: z.enum(operations).describe("The LSP operation to perform"),
34-
filePath: z.string().describe("The absolute or relative path to the file"),
35-
line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"),
36-
character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
37-
}),
42+
parameters: Parameters,
3843
execute: (
3944
args: { operation: (typeof operations)[number]; filePath: string; line: number; character: number },
4045
ctx: Tool.Context,

0 commit comments

Comments
 (0)