Skip to content

Commit 5d6fe01

Browse files
authored
convert skill tool to Tool.defineEffect (#21936)
1 parent cf27a73 commit 5d6fe01

File tree

3 files changed

+90
-83
lines changed

3 files changed

+90
-83
lines changed

packages/opencode/src/tool/registry.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export namespace ToolRegistry {
116116
const edit = yield* EditTool
117117
const greptool = yield* GrepTool
118118
const patchtool = yield* ApplyPatchTool
119+
const skilltool = yield* SkillTool
119120

120121
const state = yield* InstanceState.make<State>(
121122
Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -185,7 +186,7 @@ export namespace ToolRegistry {
185186
todo: Tool.init(todo),
186187
search: Tool.init(websearch),
187188
code: Tool.init(codesearch),
188-
skill: Tool.init(SkillTool),
189+
skill: Tool.init(skilltool),
189190
patch: Tool.init(patchtool),
190191
question: Tool.init(question),
191192
lsp: Tool.init(lsptool),
Lines changed: 82 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,101 @@
11
import path from "path"
22
import { pathToFileURL } from "url"
33
import z from "zod"
4+
import { Effect } from "effect"
5+
import * as Stream from "effect/Stream"
46
import { Tool } from "./tool"
57
import { Skill } from "../skill"
68
import { Ripgrep } from "../file/ripgrep"
7-
import { iife } from "@/util/iife"
89

910
const Parameters = z.object({
1011
name: z.string().describe("The name of the skill from available_skills"),
1112
})
1213

13-
export const SkillTool = Tool.define("skill", async () => {
14-
const list = await Skill.available()
14+
export const SkillTool = Tool.defineEffect(
15+
"skill",
16+
Effect.gen(function* () {
17+
const skill = yield* Skill.Service
18+
const rg = yield* Ripgrep.Service
1519

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

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

40-
if (!skill) {
41-
const available = await Skill.all().then((x) => x.map((skill) => skill.name).join(", "))
42-
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
43-
}
41+
return {
42+
description,
43+
parameters: Parameters,
44+
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
45+
Effect.gen(function* () {
46+
const info = yield* skill.get(params.name)
4447

45-
await ctx.ask({
46-
permission: "skill",
47-
patterns: [params.name],
48-
always: [params.name],
49-
metadata: {},
50-
})
48+
if (!info) {
49+
const all = yield* skill.all()
50+
const available = all.map((s) => s.name).join(", ")
51+
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
52+
}
5153

52-
const dir = path.dirname(skill.location)
53-
const base = pathToFileURL(dir).href
54+
yield* Effect.promise(() =>
55+
ctx.ask({
56+
permission: "skill",
57+
patterns: [params.name],
58+
always: [params.name],
59+
metadata: {},
60+
}),
61+
)
5462

55-
const limit = 10
56-
const files = await iife(async () => {
57-
const arr = []
58-
for await (const file of Ripgrep.files({
59-
cwd: dir,
60-
follow: false,
61-
hidden: true,
62-
signal: ctx.abort,
63-
})) {
64-
if (file.includes("SKILL.md")) {
65-
continue
66-
}
67-
arr.push(path.resolve(dir, file))
68-
if (arr.length >= limit) {
69-
break
70-
}
71-
}
72-
return arr
73-
}).then((f) => f.map((file) => `<file>${file}</file>`).join("\n"))
63+
const dir = path.dirname(info.location)
64+
const base = pathToFileURL(dir).href
7465

75-
return {
76-
title: `Loaded skill: ${skill.name}`,
77-
output: [
78-
`<skill_content name="${skill.name}">`,
79-
`# Skill: ${skill.name}`,
80-
"",
81-
skill.content.trim(),
82-
"",
83-
`Base directory for this skill: ${base}`,
84-
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
85-
"Note: file list is sampled.",
86-
"",
87-
"<skill_files>",
88-
files,
89-
"</skill_files>",
90-
"</skill_content>",
91-
].join("\n"),
92-
metadata: {
93-
name: skill.name,
94-
dir,
95-
},
66+
const limit = 10
67+
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
68+
Stream.filter((file) => !file.includes("SKILL.md")),
69+
Stream.map((file) => path.resolve(dir, file)),
70+
Stream.take(limit),
71+
Stream.runCollect,
72+
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
73+
)
74+
75+
return {
76+
title: `Loaded skill: ${info.name}`,
77+
output: [
78+
`<skill_content name="${info.name}">`,
79+
`# Skill: ${info.name}`,
80+
"",
81+
info.content.trim(),
82+
"",
83+
`Base directory for this skill: ${base}`,
84+
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
85+
"Note: file list is sampled.",
86+
"",
87+
"<skill_files>",
88+
files,
89+
"</skill_files>",
90+
"</skill_content>",
91+
].join("\n"),
92+
metadata: {
93+
name: info.name,
94+
dir,
95+
},
96+
}
97+
}).pipe(Effect.orDie, Effect.runPromise),
9698
}
97-
},
98-
}
99-
})
99+
}
100+
}),
101+
)

packages/opencode/test/tool/skill.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { Effect } from "effect"
1+
import { Effect, Layer, ManagedRuntime } from "effect"
2+
import { Skill } from "../../src/skill"
3+
import { Ripgrep } from "../../src/file/ripgrep"
24
import { afterEach, describe, expect, test } from "bun:test"
35
import path from "path"
46
import { pathToFileURL } from "url"
@@ -148,7 +150,9 @@ Use this skill.
148150
await Instance.provide({
149151
directory: tmp.path,
150152
fn: async () => {
151-
const tool = await SkillTool.init()
153+
const runtime = ManagedRuntime.make(Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer))
154+
const info = await runtime.runPromise(SkillTool)
155+
const tool = await info.init()
152156
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
153157
const ctx: Tool.Context = {
154158
...baseCtx,

0 commit comments

Comments
 (0)