mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-28 04:29:42 +00:00
fix: make skills logic more token efficient (#23253)
This commit is contained in:
parent
5e9d5c734e
commit
9c16bd1e30
3 changed files with 57 additions and 171 deletions
|
|
@ -3,10 +3,10 @@ import { pathToFileURL } from "url"
|
|||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { EffectLogger } from "@/effect"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Skill } from "../skill"
|
||||
import * as Tool from "./tool"
|
||||
import DESCRIPTION from "./skill.txt"
|
||||
|
||||
const Parameters = z.object({
|
||||
name: z.string().describe("The name of the skill from available_skills"),
|
||||
|
|
@ -18,82 +18,59 @@ export const SkillTool = Tool.define(
|
|||
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 {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* skill.get(params.name)
|
||||
if (!info) {
|
||||
const all = yield* skill.all()
|
||||
const available = all.map((item) => item.name).join(", ")
|
||||
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
|
||||
}
|
||||
|
||||
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 `<skill_content name="...">` 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")
|
||||
yield* ctx.ask({
|
||||
permission: "skill",
|
||||
patterns: [params.name],
|
||||
always: [params.name],
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
return {
|
||||
description,
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* skill.get(params.name)
|
||||
if (!info) {
|
||||
const all = yield* skill.all()
|
||||
const available = all.map((item) => item.name).join(", ")
|
||||
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
|
||||
}
|
||||
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, signal: ctx.abort }).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>${file}</file>`).join("\n")),
|
||||
)
|
||||
|
||||
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, signal: ctx.abort }).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>${file}</file>`).join("\n")),
|
||||
)
|
||||
|
||||
return {
|
||||
title: `Loaded skill: ${info.name}`,
|
||||
output: [
|
||||
`<skill_content name="${info.name}">`,
|
||||
`# 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.",
|
||||
"",
|
||||
"<skill_files>",
|
||||
files,
|
||||
"</skill_files>",
|
||||
"</skill_content>",
|
||||
].join("\n"),
|
||||
metadata: {
|
||||
name: info.name,
|
||||
dir,
|
||||
},
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
})
|
||||
return {
|
||||
title: `Loaded skill: ${info.name}`,
|
||||
output: [
|
||||
`<skill_content name="${info.name}">`,
|
||||
`# 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.",
|
||||
"",
|
||||
"<skill_files>",
|
||||
files,
|
||||
"</skill_files>",
|
||||
"</skill_content>",
|
||||
].join("\n"),
|
||||
metadata: {
|
||||
name: info.name,
|
||||
dir,
|
||||
},
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
5
packages/opencode/src/tool/skill.txt
Normal file
5
packages/opencode/src/tool/skill.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.
|
||||
|
||||
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.
|
||||
|
||||
The skill name must match one of the skills listed in your system prompt.
|
||||
|
|
@ -31,102 +31,6 @@ const node = CrossSpawnSpawner.defaultLayer
|
|||
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
|
||||
|
||||
describe("tool.skill", () => {
|
||||
it.live("description lists skill location URL", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const skill = path.join(dir, ".opencode", "skill", "tool-skill")
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(skill, "SKILL.md"),
|
||||
`---
|
||||
name: tool-skill
|
||||
description: Skill for tool tests.
|
||||
---
|
||||
|
||||
# Tool Skill
|
||||
`,
|
||||
),
|
||||
)
|
||||
const home = process.env.OPENCODE_TEST_HOME
|
||||
process.env.OPENCODE_TEST_HOME = dir
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
process.env.OPENCODE_TEST_HOME = home
|
||||
}),
|
||||
)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const desc =
|
||||
(yield* registry.tools({
|
||||
providerID: "opencode" as any,
|
||||
modelID: "gpt-5" as any,
|
||||
agent: { name: "build", mode: "primary", permission: [], options: {} },
|
||||
})).find((tool) => tool.id === SkillTool.id)?.description ?? ""
|
||||
expect(desc).toContain("**tool-skill**: Skill for tool tests.")
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("description sorts skills by name and is stable across calls", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
for (const [name, description] of [
|
||||
["zeta-skill", "Zeta skill."],
|
||||
["alpha-skill", "Alpha skill."],
|
||||
["middle-skill", "Middle skill."],
|
||||
]) {
|
||||
const skill = path.join(dir, ".opencode", "skill", name)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(skill, "SKILL.md"),
|
||||
`---
|
||||
name: ${name}
|
||||
description: ${description}
|
||||
---
|
||||
|
||||
# ${name}
|
||||
`,
|
||||
),
|
||||
)
|
||||
}
|
||||
const home = process.env.OPENCODE_TEST_HOME
|
||||
process.env.OPENCODE_TEST_HOME = dir
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
process.env.OPENCODE_TEST_HOME = home
|
||||
}),
|
||||
)
|
||||
|
||||
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const load = Effect.fnUntraced(function* () {
|
||||
return (
|
||||
(yield* registry.tools({
|
||||
providerID: "opencode" as any,
|
||||
modelID: "gpt-5" as any,
|
||||
agent,
|
||||
})).find((tool) => tool.id === SkillTool.id)?.description ?? ""
|
||||
)
|
||||
})
|
||||
const first = yield* load()
|
||||
const second = yield* load()
|
||||
|
||||
expect(first).toBe(second)
|
||||
|
||||
const alpha = first.indexOf("**alpha-skill**: Alpha skill.")
|
||||
const middle = first.indexOf("**middle-skill**: Middle skill.")
|
||||
const zeta = first.indexOf("**zeta-skill**: Zeta skill.")
|
||||
|
||||
expect(alpha).toBeGreaterThan(-1)
|
||||
expect(middle).toBeGreaterThan(alpha)
|
||||
expect(zeta).toBeGreaterThan(middle)
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("execute returns skill content block with files", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue