diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md index b0ce4a3ee1..1990f6a577 100644 --- a/packages/opencode/specs/effect-migration.md +++ b/packages/opencode/specs/effect-migration.md @@ -229,24 +229,24 @@ Still open: ## Tool interface → Effect -Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `init` and `execute` return `Effect` instead of `Promise`. This lets tool implementations compose natively with the Effect pipeline rather than being wrapped in `Effect.promise()` at the call site. Requires: +`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch. Tool definitions should now stay Effect-native all the way through initialization instead of using Promise-returning init callbacks. Tools can still use lazy init callbacks when they need instance-bound state at init time, but those callbacks should return `Effect`, not `Promise`. Remaining work is: -1. Migrate each tool to return Effects -2. Update `Tool.define()` factory to work with Effects -3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing +1. Migrate each tool body to return Effects +2. Keep `Tool.define()` inputs Effect-native +3. Update remaining callers to `yield*` tool initialization instead of `await`ing ### Tool migration details -Until the tool interface itself returns `Effect`, use this transitional pattern for migrated tools: +With `Tool.Info.init()` now effectful, use this transitional pattern for migrated tools that still need Promise-based boundaries internally: - `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition. -- Keep the bridge at the Promise boundary only. Prefer a single `Effect.runPromise(...)` in the temporary `async execute(...)` implementation, and move the inner logic into `Effect.fn(...)` helpers instead of scattering `runPromise` islands through the tool body. +- Keep the bridge at the Promise boundary only inside the tool body when required by external APIs. Do not return Promise-based init callbacks from `Tool.define()`. - If a tool starts requiring new services, wire them into `ToolRegistry.defaultLayer` so production callers resolve the same dependencies as tests. Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`: - Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools. -- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* Effect.promise(() => info.init())`. +- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* info.init()`. - Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production. This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info` → `Effect` cleanup mostly mechanical later. diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index eb49159f16..150cafbd76 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -454,52 +454,53 @@ export const BashTool = Tool.define( } }) - return async () => { - const shell = Shell.acceptable() - const name = Shell.name(shell) - const chain = - name === "powershell" - ? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." - : "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." - log.info("bash tool using shell", { shell }) + return () => + Effect.sync(() => { + const shell = Shell.acceptable() + const name = Shell.name(shell) + const chain = + name === "powershell" + ? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." + : "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." + log.info("bash tool using shell", { shell }) - return { - description: DESCRIPTION.replaceAll("${directory}", Instance.directory) - .replaceAll("${os}", process.platform) - .replaceAll("${shell}", name) - .replaceAll("${chaining}", chain) - .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) - .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), - parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => - Effect.gen(function* () { - const cwd = params.workdir - ? yield* resolvePath(params.workdir, Instance.directory, shell) - : Instance.directory - if (params.timeout !== undefined && params.timeout < 0) { - throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) - } - const timeout = params.timeout ?? DEFAULT_TIMEOUT - const ps = PS.has(name) - const root = yield* parse(params.command, ps) - const scan = yield* collect(root, cwd, ps, shell) - if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) - yield* ask(ctx, scan) + return { + description: DESCRIPTION.replaceAll("${directory}", Instance.directory) + .replaceAll("${os}", process.platform) + .replaceAll("${shell}", name) + .replaceAll("${chaining}", chain) + .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) + .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), + parameters: Parameters, + execute: (params: z.infer, ctx: Tool.Context) => + Effect.gen(function* () { + const cwd = params.workdir + ? yield* resolvePath(params.workdir, Instance.directory, shell) + : Instance.directory + if (params.timeout !== undefined && params.timeout < 0) { + throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) + } + const timeout = params.timeout ?? DEFAULT_TIMEOUT + const ps = PS.has(name) + const root = yield* parse(params.command, ps) + const scan = yield* collect(root, cwd, ps, shell) + if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) + yield* ask(ctx, scan) - return yield* run( - { - shell, - name, - command: params.command, - cwd, - env: yield* shellEnv(ctx, cwd), - timeout, - description: params.description, - }, - ctx, - ) - }), - } - } + return yield* run( + { + shell, + name, + command: params.command, + cwd, + env: yield* shellEnv(ctx, cwd), + timeout, + description: params.description, + }, + ctx, + ) + }), + } + }) }), ) diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts index 82d6988c28..449df33430 100644 --- a/packages/opencode/src/tool/multiedit.ts +++ b/packages/opencode/src/tool/multiedit.ts @@ -10,7 +10,7 @@ export const MultiEditTool = Tool.define( "multiedit", Effect.gen(function* () { const editInfo = yield* EditTool - const edit = yield* Effect.promise(() => editInfo.init()) + const edit = yield* editInfo.init() return { description: DESCRIPTION, diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index f9f06b9cd9..14adaf231d 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -17,84 +17,84 @@ export const SkillTool = Tool.define( Effect.gen(function* () { const skill = yield* Skill.Service const rg = yield* Ripgrep.Service + return () => + Effect.gen(function* () { + const list = yield* skill.available().pipe(Effect.provide(EffectLogger.layer)) - return async () => { - const list = await Effect.runPromise(skill.available().pipe(Effect.provide(EffectLogger.layer))) - - const description = - list.length === 0 - ? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available." - : [ - "Load a specialized skill that provides domain-specific instructions and workflows.", - "", - "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.", - "", - "The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.", - "", - 'Tool output includes a `` block with the loaded content.', - "", - "The following skills provide specialized sets of instructions for particular tasks", - "Invoke this tool to load a skill when a task matches one of the available skills listed below:", - "", - Skill.fmt(list, { verbose: false }), - ].join("\n") - - return { - description, - parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => - Effect.gen(function* () { - const info = yield* skill.get(params.name) - - if (!info) { - const all = yield* skill.all() - const available = all.map((s) => s.name).join(", ") - throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) - } - - yield* ctx.ask({ - permission: "skill", - patterns: [params.name], - always: [params.name], - metadata: {}, - }) - - const dir = path.dirname(info.location) - const base = pathToFileURL(dir).href - - const limit = 10 - const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe( - Stream.filter((file) => !file.includes("SKILL.md")), - Stream.map((file) => path.resolve(dir, file)), - Stream.take(limit), - Stream.runCollect, - Effect.map((chunk) => [...chunk].map((file) => `${file}`).join("\n")), - ) - - return { - title: `Loaded skill: ${info.name}`, - output: [ - ``, - `# Skill: ${info.name}`, + const description = + list.length === 0 + ? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available." + : [ + "Load a specialized skill that provides domain-specific instructions and workflows.", "", - info.content.trim(), + "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.", "", - `Base directory for this skill: ${base}`, - "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", - "Note: file list is sampled.", + "The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.", "", - "", - files, - "", - "", - ].join("\n"), - metadata: { - name: info.name, - dir, - }, - } - }).pipe(Effect.orDie), - } - } + 'Tool output includes a `` block with the loaded content.', + "", + "The following skills provide specialized sets of instructions for particular tasks", + "Invoke this tool to load a skill when a task matches one of the available skills listed below:", + "", + Skill.fmt(list, { verbose: false }), + ].join("\n") + + return { + description, + parameters: Parameters, + execute: (params: z.infer, ctx: Tool.Context) => + Effect.gen(function* () { + const info = yield* skill.get(params.name) + + if (!info) { + const all = yield* skill.all() + const available = all.map((s) => s.name).join(", ") + throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) + } + + yield* ctx.ask({ + permission: "skill", + patterns: [params.name], + always: [params.name], + metadata: {}, + }) + + const dir = path.dirname(info.location) + const base = pathToFileURL(dir).href + + const limit = 10 + const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe( + Stream.filter((file) => !file.includes("SKILL.md")), + Stream.map((file) => path.resolve(dir, file)), + Stream.take(limit), + Stream.runCollect, + Effect.map((chunk) => [...chunk].map((file) => `${file}`).join("\n")), + ) + + return { + title: `Loaded skill: ${info.name}`, + output: [ + ``, + `# Skill: ${info.name}`, + "", + info.content.trim(), + "", + `Base directory for this skill: ${base}`, + "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", + "Note: file list is sampled.", + "", + "", + files, + "", + "", + ].join("\n"), + metadata: { + name: info.name, + dir, + }, + } + }).pipe(Effect.orDie), + } + }) }), ) diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 4cd3ba6e70..3009f4cd27 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -47,9 +47,13 @@ export namespace Tool { export interface Info { id: string - init: () => Promise> + init: () => Effect.Effect> } + type Init = + | DefWithoutID + | (() => Effect.Effect>) + export type InferParameters = T extends Info ? z.infer

@@ -66,60 +70,58 @@ export namespace Tool { ? Def : never - function wrap( - id: string, - init: (() => Promise>) | DefWithoutID, - ) { - return async () => { - const toolInfo = init instanceof Function ? await init() : { ...init } - const execute = toolInfo.execute - toolInfo.execute = (args, ctx) => - 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) - if (result.metadata.truncated !== undefined) { - return result - } - const agent = yield* Effect.promise(() => Agent.get(ctx.agent)) - const truncated = yield* Effect.promise(() => Truncate.output(result.output, {}, agent)) - return { - ...result, - output: truncated.content, - metadata: { - ...result.metadata, - truncated: truncated.truncated, - ...(truncated.truncated && { outputPath: truncated.outputPath }), - }, - } - }).pipe(Effect.orDie) - return toolInfo - } + function wrap(id: string, init: Init) { + return () => + Effect.gen(function* () { + const toolInfo = init instanceof Function ? { ...(yield* init()) } : { ...init } + const execute = toolInfo.execute + toolInfo.execute = (args, ctx) => + 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) + if (result.metadata.truncated !== undefined) { + return result + } + const agent = yield* Effect.promise(() => Agent.get(ctx.agent)) + const truncated = yield* Effect.promise(() => Truncate.output(result.output, {}, agent)) + return { + ...result, + output: truncated.content, + metadata: { + ...result.metadata, + truncated: truncated.truncated, + ...(truncated.truncated && { outputPath: truncated.outputPath }), + }, + } + }).pipe(Effect.orDie) + return toolInfo + }) } export function define( id: ID, - init: Effect.Effect<(() => Promise>) | DefWithoutID, never, R>, + init: Effect.Effect, never, R>, ): Effect.Effect, never, R> & { id: ID } { return Object.assign( - Effect.map(init, (next) => ({ id, init: wrap(id, next) })), + Effect.map(init, (init) => ({ id, init: wrap(id, init) })), { id }, ) } export function init

