Jelajahi Sumber

feat: improve skills, better prompting, fix permission asks after invoking skills, ensure agent knows where scripts/resources are (#11737)

Aiden Cline 2 bulan lalu
induk
melakukan
3975329629

+ 0 - 1
.opencode/opencode.jsonc

@@ -1,6 +1,5 @@
 {
   "$schema": "https://opencode.ai/config.json",
-  // "plugin": ["opencode-openai-codex-auth"],
   // "enterprise": {
   //   "url": "https://enterprise.dev.opencode.ai",
   // },

+ 3 - 0
packages/opencode/src/agent/agent.ts

@@ -18,6 +18,7 @@ import { mergeDeep, pipe, sortBy, values } from "remeda"
 import { Global } from "@/global"
 import path from "path"
 import { Plugin } from "@/plugin"
+import { Skill } from "../skill"
 
 export namespace Agent {
   export const Info = z
@@ -50,12 +51,14 @@ export namespace Agent {
   const state = Instance.state(async () => {
     const cfg = await Config.get()
 
+    const skillDirs = await Skill.dirs()
     const defaults = PermissionNext.fromConfig({
       "*": "allow",
       doom_loop: "ask",
       external_directory: {
         "*": "ask",
         [Truncate.GLOB]: "allow",
+        ...Object.fromEntries(skillDirs.map((dir) => [path.join(dir, "*"), "allow"])),
       },
       question: "deny",
       plan_enter: "deny",

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

@@ -145,14 +145,23 @@ export namespace Skill {
       }
     }
 
-    return skills
+    const dirs = Array.from(new Set(Object.values(skills).map((item) => path.dirname(item.location))))
+
+    return {
+      skills,
+      dirs,
+    }
   })
 
   export async function get(name: string) {
-    return state().then((x) => x[name])
+    return state().then((x) => x.skills[name])
   }
 
   export async function all() {
-    return state().then((x) => Object.values(x))
+    return state().then((x) => Object.values(x.skills))
+  }
+
+  export async function dirs() {
+    return state().then((x) => x.dirs)
   }
 }

+ 54 - 11
packages/opencode/src/tool/skill.ts

@@ -1,8 +1,11 @@
 import path from "path"
+import { pathToFileURL } from "url"
 import z from "zod"
 import { Tool } from "./tool"
 import { Skill } from "../skill"
 import { PermissionNext } from "../permission/next"
+import { Ripgrep } from "../file/ripgrep"
+import { iife } from "@/util/iife"
 
 export const SkillTool = Tool.define("skill", async (ctx) => {
   const skills = await Skill.all()
@@ -18,21 +21,29 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
 
   const description =
     accessibleSkills.length === 0
-      ? "Load a skill to get detailed instructions for a specific task. No skills are currently available."
+      ? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
       : [
-          "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.",
-          "Only the skills listed here are available:",
+          "Load a specialized skill that provides domain-specific instructions and workflows.",
+          "",
+          "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
+          "",
+          "The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
+          "",
+          'Tool output includes a `<skill_content name="...">` block with the loaded content.',
+          "",
+          "The following skills provide specialized sets of instructions for particular tasks",
+          "Invoke this tool to load a skill when a task matches one of the available skills listed below:",
+          "",
           "<available_skills>",
           ...accessibleSkills.flatMap((skill) => [
             `  <skill>`,
             `    <name>${skill.name}</name>`,
             `    <description>${skill.description}</description>`,
+            `    <location>${pathToFileURL(skill.location).href}</location>`,
             `  </skill>`,
           ]),
           "</available_skills>",
-        ].join(" ")
+        ].join("\n")
 
   const examples = accessibleSkills
     .map((skill) => `'${skill.name}'`)
@@ -41,7 +52,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
   const hint = examples.length > 0 ? ` (e.g., ${examples}, ...)` : ""
 
   const parameters = z.object({
-    name: z.string().describe(`The skill identifier from available_skills${hint}`),
+    name: z.string().describe(`The name of the skill from available_skills${hint}`),
   })
 
   return {
@@ -61,15 +72,47 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
         always: [params.name],
         metadata: {},
       })
-      const content = skill.content
+
       const dir = path.dirname(skill.location)
+      const base = pathToFileURL(dir).href
 
-      // Format output similar to plugin pattern
-      const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", content.trim()].join("\n")
+      const limit = 10
+      const files = await iife(async () => {
+        const arr = []
+        for await (const file of Ripgrep.files({
+          cwd: dir,
+          follow: false,
+          hidden: true,
+          signal: ctx.abort,
+        })) {
+          if (file.includes("SKILL.md")) {
+            continue
+          }
+          arr.push(path.resolve(dir, file))
+          if (arr.length >= limit) {
+            break
+          }
+        }
+        return arr
+      }).then((f) => f.map((file) => `<file>${file}</file>`).join("\n"))
 
       return {
         title: `Loaded skill: ${skill.name}`,
-        output,
+        output: [
+          `<skill_content name="${skill.name}">`,
+          `# Skill: ${skill.name}`,
+          "",
+          skill.content.trim(),
+          "",
+          `Base directory for this skill: ${base}`,
+          "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
+          "Note: file list is sampled.",
+          "",
+          "<skill_files>",
+          files,
+          "</skill_files>",
+          "</skill_content>",
+        ].join("\n"),
         metadata: {
           name: skill.name,
           dir,

+ 37 - 0
packages/opencode/test/agent/agent.test.ts

@@ -1,4 +1,5 @@
 import { test, expect } from "bun:test"
+import path from "path"
 import { tmpdir } from "../fixture/fixture"
 import { Instance } from "../../src/project/instance"
 import { Agent } from "../../src/agent/agent"
@@ -513,6 +514,42 @@ test("explicit Truncate.GLOB deny is respected", async () => {
   })
 })
 
+test("skill directories are allowed for external_directory", async () => {
+  await using tmp = await tmpdir({
+    git: true,
+    init: async (dir) => {
+      const skillDir = path.join(dir, ".opencode", "skill", "perm-skill")
+      await Bun.write(
+        path.join(skillDir, "SKILL.md"),
+        `---
+name: perm-skill
+description: Permission skill.
+---
+
+# Permission Skill
+`,
+      )
+    },
+  })
+
+  const home = process.env.OPENCODE_TEST_HOME
+  process.env.OPENCODE_TEST_HOME = tmp.path
+
+  try {
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const build = await Agent.get("build")
+        const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill")
+        const target = path.join(skillDir, "reference", "notes.md")
+        expect(PermissionNext.evaluate("external_directory", target, build!.permission).action).toBe("allow")
+      },
+    })
+  } finally {
+    process.env.OPENCODE_TEST_HOME = home
+  }
+})
+
 test("defaultAgent returns build when no default_agent config", async () => {
   await using tmp = await tmpdir()
   await Instance.provide({

+ 36 - 0
packages/opencode/test/skill/skill.test.ts

@@ -55,6 +55,42 @@ Instructions here.
   })
 })
 
+test("returns skill directories from Skill.dirs", async () => {
+  await using tmp = await tmpdir({
+    git: true,
+    init: async (dir) => {
+      const skillDir = path.join(dir, ".opencode", "skill", "dir-skill")
+      await Bun.write(
+        path.join(skillDir, "SKILL.md"),
+        `---
+name: dir-skill
+description: Skill for dirs test.
+---
+
+# Dir Skill
+`,
+      )
+    },
+  })
+
+  const home = process.env.OPENCODE_TEST_HOME
+  process.env.OPENCODE_TEST_HOME = tmp.path
+
+  try {
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const dirs = await Skill.dirs()
+        const skillDir = path.join(tmp.path, ".opencode", "skill", "dir-skill")
+        expect(dirs).toContain(skillDir)
+        expect(dirs.length).toBe(1)
+      },
+    })
+  } finally {
+    process.env.OPENCODE_TEST_HOME = home
+  }
+})
+
 test("discovers multiple skills from .opencode/skill/ directory", async () => {
   await using tmp = await tmpdir({
     git: true,