Explorar o código

feat(skill): add per-agent filtering to skill tool description (#6000)

Mohammad Alhashemi hai 2 meses
pai
achega
3a54ab68d1

+ 1 - 1
packages/opencode/src/session/prompt.ts

@@ -583,7 +583,7 @@ export namespace SessionPrompt {
       mergeDeep(await ToolRegistry.enabled(input.agent)),
       mergeDeep(input.tools ?? {}),
     )
-    for (const item of await ToolRegistry.tools(input.model.providerID)) {
+    for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) {
       if (Wildcard.all(item.id, enabledTools) === false) continue
       const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
       tools[item.id] = tool({

+ 12 - 0
packages/opencode/src/skill/skill.ts

@@ -3,8 +3,10 @@ import { Config } from "../config/config"
 import { Instance } from "../project/instance"
 import { NamedError } from "@opencode-ai/util/error"
 import { ConfigMarkdown } from "../config/markdown"
+import { Log } from "../util/log"
 
 export namespace Skill {
+  const log = Log.create({ service: "skill" })
   export const Info = z.object({
     name: z.string(),
     description: z.string(),
@@ -50,6 +52,16 @@ export namespace Skill {
 
         const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
         if (!parsed.success) continue
+
+        // Warn on duplicate skill names
+        if (skills[parsed.data.name]) {
+          log.warn("duplicate skill name", {
+            name: parsed.data.name,
+            existing: skills[parsed.data.name].location,
+            duplicate: match,
+          })
+        }
+
         skills[parsed.data.name] = {
           name: parsed.data.name,
           description: parsed.data.description,

+ 2 - 2
packages/opencode/src/tool/registry.ts

@@ -115,7 +115,7 @@ export namespace ToolRegistry {
     return all().then((x) => x.map((t) => t.id))
   }
 
-  export async function tools(providerID: string) {
+  export async function tools(providerID: string, agent?: Agent.Info) {
     const tools = await all()
     const result = await Promise.all(
       tools
@@ -130,7 +130,7 @@ export namespace ToolRegistry {
           using _ = log.time(t.id)
           return {
             id: t.id,
-            ...(await t.init()),
+            ...(await t.init({ agent })),
           }
         }),
     )

+ 83 - 67
packages/opencode/src/tool/skill.ts

@@ -7,78 +7,94 @@ import { Permission } from "../permission"
 import { Wildcard } from "../util/wildcard"
 import { ConfigMarkdown } from "../config/markdown"
 
-export const SkillTool = Tool.define("skill", async () => {
-  const skills = 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.",
-      "<available_skills>",
-      ...skills.flatMap((skill) => [
-        `  <skill>`,
-        `    <name>${skill.name}</name>`,
-        `    <description>${skill.description}</description>`,
-        `  </skill>`,
-      ]),
-      "</available_skills>",
-    ].join(" "),
-    parameters: z.object({
-      name: 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)
+const parameters = z.object({
+  name: z.string().describe("The skill identifier from available_skills (e.g., 'code-review')"),
+})
 
-      const skill = await Skill.get(params.name)
+export const SkillTool: Tool.Info<typeof parameters> = {
+  id: "skill",
+  async init(ctx) {
+    const skills = await Skill.all()
 
-      if (!skill) {
-        const available = Skill.all().then((x) => Object.keys(x).join(", "))
-        throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
-      }
+    // Filter skills by agent permissions if agent provided
+    let accessibleSkills = skills
+    if (ctx?.agent) {
+      const permissions = ctx.agent.permission.skill
+      accessibleSkills = skills.filter((skill) => {
+        const action = Wildcard.all(skill.name, permissions)
+        return action !== "deny"
+      })
+    }
 
-      // Check permission using Wildcard.all on the skill ID
-      const permissions = agent.permission.skill
-      const action = Wildcard.all(params.name, permissions)
+    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.",
+        "<available_skills>",
+        ...accessibleSkills.flatMap((skill) => [
+          `  <skill>`,
+          `    <name>${skill.name}</name>`,
+          `    <description>${skill.description}</description>`,
+          `  </skill>`,
+        ]),
+        "</available_skills>",
+      ].join(" "),
+      parameters,
+      async execute(params, ctx) {
+        const agent = await Agent.get(ctx.agent)
 
-      if (action === "deny") {
-        throw new Permission.RejectedError(
-          ctx.sessionID,
-          "skill",
-          ctx.callID,
-          { skill: params.name },
-          `Access to skill "${params.name}" is denied for agent "${agent.name}".`,
-        )
-      }
+        const skill = await Skill.get(params.name)
 
-      if (action === "ask") {
-        await Permission.ask({
-          type: "skill",
-          pattern: params.name,
-          sessionID: ctx.sessionID,
-          messageID: ctx.messageID,
-          callID: ctx.callID,
-          title: `Load skill: ${skill.name}`,
-          metadata: { id: params.name, name: skill.name, description: skill.description },
-        })
-      }
+        if (!skill) {
+          const available = await Skill.all().then((x) => x.map((s) => s.name).join(", "))
+          throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
+        }
 
-      // Load and parse skill content
-      const parsed = await ConfigMarkdown.parse(skill.location)
-      const dir = path.dirname(skill.location)
+        // Check permission using Wildcard.all on the skill name
+        const permissions = agent.permission.skill
+        const action = Wildcard.all(params.name, permissions)
 
-      // Format output similar to plugin pattern
-      const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join("\n")
+        if (action === "deny") {
+          throw new Permission.RejectedError(
+            ctx.sessionID,
+            "skill",
+            ctx.callID,
+            { skill: params.name },
+            `Access to skill "${params.name}" is denied for agent "${agent.name}".`,
+          )
+        }
 
-      return {
-        title: `Loaded skill: ${skill.name}`,
-        output,
-        metadata: {
-          name: skill.name,
-          dir,
-        },
-      }
-    },
-  }
-})
+        if (action === "ask") {
+          await Permission.ask({
+            type: "skill",
+            pattern: params.name,
+            sessionID: ctx.sessionID,
+            messageID: ctx.messageID,
+            callID: ctx.callID,
+            title: `Load skill: ${skill.name}`,
+            metadata: { name: skill.name, description: skill.description },
+          })
+        }
+
+        // Load and parse skill content
+        const parsed = await ConfigMarkdown.parse(skill.location)
+        const dir = path.dirname(skill.location)
+
+        // Format output similar to plugin pattern
+        const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join(
+          "\n",
+        )
+
+        return {
+          title: `Loaded skill: ${skill.name}`,
+          output,
+          metadata: {
+            name: skill.name,
+            dir,
+          },
+        }
+      },
+    }
+  },
+}

+ 8 - 3
packages/opencode/src/tool/tool.ts

@@ -1,11 +1,16 @@
 import z from "zod"
 import type { MessageV2 } from "../session/message-v2"
+import type { Agent } from "../agent/agent"
 
 export namespace Tool {
   interface Metadata {
     [key: string]: any
   }
 
+  export interface InitContext {
+    agent?: Agent.Info
+  }
+
   export type Context<M extends Metadata = Metadata> = {
     sessionID: string
     messageID: string
@@ -17,7 +22,7 @@ export namespace Tool {
   }
   export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
     id: string
-    init: () => Promise<{
+    init: (ctx?: InitContext) => Promise<{
       description: string
       parameters: Parameters
       execute(
@@ -42,8 +47,8 @@ export namespace Tool {
   ): Info<Parameters, Result> {
     return {
       id,
-      init: async () => {
-        const toolInfo = init instanceof Function ? await init() : init
+      init: async (ctx) => {
+        const toolInfo = init instanceof Function ? await init(ctx) : init
         const execute = toolInfo.execute
         toolInfo.execute = (args, ctx) => {
           try {

+ 106 - 7
packages/web/src/content/docs/skills.mdx

@@ -4,7 +4,7 @@ description: "Define reusable behavior via SKILL.md definitions"
 ---
 
 Agent skills let OpenCode discover reusable instructions from your repo or home directory.
-When a conversation matches a skill, the agent is prompted to read its `SKILL.md`.
+Skills are loaded on-demand via the native `skill` tool—agents see available skills and can load the full content when needed.
 
 ---
 
@@ -97,24 +97,123 @@ Ask clarifying questions if the target versioning scheme is unclear.
 
 ---
 
-## Recognize prompt injection
+## Recognize tool description
 
-OpenCode adds an `<available_skills>` XML block to the system prompt.
-Each entry includes the skill name, description, and its discovered location.
+OpenCode lists available skills in the `skill` tool description.
+Each entry includes the skill name and description:
 
 ```xml
 <available_skills>
   <skill>
     <name>git-release</name>
     <description>Create consistent releases and changelogs</description>
-    <location>.opencode/skill/git-release/SKILL.md</location>
   </skill>
 </available_skills>
 ```
 
+The agent loads a skill by calling the tool:
+
+```
+skill({ name: "git-release" })
+```
+
+---
+
+## Configure permissions
+
+Control which skills agents can access using pattern-based permissions in `opencode.json`:
+
+```json
+{
+  "permission": {
+    "skill": {
+      "pr-review": "allow",
+      "internal-*": "deny",
+      "experimental-*": "ask",
+      "*": "allow"
+    }
+  }
+}
+```
+
+| Permission | Behavior                                  |
+| ---------- | ----------------------------------------- |
+| `allow`    | Skill loads immediately                   |
+| `deny`     | Skill hidden from agent, access rejected  |
+| `ask`      | User prompted for approval before loading |
+
+Patterns support wildcards: `internal-*` matches `internal-docs`, `internal-tools`, etc.
+
+---
+
+## Override per agent
+
+Give specific agents different permissions than the global defaults.
+
+**For custom agents** (in agent frontmatter):
+
+```yaml
+---
+permission:
+  skill:
+    "documents-*": "allow"
+---
+```
+
+**For built-in agents** (in `opencode.json`):
+
+```json
+{
+  "agent": {
+    "plan": {
+      "permission": {
+        "skill": {
+          "internal-*": "allow"
+        }
+      }
+    }
+  }
+}
+```
+
+---
+
+## Disable the skill tool
+
+Completely disable skills for agents that shouldn't use them:
+
+**For custom agents**:
+
+```yaml
+---
+tools:
+  skill: false
+---
+```
+
+**For built-in agents**:
+
+```json
+{
+  "agent": {
+    "plan": {
+      "tools": {
+        "skill": false
+      }
+    }
+  }
+}
+```
+
+When disabled, the `<available_skills>` section is omitted entirely.
+
 ---
 
 ## Troubleshoot loading
 
-If a skill does not show up, verify the folder name matches `name` exactly.
-Also check that `SKILL.md` is spelled in all caps and includes frontmatter.
+If a skill does not show up:
+
+1. Verify `SKILL.md` is spelled in all caps
+2. Check that frontmatter includes `name` and `description`
+3. Ensure skill names are unique across all locations
+4. Check permissions—skills with `deny` are hidden from agents