mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-03-29 21:33:54 +00:00
feat: improve skills, better prompting, fix permission asks after invoking skills, ensure agent knows where scripts/resources are (#11737)
This commit is contained in:
parent
54e14c1a17
commit
3975329629
@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
// "plugin": ["opencode-openai-codex-auth"],
|
|
||||||
// "enterprise": {
|
// "enterprise": {
|
||||||
// "url": "https://enterprise.dev.opencode.ai",
|
// "url": "https://enterprise.dev.opencode.ai",
|
||||||
// },
|
// },
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { mergeDeep, pipe, sortBy, values } from "remeda"
|
|||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Plugin } from "@/plugin"
|
import { Plugin } from "@/plugin"
|
||||||
|
import { Skill } from "../skill"
|
||||||
|
|
||||||
export namespace Agent {
|
export namespace Agent {
|
||||||
export const Info = z
|
export const Info = z
|
||||||
@ -50,12 +51,14 @@ export namespace Agent {
|
|||||||
const state = Instance.state(async () => {
|
const state = Instance.state(async () => {
|
||||||
const cfg = await Config.get()
|
const cfg = await Config.get()
|
||||||
|
|
||||||
|
const skillDirs = await Skill.dirs()
|
||||||
const defaults = PermissionNext.fromConfig({
|
const defaults = PermissionNext.fromConfig({
|
||||||
"*": "allow",
|
"*": "allow",
|
||||||
doom_loop: "ask",
|
doom_loop: "ask",
|
||||||
external_directory: {
|
external_directory: {
|
||||||
"*": "ask",
|
"*": "ask",
|
||||||
[Truncate.GLOB]: "allow",
|
[Truncate.GLOB]: "allow",
|
||||||
|
...Object.fromEntries(skillDirs.map((dir) => [path.join(dir, "*"), "allow"])),
|
||||||
},
|
},
|
||||||
question: "deny",
|
question: "deny",
|
||||||
plan_enter: "deny",
|
plan_enter: "deny",
|
||||||
|
|||||||
@ -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) {
|
export async function get(name: string) {
|
||||||
return state().then((x) => x[name])
|
return state().then((x) => x.skills[name])
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function all() {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { pathToFileURL } from "url"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
import { PermissionNext } from "../permission/next"
|
import { PermissionNext } from "../permission/next"
|
||||||
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
|
import { iife } from "@/util/iife"
|
||||||
|
|
||||||
export const SkillTool = Tool.define("skill", async (ctx) => {
|
export const SkillTool = Tool.define("skill", async (ctx) => {
|
||||||
const skills = await Skill.all()
|
const skills = await Skill.all()
|
||||||
@ -18,21 +21,29 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
|
|||||||
|
|
||||||
const description =
|
const description =
|
||||||
accessibleSkills.length === 0
|
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.",
|
"Load a specialized skill that provides domain-specific instructions and workflows.",
|
||||||
"Skills provide specialized knowledge and step-by-step guidance.",
|
"",
|
||||||
"Use this when a task matches an available skill's description.",
|
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
|
||||||
"Only the skills listed here are available:",
|
"",
|
||||||
|
"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>",
|
"<available_skills>",
|
||||||
...accessibleSkills.flatMap((skill) => [
|
...accessibleSkills.flatMap((skill) => [
|
||||||
` <skill>`,
|
` <skill>`,
|
||||||
` <name>${skill.name}</name>`,
|
` <name>${skill.name}</name>`,
|
||||||
` <description>${skill.description}</description>`,
|
` <description>${skill.description}</description>`,
|
||||||
|
` <location>${pathToFileURL(skill.location).href}</location>`,
|
||||||
` </skill>`,
|
` </skill>`,
|
||||||
]),
|
]),
|
||||||
"</available_skills>",
|
"</available_skills>",
|
||||||
].join(" ")
|
].join("\n")
|
||||||
|
|
||||||
const examples = accessibleSkills
|
const examples = accessibleSkills
|
||||||
.map((skill) => `'${skill.name}'`)
|
.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 hint = examples.length > 0 ? ` (e.g., ${examples}, ...)` : ""
|
||||||
|
|
||||||
const parameters = z.object({
|
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 {
|
return {
|
||||||
@ -61,15 +72,47 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
|
|||||||
always: [params.name],
|
always: [params.name],
|
||||||
metadata: {},
|
metadata: {},
|
||||||
})
|
})
|
||||||
const content = skill.content
|
|
||||||
const dir = path.dirname(skill.location)
|
|
||||||
|
|
||||||
// Format output similar to plugin pattern
|
const dir = path.dirname(skill.location)
|
||||||
const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", content.trim()].join("\n")
|
const base = pathToFileURL(dir).href
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
title: `Loaded skill: ${skill.name}`,
|
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: {
|
metadata: {
|
||||||
name: skill.name,
|
name: skill.name,
|
||||||
dir,
|
dir,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { test, expect } from "bun:test"
|
import { test, expect } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Agent } from "../../src/agent/agent"
|
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 () => {
|
test("defaultAgent returns build when no default_agent config", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
|
|||||||
@ -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 () => {
|
test("discovers multiple skills from .opencode/skill/ directory", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
git: true,
|
git: true,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user