feat: add native skill tool with permission system

- Add skill tool for loading skill content on-demand
- Support pattern-based skill permissions (allow/deny/ask)
- Enable agent-specific permission overrides
- Support nested skills with path-based IDs (e.g., prompter/skill-creator)
- Filter available_skills in system prompt by agent permissions
- Preserve skill tool responses during compaction pruning
This commit is contained in:
M. Adel Alhashemi 2025-12-22 12:11:34 +08:00
parent 4035afe5c8
commit a9542e282c
9 changed files with 171 additions and 19 deletions

View file

@ -30,6 +30,7 @@ export namespace Agent {
permission: z.object({
edit: Config.Permission,
bash: z.record(z.string(), Config.Permission),
skill: z.record(z.string(), Config.Permission),
webfetch: Config.Permission.optional(),
doom_loop: Config.Permission.optional(),
external_directory: Config.Permission.optional(),
@ -58,6 +59,9 @@ export namespace Agent {
bash: {
"*": "allow",
},
skill: {
"*": "allow",
},
webfetch: "allow",
doom_loop: "ask",
external_directory: "ask",
@ -337,6 +341,17 @@ function mergeAgentPermissions(basePermission: any, overridePermission: any): Ag
"*": overridePermission.bash,
}
}
if (typeof basePermission.skill === "string") {
basePermission.skill = {
"*": basePermission.skill,
}
}
if (typeof overridePermission.skill === "string") {
overridePermission.skill = {
"*": overridePermission.skill,
}
}
const merged = mergeDeep(basePermission ?? {}, overridePermission ?? {}) as any
let mergedBash
if (merged.bash) {
@ -354,10 +369,27 @@ function mergeAgentPermissions(basePermission: any, overridePermission: any): Ag
}
}
let mergedSkill
if (merged.skill) {
if (typeof merged.skill === "string") {
mergedSkill = {
"*": merged.skill,
}
} else if (typeof merged.skill === "object") {
mergedSkill = mergeDeep(
{
"*": "allow",
},
merged.skill,
)
}
}
const result: Agent.Info["permission"] = {
edit: merged.edit ?? "allow",
webfetch: merged.webfetch ?? "allow",
bash: mergedBash ?? { "*": "allow" },
skill: mergedSkill ?? { "*": "allow" },
doom_loop: merged.doom_loop,
external_directory: merged.external_directory,
}

View file

@ -414,6 +414,7 @@ export namespace Config {
.object({
edit: Permission.optional(),
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
webfetch: Permission.optional(),
doom_loop: Permission.optional(),
external_directory: Permission.optional(),
@ -761,6 +762,7 @@ export namespace Config {
.object({
edit: Permission.optional(),
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
webfetch: Permission.optional(),
doom_loop: Permission.optional(),
external_directory: Permission.optional(),

View file

@ -61,6 +61,9 @@ export namespace SessionCompaction {
const part = msg.parts[partIndex]
if (part.type === "tool")
if (part.state.status === "completed") {
// Skip skill tool responses - they contain important instructions
if (part.tool === "skill") continue
if (part.state.time.compacted) break loop
const estimate = Token.estimate(part.state.output)
total += estimate

View file

@ -534,7 +534,7 @@ export namespace SessionPrompt {
sessionID,
system: [
...(await SystemPrompt.environment()),
...(await SystemPrompt.skills()),
...(await SystemPrompt.skills(agent.name)),
...(await SystemPrompt.custom()),
],
messages: [

View file

@ -119,20 +119,37 @@ export namespace SystemPrompt {
return Promise.all(found).then((result) => result.filter(Boolean))
}
export async function skills() {
export async function skills(agentName?: string) {
const all = await Skill.all()
if (all.length === 0) return []
// Filter skills by agent permission if agent name provided
let filtered = all
if (agentName) {
const { Agent } = await import("../agent/agent")
const { Wildcard } = await import("../util/wildcard")
const agent = await Agent.get(agentName)
if (agent) {
const permissions = agent.permission.skill
filtered = all.filter((skill) => {
const action = Wildcard.all(skill.id, permissions)
return action !== "deny"
})
}
}
if (filtered.length === 0) return []
const lines = [
"You have access to skills listed in `<available_skills>`. When a task matches a skill's description, read its SKILL.md file to get detailed instructions.",
"You have access to skills listed in `<available_skills>`. When a task matches a skill's description, use the skill tool to load detailed instructions.",
"",
"<available_skills>",
]
for (const skill of all) {
for (const skill of filtered) {
lines.push(" <skill>")
lines.push(` <id>${skill.id}</id>`)
lines.push(` <name>${skill.name}</name>`)
lines.push(` <description>${skill.description}</description>`)
lines.push(` <location>${skill.location}</location>`)
lines.push(" </skill>")
}
lines.push("</available_skills>")

View file

@ -31,6 +31,7 @@ export namespace Skill {
export type Frontmatter = z.infer<typeof Frontmatter>
export interface Info {
id: string // Path-based identifier (e.g., "code-review" or "docs/api-guide")
name: string
description: string
location: string
@ -57,23 +58,29 @@ export namespace Skill {
}),
)
const SKILL_GLOB = new Bun.Glob("skill/*/SKILL.md")
const CLAUDE_SKILL_GLOB = new Bun.Glob("*/SKILL.md")
const SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md")
const CLAUDE_SKILL_GLOB = new Bun.Glob("**/SKILL.md")
async function discover(): Promise<string[]> {
interface DiscoveredSkill {
path: string
baseDir: string // The skill/ or .claude/skills/ directory
}
async function discover(): Promise<DiscoveredSkill[]> {
const directories = await Config.directories()
const paths: string[] = []
const results: DiscoveredSkill[] = []
// Scan skill/ subdirectory in config directories (.opencode/, ~/.opencode/, etc.)
for (const dir of directories) {
const baseDir = path.join(dir, "skill")
for await (const match of SKILL_GLOB.scan({
cwd: dir,
absolute: true,
onlyFiles: true,
followSymlinks: true,
})) {
paths.push(match)
results.push({ path: match, baseDir })
}
}
@ -89,14 +96,15 @@ export namespace Skill {
onlyFiles: true,
followSymlinks: true,
})) {
paths.push(match)
results.push({ path: match, baseDir: dir })
}
}
return paths
return results
}
async function load(skillMdPath: string): Promise<Info> {
async function load(discovered: DiscoveredSkill): Promise<Info> {
const skillMdPath = discovered.path
const md = await ConfigMarkdown.parse(skillMdPath)
if (!md.data) {
throw new InvalidError({
@ -125,7 +133,13 @@ export namespace Skill {
})
}
// Generate path-based ID from relative path
// e.g., baseDir=/path/skill, skillDir=/path/skill/docs/api-guide → id=docs/api-guide
const relativePath = path.relative(discovered.baseDir, skillDir)
const id = relativePath.split(path.sep).join("/") // Normalize to forward slashes
return {
id,
name: frontmatter.name,
description: frontmatter.description,
location: skillMdPath,
@ -136,12 +150,12 @@ export namespace Skill {
}
export const state = Instance.state(async () => {
const paths = await discover()
const discovered = await discover()
const skills: Info[] = []
for (const skillPath of paths) {
const info = await load(skillPath)
log.info("loaded skill", { name: info.name, location: info.location })
for (const item of discovered) {
const info = await load(item)
log.info("loaded skill", { id: info.id, name: info.name, location: info.location })
skills.push(info)
}

View file

@ -10,6 +10,7 @@ import { TodoWriteTool, TodoReadTool } from "./todo"
import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write"
import { InvalidTool } from "./invalid"
import { SkillTool } from "./skill"
import type { Agent } from "../agent/agent"
import { Tool } from "./tool"
import { Instance } from "../project/instance"
@ -102,6 +103,7 @@ export namespace ToolRegistry {
TodoReadTool,
WebSearchTool,
CodeSearchTool,
SkillTool,
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
...custom,
]
@ -148,6 +150,10 @@ export namespace ToolRegistry {
result["codesearch"] = false
result["websearch"] = false
}
// Disable skill tool if all skills are denied
if (agent.permission.skill["*"] === "deny" && Object.keys(agent.permission.skill).length === 1) {
result["skill"] = false
}
return result
}

View file

@ -0,0 +1,78 @@
import path from "path"
import z from "zod"
import { Tool } from "./tool"
import { Skill } from "../skill"
import { Agent } from "../agent/agent"
import { Permission } from "../permission"
import { Wildcard } from "../util/wildcard"
import { ConfigMarkdown } from "../config/markdown"
export const SkillTool = Tool.define("skill", async () => {
const allSkills = await Skill.all()
return {
description: [
"Load a skill to get detailed instructions for a specific task.",
"Skills provide specialized knowledge and step-by-step guidance.",
"Use this when a task matches an available skill's description.",
].join(" "),
parameters: z.object({
id: z.string().describe("The skill identifier from available_skills (e.g., 'code-review' or 'category/helper')"),
}),
async execute(params, ctx) {
const agent = await Agent.get(ctx.agent)
// Look up by id (path-based identifier)
const skill = allSkills.find((s) => s.id === params.id)
if (!skill) {
const available = allSkills.map((s) => s.id).join(", ")
throw new Error(`Skill "${params.id}" not found. Available skills: ${available || "none"}`)
}
// Check permission using Wildcard.all on the skill ID
const permissions = agent.permission.skill
const action = Wildcard.all(params.id, permissions)
if (action === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"skill",
ctx.callID,
{ skill: params.id },
`Access to skill "${params.id}" is denied for agent "${agent.name}".`,
)
}
if (action === "ask") {
await Permission.ask({
type: "skill",
pattern: params.id,
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: `Load skill: ${skill.name}`,
metadata: { id: params.id, name: skill.name, description: skill.description },
})
}
// Load and parse skill content
const parsed = await ConfigMarkdown.parse(skill.location)
const baseDir = path.dirname(skill.location)
// Format output similar to plugin pattern
const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${baseDir}`, "", parsed.content.trim()].join(
"\n",
)
return {
title: `Loaded skill: ${skill.name}`,
output,
metadata: {
id: skill.id,
name: skill.name,
baseDir,
},
}
},
}
})

View file

@ -252,11 +252,11 @@ description: An example skill for testing XML output.
const result = await SystemPrompt.skills()
expect(result.length).toBe(1)
expect(result[0]).toContain("<available_skills>")
expect(result[0]).toContain("<id>example-skill</id>")
expect(result[0]).toContain("<name>example-skill</name>")
expect(result[0]).toContain("<description>An example skill for testing XML output.</description>")
expect(result[0]).toContain("SKILL.md</location>")
expect(result[0]).toContain("</available_skills>")
expect(result[0]).toContain("When a task matches a skill's description")
expect(result[0]).toContain("use the skill tool")
},
})
})