Skip to content

Commit 9c16bd1

Browse files
authored
fix: make skills logic more token efficient (#23253)
1 parent 5e9d5c7 commit 9c16bd1

3 files changed

Lines changed: 57 additions & 171 deletions

File tree

packages/opencode/src/tool/skill.ts

Lines changed: 52 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import { pathToFileURL } from "url"
33
import z from "zod"
44
import { Effect } from "effect"
55
import * as Stream from "effect/Stream"
6-
import { EffectLogger } from "@/effect"
76
import { Ripgrep } from "../file/ripgrep"
87
import { Skill } from "../skill"
98
import * as Tool from "./tool"
9+
import DESCRIPTION from "./skill.txt"
1010

1111
const Parameters = z.object({
1212
name: z.string().describe("The name of the skill from available_skills"),
@@ -18,82 +18,59 @@ export const SkillTool = Tool.define(
1818
const skill = yield* Skill.Service
1919
const rg = yield* Ripgrep.Service
2020

21-
return () =>
22-
Effect.gen(function* () {
23-
const list = yield* skill.available().pipe(Effect.provide(EffectLogger.layer))
21+
return {
22+
description: DESCRIPTION,
23+
parameters: Parameters,
24+
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
25+
Effect.gen(function* () {
26+
const info = yield* skill.get(params.name)
27+
if (!info) {
28+
const all = yield* skill.all()
29+
const available = all.map((item) => item.name).join(", ")
30+
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
31+
}
2432

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

43-
return {
44-
description,
45-
parameters: Parameters,
46-
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
47-
Effect.gen(function* () {
48-
const info = yield* skill.get(params.name)
49-
if (!info) {
50-
const all = yield* skill.all()
51-
const available = all.map((item) => item.name).join(", ")
52-
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
53-
}
40+
const dir = path.dirname(info.location)
41+
const base = pathToFileURL(dir).href
42+
const limit = 10
43+
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
44+
Stream.filter((file) => !file.includes("SKILL.md")),
45+
Stream.map((file) => path.resolve(dir, file)),
46+
Stream.take(limit),
47+
Stream.runCollect,
48+
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
49+
)
5450

55-
yield* ctx.ask({
56-
permission: "skill",
57-
patterns: [params.name],
58-
always: [params.name],
59-
metadata: {},
60-
})
61-
62-
const dir = path.dirname(info.location)
63-
const base = pathToFileURL(dir).href
64-
const limit = 10
65-
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
66-
Stream.filter((file) => !file.includes("SKILL.md")),
67-
Stream.map((file) => path.resolve(dir, file)),
68-
Stream.take(limit),
69-
Stream.runCollect,
70-
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
71-
)
72-
73-
return {
74-
title: `Loaded skill: ${info.name}`,
75-
output: [
76-
`<skill_content name="${info.name}">`,
77-
`# Skill: ${info.name}`,
78-
"",
79-
info.content.trim(),
80-
"",
81-
`Base directory for this skill: ${base}`,
82-
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
83-
"Note: file list is sampled.",
84-
"",
85-
"<skill_files>",
86-
files,
87-
"</skill_files>",
88-
"</skill_content>",
89-
].join("\n"),
90-
metadata: {
91-
name: info.name,
92-
dir,
93-
},
94-
}
95-
}).pipe(Effect.orDie),
96-
}
97-
})
51+
return {
52+
title: `Loaded skill: ${info.name}`,
53+
output: [
54+
`<skill_content name="${info.name}">`,
55+
`# Skill: ${info.name}`,
56+
"",
57+
info.content.trim(),
58+
"",
59+
`Base directory for this skill: ${base}`,
60+
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
61+
"Note: file list is sampled.",
62+
"",
63+
"<skill_files>",
64+
files,
65+
"</skill_files>",
66+
"</skill_content>",
67+
].join("\n"),
68+
metadata: {
69+
name: info.name,
70+
dir,
71+
},
72+
}
73+
}).pipe(Effect.orDie),
74+
}
9875
}),
9976
)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.
2+
3+
Use this tool to inject the skill's instructions and resources into current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc in the same directory as the skill.
4+
5+
The skill name must match one of the skills listed in your system prompt.

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

Lines changed: 0 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -31,102 +31,6 @@ const node = CrossSpawnSpawner.defaultLayer
3131
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
3232

3333
describe("tool.skill", () => {
34-
it.live("description lists skill location URL", () =>
35-
provideTmpdirInstance(
36-
(dir) =>
37-
Effect.gen(function* () {
38-
const skill = path.join(dir, ".opencode", "skill", "tool-skill")
39-
yield* Effect.promise(() =>
40-
Bun.write(
41-
path.join(skill, "SKILL.md"),
42-
`---
43-
name: tool-skill
44-
description: Skill for tool tests.
45-
---
46-
47-
# Tool Skill
48-
`,
49-
),
50-
)
51-
const home = process.env.OPENCODE_TEST_HOME
52-
process.env.OPENCODE_TEST_HOME = dir
53-
yield* Effect.addFinalizer(() =>
54-
Effect.sync(() => {
55-
process.env.OPENCODE_TEST_HOME = home
56-
}),
57-
)
58-
const registry = yield* ToolRegistry.Service
59-
const desc =
60-
(yield* registry.tools({
61-
providerID: "opencode" as any,
62-
modelID: "gpt-5" as any,
63-
agent: { name: "build", mode: "primary", permission: [], options: {} },
64-
})).find((tool) => tool.id === SkillTool.id)?.description ?? ""
65-
expect(desc).toContain("**tool-skill**: Skill for tool tests.")
66-
}),
67-
{ git: true },
68-
),
69-
)
70-
71-
it.live("description sorts skills by name and is stable across calls", () =>
72-
provideTmpdirInstance(
73-
(dir) =>
74-
Effect.gen(function* () {
75-
for (const [name, description] of [
76-
["zeta-skill", "Zeta skill."],
77-
["alpha-skill", "Alpha skill."],
78-
["middle-skill", "Middle skill."],
79-
]) {
80-
const skill = path.join(dir, ".opencode", "skill", name)
81-
yield* Effect.promise(() =>
82-
Bun.write(
83-
path.join(skill, "SKILL.md"),
84-
`---
85-
name: ${name}
86-
description: ${description}
87-
---
88-
89-
# ${name}
90-
`,
91-
),
92-
)
93-
}
94-
const home = process.env.OPENCODE_TEST_HOME
95-
process.env.OPENCODE_TEST_HOME = dir
96-
yield* Effect.addFinalizer(() =>
97-
Effect.sync(() => {
98-
process.env.OPENCODE_TEST_HOME = home
99-
}),
100-
)
101-
102-
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
103-
const registry = yield* ToolRegistry.Service
104-
const load = Effect.fnUntraced(function* () {
105-
return (
106-
(yield* registry.tools({
107-
providerID: "opencode" as any,
108-
modelID: "gpt-5" as any,
109-
agent,
110-
})).find((tool) => tool.id === SkillTool.id)?.description ?? ""
111-
)
112-
})
113-
const first = yield* load()
114-
const second = yield* load()
115-
116-
expect(first).toBe(second)
117-
118-
const alpha = first.indexOf("**alpha-skill**: Alpha skill.")
119-
const middle = first.indexOf("**middle-skill**: Middle skill.")
120-
const zeta = first.indexOf("**zeta-skill**: Zeta skill.")
121-
122-
expect(alpha).toBeGreaterThan(-1)
123-
expect(middle).toBeGreaterThan(alpha)
124-
expect(zeta).toBeGreaterThan(middle)
125-
}),
126-
{ git: true },
127-
),
128-
)
129-
13034
it.live("execute returns skill content block with files", () =>
13135
provideTmpdirInstance(
13236
(dir) =>

0 commit comments

Comments
 (0)