Skip to content

Commit 0959d9e

Browse files
committed
refactor(effect): resolve built tools through the registry
1 parent 38014fe commit 0959d9e

File tree

10 files changed

+224
-146
lines changed

10 files changed

+224
-146
lines changed

packages/opencode/src/cli/cmd/run.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,13 @@ import { BashTool } from "../../tool/bash"
2828
import { TodoWriteTool } from "../../tool/todo"
2929
import { Locale } from "../../util/locale"
3030

31-
type ToolProps<T extends Tool.Info> = {
31+
type ToolProps<T> = {
3232
input: Tool.InferParameters<T>
3333
metadata: Tool.InferMetadata<T>
3434
part: ToolPart
3535
}
3636

37-
function props<T extends Tool.Info>(part: ToolPart): ToolProps<T> {
37+
function props<T>(part: ToolPart): ToolProps<T> {
3838
const state = part.state
3939
return {
4040
input: state.input as Tool.InferParameters<T>,

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1572,7 +1572,7 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
15721572
)
15731573
}
15741574

1575-
type ToolProps<T extends Tool.Info> = {
1575+
type ToolProps<T> = {
15761576
input: Partial<Tool.InferParameters<T>>
15771577
metadata: Partial<Tool.InferMetadata<T>>
15781578
permission: Record<string, any>

packages/opencode/src/question/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ export namespace Question {
198198
}),
199199
)
200200

201-
const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
201+
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
202202

203203
const { runPromise } = makeRuntime(Service, defaultLayer)
204204

packages/opencode/src/session/prompt.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
560560
}) {
561561
const { task, model, lastUser, sessionID, session, msgs } = input
562562
const ctx = yield* InstanceState.context
563-
const taskTool = yield* Effect.promise(() => TaskTool.init())
563+
const taskTool = yield* Effect.promise(() => registry.named.task.init())
564564
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
565565
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
566566
id: MessageID.ascending(),
@@ -583,7 +583,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
583583
sessionID: assistantMessage.sessionID,
584584
type: "tool",
585585
callID: ulid(),
586-
tool: TaskTool.id,
586+
tool: registry.named.task.id,
587587
state: {
588588
status: "running",
589589
input: {
@@ -1110,7 +1110,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
11101110
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
11111111
},
11121112
]
1113-
const read = yield* Effect.promise(() => ReadTool.init()).pipe(
1113+
const read = yield* Effect.promise(() => registry.named.read.init()).pipe(
11141114
Effect.flatMap((t) =>
11151115
provider.getModel(info.model.providerID, info.model.modelID).pipe(
11161116
Effect.flatMap((mdl) =>
@@ -1174,7 +1174,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
11741174

11751175
if (part.mime === "application/x-directory") {
11761176
const args = { filePath: filepath }
1177-
const result = yield* Effect.promise(() => ReadTool.init()).pipe(
1177+
const result = yield* Effect.promise(() => registry.named.read.init()).pipe(
11781178
Effect.flatMap((t) =>
11791179
Effect.promise(() =>
11801180
t.execute(args, {
Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,46 @@
11
import z from "zod"
2+
import { Effect } from "effect"
23
import { Tool } from "./tool"
34
import { Question } from "../question"
45
import DESCRIPTION from "./question.txt"
56

6-
export const QuestionTool = Tool.define("question", {
7-
description: DESCRIPTION,
8-
parameters: z.object({
9-
questions: z.array(Question.Info.omit({ custom: true })).describe("Questions to ask"),
10-
}),
11-
async execute(params, ctx) {
12-
const answers = await Question.ask({
13-
sessionID: ctx.sessionID,
14-
questions: params.questions,
15-
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
16-
})
7+
const parameters = z.object({
8+
questions: z.array(Question.Info.omit({ custom: true })).describe("Questions to ask"),
9+
})
1710

18-
function format(answer: Question.Answer | undefined) {
19-
if (!answer?.length) return "Unanswered"
20-
return answer.join(", ")
21-
}
11+
type Metadata = {
12+
answers: Question.Answer[]
13+
}
2214

23-
const formatted = params.questions.map((q, i) => `"${q.question}"="${format(answers[i])}"`).join(", ")
15+
export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Question.Service>(
16+
"question",
17+
Effect.gen(function* () {
18+
const question = yield* Question.Service
2419

2520
return {
26-
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
27-
output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
28-
metadata: {
29-
answers,
21+
description: DESCRIPTION,
22+
parameters,
23+
async execute(params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) {
24+
const answers = await Effect.gen(function* () {
25+
return yield* question.ask({
26+
sessionID: ctx.sessionID,
27+
questions: params.questions,
28+
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
29+
})
30+
}).pipe(Effect.runPromise)
31+
32+
const formatted = params.questions
33+
.map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`)
34+
.join(", ")
35+
36+
return {
37+
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
38+
output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
39+
metadata: {
40+
answers,
41+
},
42+
}
3043
},
31-
}
32-
},
33-
})
44+
} satisfies Tool.Def<typeof parameters, Metadata>
45+
}),
46+
)

packages/opencode/src/tool/registry.ts

Lines changed: 55 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { Effect, Layer, ServiceMap } from "effect"
3333
import { InstanceState } from "@/effect/instance-state"
3434
import { makeRuntime } from "@/effect/run-service"
3535
import { Env } from "../env"
36+
import { Question } from "../question"
3637

3738
export namespace ToolRegistry {
3839
const log = Log.create({ service: "tool.registry" })
@@ -42,8 +43,12 @@ export namespace ToolRegistry {
4243
}
4344

4445
export interface Interface {
45-
readonly register: (tool: Tool.Info) => Effect.Effect<void>
4646
readonly ids: () => Effect.Effect<string[]>
47+
readonly named: {
48+
task: Tool.Info
49+
read: Tool.Info
50+
edit: Tool.Info
51+
}
4752
readonly tools: (
4853
model: { providerID: ProviderID; modelID: ModelID },
4954
agent?: Agent.Info,
@@ -52,12 +57,15 @@ export namespace ToolRegistry {
5257

5358
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
5459

55-
export const layer: Layer.Layer<Service, never, Config.Service | Plugin.Service> = Layer.effect(
60+
export const layer: Layer.Layer<Service, never, Config.Service | Plugin.Service | Question.Service> = Layer.effect(
5661
Service,
5762
Effect.gen(function* () {
5863
const config = yield* Config.Service
5964
const plugin = yield* Plugin.Service
6065

66+
const build = <T extends Tool.Info>(tool: T | Effect.Effect<T, never, any>) =>
67+
Effect.isEffect(tool) ? tool : Effect.succeed(tool)
68+
6169
const state = yield* InstanceState.make<State>(
6270
Effect.fn("ToolRegistry.state")(function* (ctx) {
6371
const custom: Tool.Info[] = []
@@ -112,43 +120,52 @@ export namespace ToolRegistry {
112120
}),
113121
)
114122

123+
const invalid = yield* build(InvalidTool)
124+
const ask = yield* build(QuestionTool)
125+
const bash = yield* build(BashTool)
126+
const read = yield* build(ReadTool)
127+
const glob = yield* build(GlobTool)
128+
const grep = yield* build(GrepTool)
129+
const edit = yield* build(EditTool)
130+
const write = yield* build(WriteTool)
131+
const task = yield* build(TaskTool)
132+
const fetch = yield* build(WebFetchTool)
133+
const todo = yield* build(TodoWriteTool)
134+
const search = yield* build(WebSearchTool)
135+
const code = yield* build(CodeSearchTool)
136+
const skill = yield* build(SkillTool)
137+
const patch = yield* build(ApplyPatchTool)
138+
const lsp = yield* build(LspTool)
139+
const batch = yield* build(BatchTool)
140+
const plan = yield* build(PlanExitTool)
141+
115142
const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
116143
const cfg = yield* config.get()
117144
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
118145

119146
return [
120-
InvalidTool,
121-
...(question ? [QuestionTool] : []),
122-
BashTool,
123-
ReadTool,
124-
GlobTool,
125-
GrepTool,
126-
EditTool,
127-
WriteTool,
128-
TaskTool,
129-
WebFetchTool,
130-
TodoWriteTool,
131-
WebSearchTool,
132-
CodeSearchTool,
133-
SkillTool,
134-
ApplyPatchTool,
135-
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
136-
...(cfg.experimental?.batch_tool === true ? [BatchTool] : []),
137-
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
147+
invalid,
148+
...(question ? [ask] : []),
149+
bash,
150+
read,
151+
glob,
152+
grep,
153+
edit,
154+
write,
155+
task,
156+
fetch,
157+
todo,
158+
search,
159+
code,
160+
skill,
161+
patch,
162+
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [lsp] : []),
163+
...(cfg.experimental?.batch_tool === true ? [batch] : []),
164+
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [plan] : []),
138165
...custom,
139166
]
140167
})
141168

142-
const register = Effect.fn("ToolRegistry.register")(function* (tool: Tool.Info) {
143-
const s = yield* InstanceState.get(state)
144-
const idx = s.custom.findIndex((t) => t.id === tool.id)
145-
if (idx >= 0) {
146-
s.custom.splice(idx, 1, tool)
147-
return
148-
}
149-
s.custom.push(tool)
150-
})
151-
152169
const ids = Effect.fn("ToolRegistry.ids")(function* () {
153170
const s = yield* InstanceState.get(state)
154171
const tools = yield* all(s.custom)
@@ -196,12 +213,18 @@ export namespace ToolRegistry {
196213
)
197214
})
198215

199-
return Service.of({ register, ids, tools })
216+
return Service.of({ ids, named: { task, read, edit }, tools })
200217
}),
201218
)
202219

203220
export const defaultLayer = Layer.unwrap(
204-
Effect.sync(() => layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Plugin.defaultLayer))),
221+
Effect.sync(() =>
222+
layer.pipe(
223+
Layer.provide(Config.defaultLayer),
224+
Layer.provide(Plugin.defaultLayer),
225+
Layer.provide(Question.defaultLayer),
226+
),
227+
),
205228
)
206229

207230
const { runPromise } = makeRuntime(Service, defaultLayer)

packages/opencode/src/tool/tool.ts

Lines changed: 56 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import z from "zod"
2+
import { Effect } from "effect"
23
import type { MessageV2 } from "../session/message-v2"
34
import type { Agent } from "../agent/agent"
45
import type { Permission } from "../permission"
@@ -45,48 +46,67 @@ export namespace Tool {
4546
init: (ctx?: InitContext) => Promise<Def<Parameters, M>>
4647
}
4748

48-
export type InferParameters<T extends Info> = T extends Info<infer P> ? z.infer<P> : never
49-
export type InferMetadata<T extends Info> = T extends Info<any, infer M> ? M : never
49+
export type InferParameters<T> =
50+
T extends Info<infer P, any>
51+
? z.infer<P>
52+
: T extends Effect.Effect<Info<infer P, any>, any, any>
53+
? z.infer<P>
54+
: never
55+
export type InferMetadata<T> =
56+
T extends Info<any, infer M> ? M : T extends Effect.Effect<Info<any, infer M>, any, any> ? M : never
57+
58+
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
59+
id: string,
60+
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
61+
) {
62+
return async (initCtx?: InitContext) => {
63+
const toolInfo = init instanceof Function ? await init(initCtx) : init
64+
const execute = toolInfo.execute
65+
toolInfo.execute = async (args, ctx) => {
66+
try {
67+
toolInfo.parameters.parse(args)
68+
} catch (error) {
69+
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
70+
throw new Error(toolInfo.formatValidationError(error), { cause: error })
71+
}
72+
throw new Error(
73+
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
74+
{ cause: error },
75+
)
76+
}
77+
const result = await execute(args, ctx)
78+
if (result.metadata.truncated !== undefined) {
79+
return result
80+
}
81+
const truncated = await Truncate.output(result.output, {}, initCtx?.agent)
82+
return {
83+
...result,
84+
output: truncated.content,
85+
metadata: {
86+
...result.metadata,
87+
truncated: truncated.truncated,
88+
...(truncated.truncated && { outputPath: truncated.outputPath }),
89+
},
90+
}
91+
}
92+
return toolInfo
93+
}
94+
}
5095

5196
export function define<Parameters extends z.ZodType, Result extends Metadata>(
5297
id: string,
53-
init: Info<Parameters, Result>["init"] | Def<Parameters, Result>,
98+
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
5499
): Info<Parameters, Result> {
55100
return {
56101
id,
57-
init: async (initCtx) => {
58-
const toolInfo = init instanceof Function ? await init(initCtx) : init
59-
const execute = toolInfo.execute
60-
toolInfo.execute = async (args, ctx) => {
61-
try {
62-
toolInfo.parameters.parse(args)
63-
} catch (error) {
64-
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
65-
throw new Error(toolInfo.formatValidationError(error), { cause: error })
66-
}
67-
throw new Error(
68-
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
69-
{ cause: error },
70-
)
71-
}
72-
const result = await execute(args, ctx)
73-
// skip truncation for tools that handle it themselves
74-
if (result.metadata.truncated !== undefined) {
75-
return result
76-
}
77-
const truncated = await Truncate.output(result.output, {}, initCtx?.agent)
78-
return {
79-
...result,
80-
output: truncated.content,
81-
metadata: {
82-
...result.metadata,
83-
truncated: truncated.truncated,
84-
...(truncated.truncated && { outputPath: truncated.outputPath }),
85-
},
86-
}
87-
}
88-
return toolInfo
89-
},
102+
init: wrap(id, init),
90103
}
91104
}
105+
106+
export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R>(
107+
id: string,
108+
init: Effect.Effect<((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>, never, R>,
109+
): Effect.Effect<Info<Parameters, Result>, never, R> {
110+
return Effect.map(init, (next) => ({ id, init: wrap(id, next) }))
111+
}
92112
}

0 commit comments

Comments
 (0)