Skip to content

Commit 3a54ab6

Browse files
authored
feat(skill): add per-agent filtering to skill tool description (anomalyco#6000)
1 parent 44fd0ee commit 3a54ab6

File tree

6 files changed

+212
-80
lines changed

6 files changed

+212
-80
lines changed

packages/opencode/src/session/prompt.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -583,7 +583,7 @@ export namespace SessionPrompt {
583583
mergeDeep(await ToolRegistry.enabled(input.agent)),
584584
mergeDeep(input.tools ?? {}),
585585
)
586-
for (const item of await ToolRegistry.tools(input.model.providerID)) {
586+
for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) {
587587
if (Wildcard.all(item.id, enabledTools) === false) continue
588588
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
589589
tools[item.id] = tool({

packages/opencode/src/skill/skill.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import { Config } from "../config/config"
33
import { Instance } from "../project/instance"
44
import { NamedError } from "@opencode-ai/util/error"
55
import { ConfigMarkdown } from "../config/markdown"
6+
import { Log } from "../util/log"
67

78
export namespace Skill {
9+
const log = Log.create({ service: "skill" })
810
export const Info = z.object({
911
name: z.string(),
1012
description: z.string(),
@@ -50,6 +52,16 @@ export namespace Skill {
5052

5153
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
5254
if (!parsed.success) continue
55+
56+
// Warn on duplicate skill names
57+
if (skills[parsed.data.name]) {
58+
log.warn("duplicate skill name", {
59+
name: parsed.data.name,
60+
existing: skills[parsed.data.name].location,
61+
duplicate: match,
62+
})
63+
}
64+
5365
skills[parsed.data.name] = {
5466
name: parsed.data.name,
5567
description: parsed.data.description,

packages/opencode/src/tool/registry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ export namespace ToolRegistry {
115115
return all().then((x) => x.map((t) => t.id))
116116
}
117117

118-
export async function tools(providerID: string) {
118+
export async function tools(providerID: string, agent?: Agent.Info) {
119119
const tools = await all()
120120
const result = await Promise.all(
121121
tools
@@ -130,7 +130,7 @@ export namespace ToolRegistry {
130130
using _ = log.time(t.id)
131131
return {
132132
id: t.id,
133-
...(await t.init()),
133+
...(await t.init({ agent })),
134134
}
135135
}),
136136
)

packages/opencode/src/tool/skill.ts

Lines changed: 83 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -7,78 +7,94 @@ import { Permission } from "../permission"
77
import { Wildcard } from "../util/wildcard"
88
import { ConfigMarkdown } from "../config/markdown"
99

10-
export const SkillTool = Tool.define("skill", async () => {
11-
const skills = await Skill.all()
12-
return {
13-
description: [
14-
"Load a skill to get detailed instructions for a specific task.",
15-
"Skills provide specialized knowledge and step-by-step guidance.",
16-
"Use this when a task matches an available skill's description.",
17-
"<available_skills>",
18-
...skills.flatMap((skill) => [
19-
` <skill>`,
20-
` <name>${skill.name}</name>`,
21-
` <description>${skill.description}</description>`,
22-
` </skill>`,
23-
]),
24-
"</available_skills>",
25-
].join(" "),
26-
parameters: z.object({
27-
name: z
28-
.string()
29-
.describe("The skill identifier from available_skills (e.g., 'code-review' or 'category/helper')"),
30-
}),
31-
async execute(params, ctx) {
32-
const agent = await Agent.get(ctx.agent)
10+
const parameters = z.object({
11+
name: z.string().describe("The skill identifier from available_skills (e.g., 'code-review')"),
12+
})
3313

34-
const skill = await Skill.get(params.name)
14+
export const SkillTool: Tool.Info<typeof parameters> = {
15+
id: "skill",
16+
async init(ctx) {
17+
const skills = await Skill.all()
3518

36-
if (!skill) {
37-
const available = Skill.all().then((x) => Object.keys(x).join(", "))
38-
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
39-
}
19+
// Filter skills by agent permissions if agent provided
20+
let accessibleSkills = skills
21+
if (ctx?.agent) {
22+
const permissions = ctx.agent.permission.skill
23+
accessibleSkills = skills.filter((skill) => {
24+
const action = Wildcard.all(skill.name, permissions)
25+
return action !== "deny"
26+
})
27+
}
4028

41-
// Check permission using Wildcard.all on the skill ID
42-
const permissions = agent.permission.skill
43-
const action = Wildcard.all(params.name, permissions)
29+
return {
30+
description: [
31+
"Load a skill to get detailed instructions for a specific task.",
32+
"Skills provide specialized knowledge and step-by-step guidance.",
33+
"Use this when a task matches an available skill's description.",
34+
"<available_skills>",
35+
...accessibleSkills.flatMap((skill) => [
36+
` <skill>`,
37+
` <name>${skill.name}</name>`,
38+
` <description>${skill.description}</description>`,
39+
` </skill>`,
40+
]),
41+
"</available_skills>",
42+
].join(" "),
43+
parameters,
44+
async execute(params, ctx) {
45+
const agent = await Agent.get(ctx.agent)
4446

45-
if (action === "deny") {
46-
throw new Permission.RejectedError(
47-
ctx.sessionID,
48-
"skill",
49-
ctx.callID,
50-
{ skill: params.name },
51-
`Access to skill "${params.name}" is denied for agent "${agent.name}".`,
52-
)
53-
}
47+
const skill = await Skill.get(params.name)
5448

55-
if (action === "ask") {
56-
await Permission.ask({
57-
type: "skill",
58-
pattern: params.name,
59-
sessionID: ctx.sessionID,
60-
messageID: ctx.messageID,
61-
callID: ctx.callID,
62-
title: `Load skill: ${skill.name}`,
63-
metadata: { id: params.name, name: skill.name, description: skill.description },
64-
})
65-
}
49+
if (!skill) {
50+
const available = await Skill.all().then((x) => x.map((s) => s.name).join(", "))
51+
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
52+
}
6653

67-
// Load and parse skill content
68-
const parsed = await ConfigMarkdown.parse(skill.location)
69-
const dir = path.dirname(skill.location)
54+
// Check permission using Wildcard.all on the skill name
55+
const permissions = agent.permission.skill
56+
const action = Wildcard.all(params.name, permissions)
7057

71-
// Format output similar to plugin pattern
72-
const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join("\n")
58+
if (action === "deny") {
59+
throw new Permission.RejectedError(
60+
ctx.sessionID,
61+
"skill",
62+
ctx.callID,
63+
{ skill: params.name },
64+
`Access to skill "${params.name}" is denied for agent "${agent.name}".`,
65+
)
66+
}
7367

74-
return {
75-
title: `Loaded skill: ${skill.name}`,
76-
output,
77-
metadata: {
78-
name: skill.name,
79-
dir,
80-
},
81-
}
82-
},
83-
}
84-
})
68+
if (action === "ask") {
69+
await Permission.ask({
70+
type: "skill",
71+
pattern: params.name,
72+
sessionID: ctx.sessionID,
73+
messageID: ctx.messageID,
74+
callID: ctx.callID,
75+
title: `Load skill: ${skill.name}`,
76+
metadata: { name: skill.name, description: skill.description },
77+
})
78+
}
79+
80+
// Load and parse skill content
81+
const parsed = await ConfigMarkdown.parse(skill.location)
82+
const dir = path.dirname(skill.location)
83+
84+
// Format output similar to plugin pattern
85+
const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join(
86+
"\n",
87+
)
88+
89+
return {
90+
title: `Loaded skill: ${skill.name}`,
91+
output,
92+
metadata: {
93+
name: skill.name,
94+
dir,
95+
},
96+
}
97+
},
98+
}
99+
},
100+
}

packages/opencode/src/tool/tool.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import z from "zod"
22
import type { MessageV2 } from "../session/message-v2"
3+
import type { Agent } from "../agent/agent"
34

45
export namespace Tool {
56
interface Metadata {
67
[key: string]: any
78
}
89

10+
export interface InitContext {
11+
agent?: Agent.Info
12+
}
13+
914
export type Context<M extends Metadata = Metadata> = {
1015
sessionID: string
1116
messageID: string
@@ -17,7 +22,7 @@ export namespace Tool {
1722
}
1823
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
1924
id: string
20-
init: () => Promise<{
25+
init: (ctx?: InitContext) => Promise<{
2126
description: string
2227
parameters: Parameters
2328
execute(
@@ -42,8 +47,8 @@ export namespace Tool {
4247
): Info<Parameters, Result> {
4348
return {
4449
id,
45-
init: async () => {
46-
const toolInfo = init instanceof Function ? await init() : init
50+
init: async (ctx) => {
51+
const toolInfo = init instanceof Function ? await init(ctx) : init
4752
const execute = toolInfo.execute
4853
toolInfo.execute = (args, ctx) => {
4954
try {

packages/web/src/content/docs/skills.mdx

Lines changed: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ description: "Define reusable behavior via SKILL.md definitions"
44
---
55

66
Agent skills let OpenCode discover reusable instructions from your repo or home directory.
7-
When a conversation matches a skill, the agent is prompted to read its `SKILL.md`.
7+
Skills are loaded on-demand via the native `skill` tool—agents see available skills and can load the full content when needed.
88

99
---
1010

@@ -97,24 +97,123 @@ Ask clarifying questions if the target versioning scheme is unclear.
9797

9898
---
9999

100-
## Recognize prompt injection
100+
## Recognize tool description
101101

102-
OpenCode adds an `<available_skills>` XML block to the system prompt.
103-
Each entry includes the skill name, description, and its discovered location.
102+
OpenCode lists available skills in the `skill` tool description.
103+
Each entry includes the skill name and description:
104104

105105
```xml
106106
<available_skills>
107107
<skill>
108108
<name>git-release</name>
109109
<description>Create consistent releases and changelogs</description>
110-
<location>.opencode/skill/git-release/SKILL.md</location>
111110
</skill>
112111
</available_skills>
113112
```
114113

114+
The agent loads a skill by calling the tool:
115+
116+
```
117+
skill({ name: "git-release" })
118+
```
119+
120+
---
121+
122+
## Configure permissions
123+
124+
Control which skills agents can access using pattern-based permissions in `opencode.json`:
125+
126+
```json
127+
{
128+
"permission": {
129+
"skill": {
130+
"pr-review": "allow",
131+
"internal-*": "deny",
132+
"experimental-*": "ask",
133+
"*": "allow"
134+
}
135+
}
136+
}
137+
```
138+
139+
| Permission | Behavior |
140+
| ---------- | ----------------------------------------- |
141+
| `allow` | Skill loads immediately |
142+
| `deny` | Skill hidden from agent, access rejected |
143+
| `ask` | User prompted for approval before loading |
144+
145+
Patterns support wildcards: `internal-*` matches `internal-docs`, `internal-tools`, etc.
146+
147+
---
148+
149+
## Override per agent
150+
151+
Give specific agents different permissions than the global defaults.
152+
153+
**For custom agents** (in agent frontmatter):
154+
155+
```yaml
156+
---
157+
permission:
158+
skill:
159+
"documents-*": "allow"
160+
---
161+
```
162+
163+
**For built-in agents** (in `opencode.json`):
164+
165+
```json
166+
{
167+
"agent": {
168+
"plan": {
169+
"permission": {
170+
"skill": {
171+
"internal-*": "allow"
172+
}
173+
}
174+
}
175+
}
176+
}
177+
```
178+
179+
---
180+
181+
## Disable the skill tool
182+
183+
Completely disable skills for agents that shouldn't use them:
184+
185+
**For custom agents**:
186+
187+
```yaml
188+
---
189+
tools:
190+
skill: false
191+
---
192+
```
193+
194+
**For built-in agents**:
195+
196+
```json
197+
{
198+
"agent": {
199+
"plan": {
200+
"tools": {
201+
"skill": false
202+
}
203+
}
204+
}
205+
}
206+
```
207+
208+
When disabled, the `<available_skills>` section is omitted entirely.
209+
115210
---
116211

117212
## Troubleshoot loading
118213

119-
If a skill does not show up, verify the folder name matches `name` exactly.
120-
Also check that `SKILL.md` is spelled in all caps and includes frontmatter.
214+
If a skill does not show up:
215+
216+
1. Verify `SKILL.md` is spelled in all caps
217+
2. Check that frontmatter includes `name` and `description`
218+
3. Ensure skill names are unique across all locations
219+
4. Check permissions—skills with `deny` are hidden from agents

0 commit comments

Comments
 (0)