(info: Info): Effect.Effect> { return Effect.gen(function* () { - const init = yield* Effect.promise(() => info.init()) + const init = yield* info.init() return { ...init, id: info.id, diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 7bd2e2a599..4efa12f2fc 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -50,7 +50,7 @@ type ToolCtx = typeof baseCtx & { const execute = async (params: { patchText: string }, ctx: ToolCtx) => { const info = await runtime.runPromise(ApplyPatchTool) - const tool = await info.init() + const tool = await runtime.runPromise(info.init()) return Effect.runPromise(tool.execute(params, ctx)) } diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 75836c6d96..e9a6ae38cf 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -19,7 +19,7 @@ const runtime = ManagedRuntime.make( ) function initBash() { - return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init())))) + return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => info.init()))) } const ctx = { diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 1c2a4a26f3..0695b54bac 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -45,7 +45,7 @@ const resolve = () => runtime.runPromise( Effect.gen(function* () { const info = yield* EditTool - return yield* Effect.promise(() => info.init()) + return yield* info.init() }), ) diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index c85daa0573..4078c9ce6a 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -10,7 +10,7 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer)) function initGrep() { - return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init())))) + return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => info.init()))) } const ctx = { diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 0ca72fdb2c..f651f019ef 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -36,7 +36,7 @@ describe("tool.question", () => { Effect.gen(function* () { const question = yield* Question.Service const toolInfo = yield* QuestionTool - const tool = yield* Effect.promise(() => toolInfo.init()) + const tool = yield* toolInfo.init() const questions = [ { question: "What is your favorite color?", @@ -64,7 +64,7 @@ describe("tool.question", () => { Effect.gen(function* () { const question = yield* Question.Service const toolInfo = yield* QuestionTool - const tool = yield* Effect.promise(() => toolInfo.init()) + const tool = yield* toolInfo.init() const questions = [ { question: "What is your favorite animal?", diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 4e5419f517..c41cefda37 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -46,7 +46,7 @@ const it = testEffect( const init = Effect.fn("ReadToolTest.init")(function* () { const info = yield* ReadTool - return yield* Effect.promise(() => info.init()) + return yield* info.init() }) const run = Effect.fn("ReadToolTest.run")(function* ( diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index ae662bc330..47a8321efc 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -152,7 +152,7 @@ Use this skill. fn: async () => { const runtime = ManagedRuntime.make(Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer)) const info = await runtime.runPromise(SkillTool) - const tool = await info.init() + const tool = await runtime.runPromise(info.init()) const requests: Array> = [] const ctx: Tool.Context = { ...baseCtx, diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 1fea684c7d..e2ae52bf05 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -191,7 +191,7 @@ describe("tool.task", () => { const { chat, assistant } = yield* seed() const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) const tool = yield* TaskTool - const def = yield* Effect.promise(() => tool.init()) + const def = yield* tool.init() let seen: SessionPrompt.PromptInput | undefined const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) }) @@ -229,7 +229,7 @@ describe("tool.task", () => { Effect.gen(function* () { const { chat, assistant } = yield* seed() const tool = yield* TaskTool - const def = yield* Effect.promise(() => tool.init()) + const def = yield* tool.init() const calls: unknown[] = [] const promptOps = stubOps() @@ -278,7 +278,7 @@ describe("tool.task", () => { const sessions = yield* Session.Service const { chat, assistant } = yield* seed() const tool = yield* TaskTool - const def = yield* Effect.promise(() => tool.init()) + const def = yield* tool.init() let seen: SessionPrompt.PromptInput | undefined const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) }) @@ -318,7 +318,7 @@ describe("tool.task", () => { const sessions = yield* Session.Service const { chat, assistant } = yield* seed() const tool = yield* TaskTool - const def = yield* Effect.promise(() => tool.init()) + const def = yield* tool.init() let seen: SessionPrompt.PromptInput | undefined const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) diff --git a/packages/opencode/test/tool/tool-define.test.ts b/packages/opencode/test/tool/tool-define.test.ts index 1f8f43b2d1..425c83ffdd 100644 --- a/packages/opencode/test/tool/tool-define.test.ts +++ b/packages/opencode/test/tool/tool-define.test.ts @@ -23,23 +23,23 @@ describe("Tool.define", () => { const info = await Effect.runPromise(Tool.define("test-tool", Effect.succeed(original))) - await info.init() - await info.init() - await info.init() + await Effect.runPromise(info.init()) + await Effect.runPromise(info.init()) + await Effect.runPromise(info.init()) expect(original.execute).toBe(originalExecute) }) - test("function-defined tool returns fresh objects and is unaffected", async () => { + test("effect-defined tool returns fresh objects and is unaffected", async () => { const info = await Effect.runPromise( Tool.define( "test-fn-tool", - Effect.succeed(() => Promise.resolve(makeTool("test"))), + Effect.succeed(() => Effect.succeed(makeTool("test"))), ), ) - const first = await info.init() - const second = await info.init() + const first = await Effect.runPromise(info.init()) + const second = await Effect.runPromise(info.init()) expect(first).not.toBe(second) }) @@ -47,8 +47,8 @@ describe("Tool.define", () => { test("object-defined tool returns distinct objects per init() call", async () => { const info = await Effect.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test")))) - const first = await info.init() - const second = await info.init() + const first = await Effect.runPromise(info.init()) + const second = await Effect.runPromise(info.init()) expect(first).not.toBe(second) }) diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index 0773d5cc21..c6b2fd331c 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -26,7 +26,7 @@ async function withFetch(fetch: (req: Request) => Response | Promise, function initTool() { return WebFetchTool.pipe( - Effect.flatMap((info) => Effect.promise(() => info.init())), + Effect.flatMap((info) => info.init()), Effect.provide(FetchHttpClient.layer), Effect.runPromise, ) diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 745058a192..3607a9272e 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -43,7 +43,7 @@ const it = testEffect( const init = Effect.fn("WriteToolTest.init")(function* () { const info = yield* WriteTool - return yield* Effect.promise(() => info.init()) + return yield* info.init() }) const run = Effect.fn("WriteToolTest.run")(function* (