mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
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:
parent
4035afe5c8
commit
a9542e282c
9 changed files with 171 additions and 19 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -534,7 +534,7 @@ export namespace SessionPrompt {
|
|||
sessionID,
|
||||
system: [
|
||||
...(await SystemPrompt.environment()),
|
||||
...(await SystemPrompt.skills()),
|
||||
...(await SystemPrompt.skills(agent.name)),
|
||||
...(await SystemPrompt.custom()),
|
||||
],
|
||||
messages: [
|
||||
|
|
|
|||
|
|
@ -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>")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
78
packages/opencode/src/tool/skill.ts
Normal file
78
packages/opencode/src/tool/skill.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
@ -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")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue