Skip to content

Commit 5ee7eda

Browse files
authored
refactor(tool): make Tool.Info init effectful (#21989)
1 parent 2719063 commit 5ee7eda

16 files changed

Lines changed: 196 additions & 193 deletions

packages/opencode/specs/effect-migration.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -229,24 +229,24 @@ Still open:
229229

230230
## Tool interface → Effect
231231

232-
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:
232+
`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:
233233

234-
1. Migrate each tool to return Effects
235-
2. Update `Tool.define()` factory to work with Effects
236-
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing
234+
1. Migrate each tool body to return Effects
235+
2. Keep `Tool.define()` inputs Effect-native
236+
3. Update remaining callers to `yield*` tool initialization instead of `await`ing
237237

238238
### Tool migration details
239239

240-
Until the tool interface itself returns `Effect`, use this transitional pattern for migrated tools:
240+
With `Tool.Info.init()` now effectful, use this transitional pattern for migrated tools that still need Promise-based boundaries internally:
241241

242242
- `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition.
243-
- 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.
243+
- 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()`.
244244
- If a tool starts requiring new services, wire them into `ToolRegistry.defaultLayer` so production callers resolve the same dependencies as tests.
245245

246246
Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`:
247247

248248
- Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
249-
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* Effect.promise(() => info.init())`.
249+
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* info.init()`.
250250
- Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production.
251251

252252
This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info``Effect` cleanup mostly mechanical later.

packages/opencode/src/tool/bash.ts

Lines changed: 48 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -454,52 +454,53 @@ export const BashTool = Tool.define(
454454
}
455455
})
456456

457-
return async () => {
458-
const shell = Shell.acceptable()
459-
const name = Shell.name(shell)
460-
const chain =
461-
name === "powershell"
462-
? "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."
463-
: "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."
464-
log.info("bash tool using shell", { shell })
465-
466-
return {
467-
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
468-
.replaceAll("${os}", process.platform)
469-
.replaceAll("${shell}", name)
470-
.replaceAll("${chaining}", chain)
471-
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
472-
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
473-
parameters: Parameters,
474-
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
475-
Effect.gen(function* () {
476-
const cwd = params.workdir
477-
? yield* resolvePath(params.workdir, Instance.directory, shell)
478-
: Instance.directory
479-
if (params.timeout !== undefined && params.timeout < 0) {
480-
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
481-
}
482-
const timeout = params.timeout ?? DEFAULT_TIMEOUT
483-
const ps = PS.has(name)
484-
const root = yield* parse(params.command, ps)
485-
const scan = yield* collect(root, cwd, ps, shell)
486-
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
487-
yield* ask(ctx, scan)
488-
489-
return yield* run(
490-
{
491-
shell,
492-
name,
493-
command: params.command,
494-
cwd,
495-
env: yield* shellEnv(ctx, cwd),
496-
timeout,
497-
description: params.description,
498-
},
499-
ctx,
500-
)
501-
}),
502-
}
503-
}
457+
return () =>
458+
Effect.sync(() => {
459+
const shell = Shell.acceptable()
460+
const name = Shell.name(shell)
461+
const chain =
462+
name === "powershell"
463+
? "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."
464+
: "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."
465+
log.info("bash tool using shell", { shell })
466+
467+
return {
468+
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
469+
.replaceAll("${os}", process.platform)
470+
.replaceAll("${shell}", name)
471+
.replaceAll("${chaining}", chain)
472+
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
473+
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
474+
parameters: Parameters,
475+
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
476+
Effect.gen(function* () {
477+
const cwd = params.workdir
478+
? yield* resolvePath(params.workdir, Instance.directory, shell)
479+
: Instance.directory
480+
if (params.timeout !== undefined && params.timeout < 0) {
481+
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
482+
}
483+
const timeout = params.timeout ?? DEFAULT_TIMEOUT
484+
const ps = PS.has(name)
485+
const root = yield* parse(params.command, ps)
486+
const scan = yield* collect(root, cwd, ps, shell)
487+
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
488+
yield* ask(ctx, scan)
489+
490+
return yield* run(
491+
{
492+
shell,
493+
name,
494+
command: params.command,
495+
cwd,
496+
env: yield* shellEnv(ctx, cwd),
497+
timeout,
498+
description: params.description,
499+
},
500+
ctx,
501+
)
502+
}),
503+
}
504+
})
504505
}),
505506
)

packages/opencode/src/tool/multiedit.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export const MultiEditTool = Tool.define(
1010
"multiedit",
1111
Effect.gen(function* () {
1212
const editInfo = yield* EditTool
13-
const edit = yield* Effect.promise(() => editInfo.init())
13+
const edit = yield* editInfo.init()
1414

1515
return {
1616
description: DESCRIPTION,

packages/opencode/src/tool/skill.ts

Lines changed: 72 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -17,84 +17,84 @@ export const SkillTool = Tool.define(
1717
Effect.gen(function* () {
1818
const skill = yield* Skill.Service
1919
const rg = yield* Ripgrep.Service
20+
return () =>
21+
Effect.gen(function* () {
22+
const list = yield* skill.available().pipe(Effect.provide(EffectLogger.layer))
2023

21-
return async () => {
22-
const list = await Effect.runPromise(skill.available().pipe(Effect.provide(EffectLogger.layer)))
23-
24-
const description =
25-
list.length === 0
26-
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
27-
: [
28-
"Load a specialized skill that provides domain-specific instructions and workflows.",
29-
"",
30-
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
31-
"",
32-
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
33-
"",
34-
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
35-
"",
36-
"The following skills provide specialized sets of instructions for particular tasks",
37-
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
38-
"",
39-
Skill.fmt(list, { verbose: false }),
40-
].join("\n")
24+
const description =
25+
list.length === 0
26+
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
27+
: [
28+
"Load a specialized skill that provides domain-specific instructions and workflows.",
29+
"",
30+
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
31+
"",
32+
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
33+
"",
34+
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
35+
"",
36+
"The following skills provide specialized sets of instructions for particular tasks",
37+
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
38+
"",
39+
Skill.fmt(list, { verbose: false }),
40+
].join("\n")
4141

42-
return {
43-
description,
44-
parameters: Parameters,
45-
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
46-
Effect.gen(function* () {
47-
const info = yield* skill.get(params.name)
42+
return {
43+
description,
44+
parameters: Parameters,
45+
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
46+
Effect.gen(function* () {
47+
const info = yield* skill.get(params.name)
4848

49-
if (!info) {
50-
const all = yield* skill.all()
51-
const available = all.map((s) => s.name).join(", ")
52-
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
53-
}
49+
if (!info) {
50+
const all = yield* skill.all()
51+
const available = all.map((s) => s.name).join(", ")
52+
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
53+
}
5454

55-
yield* ctx.ask({
56-
permission: "skill",
57-
patterns: [params.name],
58-
always: [params.name],
59-
metadata: {},
60-
})
55+
yield* ctx.ask({
56+
permission: "skill",
57+
patterns: [params.name],
58+
always: [params.name],
59+
metadata: {},
60+
})
6161

62-
const dir = path.dirname(info.location)
63-
const base = pathToFileURL(dir).href
62+
const dir = path.dirname(info.location)
63+
const base = pathToFileURL(dir).href
6464

65-
const limit = 10
66-
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
67-
Stream.filter((file) => !file.includes("SKILL.md")),
68-
Stream.map((file) => path.resolve(dir, file)),
69-
Stream.take(limit),
70-
Stream.runCollect,
71-
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
72-
)
65+
const limit = 10
66+
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
67+
Stream.filter((file) => !file.includes("SKILL.md")),
68+
Stream.map((file) => path.resolve(dir, file)),
69+
Stream.take(limit),
70+
Stream.runCollect,
71+
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
72+
)
7373

74-
return {
75-
title: `Loaded skill: ${info.name}`,
76-
output: [
77-
`<skill_content name="${info.name}">`,
78-
`# Skill: ${info.name}`,
79-
"",
80-
info.content.trim(),
81-
"",
82-
`Base directory for this skill: ${base}`,
83-
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
84-
"Note: file list is sampled.",
85-
"",
86-
"<skill_files>",
87-
files,
88-
"</skill_files>",
89-
"</skill_content>",
90-
].join("\n"),
91-
metadata: {
92-
name: info.name,
93-
dir,
94-
},
95-
}
96-
}).pipe(Effect.orDie),
97-
}
98-
}
74+
return {
75+
title: `Loaded skill: ${info.name}`,
76+
output: [
77+
`<skill_content name="${info.name}">`,
78+
`# Skill: ${info.name}`,
79+
"",
80+
info.content.trim(),
81+
"",
82+
`Base directory for this skill: ${base}`,
83+
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
84+
"Note: file list is sampled.",
85+
"",
86+
"<skill_files>",
87+
files,
88+
"</skill_files>",
89+
"</skill_content>",
90+
].join("\n"),
91+
metadata: {
92+
name: info.name,
93+
dir,
94+
},
95+
}
96+
}).pipe(Effect.orDie),
97+
}
98+
})
9999
}),
100100
)

0 commit comments

Comments
 (0)