Skip to content

Commit 3975329

Browse files
authored
feat: improve skills, better prompting, fix permission asks after invoking skills, ensure agent knows where scripts/resources are (anomalyco#11737)
1 parent 54e14c1 commit 3975329

6 files changed

Lines changed: 142 additions & 15 deletions

File tree

.opencode/opencode.jsonc

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
22
"$schema": "https://opencode.ai/config.json",
3-
// "plugin": ["opencode-openai-codex-auth"],
43
// "enterprise": {
54
// "url": "https://enterprise.dev.opencode.ai",
65
// },

packages/opencode/src/agent/agent.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { mergeDeep, pipe, sortBy, values } from "remeda"
1818
import { Global } from "@/global"
1919
import path from "path"
2020
import { Plugin } from "@/plugin"
21+
import { Skill } from "../skill"
2122

2223
export namespace Agent {
2324
export const Info = z
@@ -50,12 +51,14 @@ export namespace Agent {
5051
const state = Instance.state(async () => {
5152
const cfg = await Config.get()
5253

54+
const skillDirs = await Skill.dirs()
5355
const defaults = PermissionNext.fromConfig({
5456
"*": "allow",
5557
doom_loop: "ask",
5658
external_directory: {
5759
"*": "ask",
5860
[Truncate.GLOB]: "allow",
61+
...Object.fromEntries(skillDirs.map((dir) => [path.join(dir, "*"), "allow"])),
5962
},
6063
question: "deny",
6164
plan_enter: "deny",

packages/opencode/src/skill/skill.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,14 +145,23 @@ export namespace Skill {
145145
}
146146
}
147147

148-
return skills
148+
const dirs = Array.from(new Set(Object.values(skills).map((item) => path.dirname(item.location))))
149+
150+
return {
151+
skills,
152+
dirs,
153+
}
149154
})
150155

151156
export async function get(name: string) {
152-
return state().then((x) => x[name])
157+
return state().then((x) => x.skills[name])
153158
}
154159

155160
export async function all() {
156-
return state().then((x) => Object.values(x))
161+
return state().then((x) => Object.values(x.skills))
162+
}
163+
164+
export async function dirs() {
165+
return state().then((x) => x.dirs)
157166
}
158167
}

packages/opencode/src/tool/skill.ts

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import path from "path"
2+
import { pathToFileURL } from "url"
23
import z from "zod"
34
import { Tool } from "./tool"
45
import { Skill } from "../skill"
56
import { PermissionNext } from "../permission/next"
7+
import { Ripgrep } from "../file/ripgrep"
8+
import { iife } from "@/util/iife"
69

710
export const SkillTool = Tool.define("skill", async (ctx) => {
811
const skills = await Skill.all()
@@ -18,21 +21,29 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
1821

1922
const description =
2023
accessibleSkills.length === 0
21-
? "Load a skill to get detailed instructions for a specific task. No skills are currently available."
24+
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
2225
: [
23-
"Load a skill to get detailed instructions for a specific task.",
24-
"Skills provide specialized knowledge and step-by-step guidance.",
25-
"Use this when a task matches an available skill's description.",
26-
"Only the skills listed here are available:",
26+
"Load a specialized skill that provides domain-specific instructions and workflows.",
27+
"",
28+
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
29+
"",
30+
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
31+
"",
32+
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
33+
"",
34+
"The following skills provide specialized sets of instructions for particular tasks",
35+
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
36+
"",
2737
"<available_skills>",
2838
...accessibleSkills.flatMap((skill) => [
2939
` <skill>`,
3040
` <name>${skill.name}</name>`,
3141
` <description>${skill.description}</description>`,
42+
` <location>${pathToFileURL(skill.location).href}</location>`,
3243
` </skill>`,
3344
]),
3445
"</available_skills>",
35-
].join(" ")
46+
].join("\n")
3647

3748
const examples = accessibleSkills
3849
.map((skill) => `'${skill.name}'`)
@@ -41,7 +52,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
4152
const hint = examples.length > 0 ? ` (e.g., ${examples}, ...)` : ""
4253

4354
const parameters = z.object({
44-
name: z.string().describe(`The skill identifier from available_skills${hint}`),
55+
name: z.string().describe(`The name of the skill from available_skills${hint}`),
4556
})
4657

4758
return {
@@ -61,15 +72,47 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
6172
always: [params.name],
6273
metadata: {},
6374
})
64-
const content = skill.content
75+
6576
const dir = path.dirname(skill.location)
77+
const base = pathToFileURL(dir).href
6678

67-
// Format output similar to plugin pattern
68-
const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", content.trim()].join("\n")
79+
const limit = 10
80+
const files = await iife(async () => {
81+
const arr = []
82+
for await (const file of Ripgrep.files({
83+
cwd: dir,
84+
follow: false,
85+
hidden: true,
86+
signal: ctx.abort,
87+
})) {
88+
if (file.includes("SKILL.md")) {
89+
continue
90+
}
91+
arr.push(path.resolve(dir, file))
92+
if (arr.length >= limit) {
93+
break
94+
}
95+
}
96+
return arr
97+
}).then((f) => f.map((file) => `<file>${file}</file>`).join("\n"))
6998

7099
return {
71100
title: `Loaded skill: ${skill.name}`,
72-
output,
101+
output: [
102+
`<skill_content name="${skill.name}">`,
103+
`# Skill: ${skill.name}`,
104+
"",
105+
skill.content.trim(),
106+
"",
107+
`Base directory for this skill: ${base}`,
108+
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
109+
"Note: file list is sampled.",
110+
"",
111+
"<skill_files>",
112+
files,
113+
"</skill_files>",
114+
"</skill_content>",
115+
].join("\n"),
73116
metadata: {
74117
name: skill.name,
75118
dir,

packages/opencode/test/agent/agent.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { test, expect } from "bun:test"
2+
import path from "path"
23
import { tmpdir } from "../fixture/fixture"
34
import { Instance } from "../../src/project/instance"
45
import { Agent } from "../../src/agent/agent"
@@ -513,6 +514,42 @@ test("explicit Truncate.GLOB deny is respected", async () => {
513514
})
514515
})
515516

517+
test("skill directories are allowed for external_directory", async () => {
518+
await using tmp = await tmpdir({
519+
git: true,
520+
init: async (dir) => {
521+
const skillDir = path.join(dir, ".opencode", "skill", "perm-skill")
522+
await Bun.write(
523+
path.join(skillDir, "SKILL.md"),
524+
`---
525+
name: perm-skill
526+
description: Permission skill.
527+
---
528+
529+
# Permission Skill
530+
`,
531+
)
532+
},
533+
})
534+
535+
const home = process.env.OPENCODE_TEST_HOME
536+
process.env.OPENCODE_TEST_HOME = tmp.path
537+
538+
try {
539+
await Instance.provide({
540+
directory: tmp.path,
541+
fn: async () => {
542+
const build = await Agent.get("build")
543+
const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill")
544+
const target = path.join(skillDir, "reference", "notes.md")
545+
expect(PermissionNext.evaluate("external_directory", target, build!.permission).action).toBe("allow")
546+
},
547+
})
548+
} finally {
549+
process.env.OPENCODE_TEST_HOME = home
550+
}
551+
})
552+
516553
test("defaultAgent returns build when no default_agent config", async () => {
517554
await using tmp = await tmpdir()
518555
await Instance.provide({

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,42 @@ Instructions here.
5555
})
5656
})
5757

58+
test("returns skill directories from Skill.dirs", async () => {
59+
await using tmp = await tmpdir({
60+
git: true,
61+
init: async (dir) => {
62+
const skillDir = path.join(dir, ".opencode", "skill", "dir-skill")
63+
await Bun.write(
64+
path.join(skillDir, "SKILL.md"),
65+
`---
66+
name: dir-skill
67+
description: Skill for dirs test.
68+
---
69+
70+
# Dir Skill
71+
`,
72+
)
73+
},
74+
})
75+
76+
const home = process.env.OPENCODE_TEST_HOME
77+
process.env.OPENCODE_TEST_HOME = tmp.path
78+
79+
try {
80+
await Instance.provide({
81+
directory: tmp.path,
82+
fn: async () => {
83+
const dirs = await Skill.dirs()
84+
const skillDir = path.join(tmp.path, ".opencode", "skill", "dir-skill")
85+
expect(dirs).toContain(skillDir)
86+
expect(dirs.length).toBe(1)
87+
},
88+
})
89+
} finally {
90+
process.env.OPENCODE_TEST_HOME = home
91+
}
92+
})
93+
5894
test("discovers multiple skills from .opencode/skill/ directory", async () => {
5995
await using tmp = await tmpdir({
6096
git: true,

0 commit comments

Comments
 (0)