- 加载器/校验:目录式 configs/skills/<id>/skill.yaml(兼容旧单文件 <id>.yaml,先目录后文件); 新字段 version/when-to-use/invocation(三态)/triggers/paths/allowed-tools/disallowed-tools/ context(ine|fork)/agent/model/effort/metadata;**依赖环 ⇒ 加载期拒**(运行期绝不挂死); depends 指向不存在步骤 ⇒ 拒;同一工具同时在 allowed 与 disallowed ⇒ 拒;错误可分类(哨兵 + %w) - Runner.Run 真执行(替换 ErrRunPending):步骤拓扑(无依赖可并行、上限 2)+ guardrail 强制点 (enforced 违反 ⇒ 中止并如实报「哪条、哪一步」;否则 WARN 继续)+ 失败不静默跳过 + ctx 取消传播 - 🔴 能力收缩只收不放:该轮可用工具 = allowed-tools ∩ 安全闸 ∩ 注册表,再减 disallowed-tools - 另有渐进披露相关的 listing/surface/activation(清单投影与激活判定) ⚠️ 未接线:本片是库与执行器,尚未把技能清单接进模型面(见 report 的分阶段)。
245 lines
8.3 KiB
Go
245 lines
8.3 KiB
Go
package skills
|
||
|
||
import (
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||
)
|
||
|
||
// fullSkillYAML 覆盖 design/09 §6.2 的全部新增字段,用来断言「字段真的接上了」。
|
||
const fullSkillYAML = `
|
||
name: full
|
||
description: 全字段技能
|
||
prompt: base
|
||
version: 3
|
||
when-to-use: 当需要全字段校验时
|
||
invocation: model-only
|
||
triggers: [汇总, 简报]
|
||
paths: ["src/**/*.go"]
|
||
allowed-tools: [search, note]
|
||
disallowed-tools: [write]
|
||
context: fork
|
||
agent: researcher
|
||
model: fast
|
||
effort: high
|
||
metadata:
|
||
owner: yeiji
|
||
steps:
|
||
- name: one
|
||
prompt: p
|
||
`
|
||
|
||
func TestLoader_Load_新字段_正常(t *testing.T) {
|
||
t.Parallel()
|
||
sk, err := NewLoader(testLogger()).Load(strings.NewReader(fullSkillYAML))
|
||
if err != nil {
|
||
t.Fatalf("Load: %v", err)
|
||
}
|
||
if sk.Version() != 3 {
|
||
t.Fatalf("Version = %d, want 3", sk.Version())
|
||
}
|
||
if sk.WhenToUse() != "当需要全字段校验时" {
|
||
t.Fatalf("WhenToUse = %q", sk.WhenToUse())
|
||
}
|
||
if sk.Invocation() != sdk.InvocationModelOnly {
|
||
t.Fatalf("Invocation = %q, want model-only", sk.Invocation())
|
||
}
|
||
if got := strings.Join(sk.Triggers(), ","); got != "汇总,简报" {
|
||
t.Fatalf("Triggers = %q", got)
|
||
}
|
||
if got := strings.Join(sk.Paths(), ","); got != "src/**/*.go" {
|
||
t.Fatalf("Paths = %q", got)
|
||
}
|
||
if got := strings.Join(sk.AllowedTools(), ","); got != "search,note" {
|
||
t.Fatalf("AllowedTools = %q", got)
|
||
}
|
||
if got := strings.Join(sk.DisallowedTools(), ","); got != "write" {
|
||
t.Fatalf("DisallowedTools = %q", got)
|
||
}
|
||
if sk.ContextMode() != sdk.ContextFork || sk.Agent() != "researcher" {
|
||
t.Fatalf("Context/Agent = %q/%q, want fork/researcher", sk.ContextMode(), sk.Agent())
|
||
}
|
||
if sk.Model() != "fast" || sk.Effort() != "high" {
|
||
t.Fatalf("Model/Effort = %q/%q, want fast/high", sk.Model(), sk.Effort())
|
||
}
|
||
if got := sk.Metadata()["owner"]; got != "yeiji" {
|
||
t.Fatalf("Metadata[owner] = %q, want yeiji", got)
|
||
}
|
||
}
|
||
|
||
// TestLoader_Load_缺省值 断言未声明的字段落到文档规定的缺省(version=1 / both / inline)。
|
||
func TestLoader_Load_缺省值(t *testing.T) {
|
||
t.Parallel()
|
||
sk, err := NewLoader(testLogger()).Load(strings.NewReader("name: bare\nprompt: p\n"))
|
||
if err != nil {
|
||
t.Fatalf("Load: %v", err)
|
||
}
|
||
if sk.Version() != sdk.DefaultSkillVersion || sk.Invocation() != sdk.InvocationBoth || sk.ContextMode() != sdk.ContextInline {
|
||
t.Fatalf("缺省值 = %d/%q/%q, want 1/both/inline", sk.Version(), sk.Invocation(), sk.ContextMode())
|
||
}
|
||
if sk.AllowedTools() != nil || sk.Triggers() != nil || sk.Metadata() != nil {
|
||
t.Fatalf("未声明的集合字段应为空:%v/%v/%v", sk.AllowedTools(), sk.Triggers(), sk.Metadata())
|
||
}
|
||
}
|
||
|
||
// TestLoader_Load_非法枚举_异常 断言枚举写错是**显式失败**,不被静默当成缺省。
|
||
func TestLoader_Load_非法枚举_异常(t *testing.T) {
|
||
t.Parallel()
|
||
cases := map[string]string{
|
||
"invocation": "name: x\ninvocation: everyone\n",
|
||
"context": "name: x\ncontext: session\n",
|
||
"paths": "name: x\npaths: [\"[bad\"]\n",
|
||
}
|
||
for name, src := range cases {
|
||
src := src
|
||
t.Run(name, func(t *testing.T) {
|
||
t.Parallel()
|
||
if _, err := NewLoader(testLogger()).Load(strings.NewReader(src)); !errors.Is(err, sdk.ErrInvalidSkillField) {
|
||
t.Fatalf("Load(%s) err = %v, want sdk.ErrInvalidSkillField", name, err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestLoader_Load_结构校验_异常 覆盖 design/09 §6.2 要求的四类「加载期就拒」。
|
||
func TestLoader_Load_结构校验_异常(t *testing.T) {
|
||
t.Parallel()
|
||
cases := map[string]struct {
|
||
yaml string
|
||
want error
|
||
}{
|
||
"依赖不存在": {
|
||
yaml: "name: x\nsteps:\n - name: a\n prompt: p\n depends: [ghost]\n",
|
||
want: ErrStepDependsMissing,
|
||
},
|
||
"依赖成环": {
|
||
yaml: "name: x\nsteps:\n - name: a\n prompt: p\n depends: [b]\n - name: b\n prompt: p\n depends: [a]\n",
|
||
want: ErrStepCycle,
|
||
},
|
||
"自依赖": {
|
||
yaml: "name: x\nsteps:\n - name: a\n prompt: p\n depends: [a]\n",
|
||
want: ErrStepCycle,
|
||
},
|
||
"工具自相矛盾": {
|
||
yaml: "name: x\nallowed-tools: [a, b]\ndisallowed-tools: [b]\n",
|
||
want: ErrToolConflict,
|
||
},
|
||
"护栏重复": {
|
||
yaml: "name: x\nguardrails:\n - id: g\n message: m\n - id: g\n message: n\n",
|
||
want: ErrGuardrailDuplicate,
|
||
},
|
||
"步骤重名": {
|
||
yaml: "name: x\nsteps:\n - name: a\n prompt: p\n - name: a\n prompt: p\n",
|
||
want: ErrStepDuplicate,
|
||
},
|
||
"步骤匿名": {
|
||
yaml: "name: x\nsteps:\n - prompt: p\n",
|
||
want: ErrInvalidSkill,
|
||
},
|
||
}
|
||
for name, tc := range cases {
|
||
tc := tc
|
||
t.Run(name, func(t *testing.T) {
|
||
t.Parallel()
|
||
_, err := NewLoader(testLogger()).Load(strings.NewReader(tc.yaml))
|
||
if !errors.Is(err, tc.want) {
|
||
t.Fatalf("%s: err = %v, want %v", name, err, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestLoader_LoadDir_目录式与单文件兼容 覆盖 design/09 §6.1 的载体规则:
|
||
// 目录式 <id>/skill.yaml、单文件 <id>.yaml、同 id 时目录胜出、无定义文件的子目录跳过。
|
||
func TestLoader_LoadDir_目录式与单文件兼容(t *testing.T) {
|
||
t.Parallel()
|
||
dir := t.TempDir()
|
||
writeNested(t, dir+"/brief/skill.yaml", "name: brief\nprompt: p\n")
|
||
writeNested(t, dir+"/brief/notes.md", "支持文件,不该被当成技能")
|
||
writeFile(t, dir+"/legacy.yaml", "name: legacy\nprompt: p\n")
|
||
writeFile(t, dir+"/both.yaml", "name: from-file\nprompt: p\n")
|
||
writeNested(t, dir+"/both/skill.yaml", "name: from-dir\nprompt: p\n")
|
||
writeNested(t, dir+"/asset/support.txt", "无 skill.yaml 的子目录")
|
||
writeFile(t, dir+"/.hidden.yaml", "name: hidden\nprompt: p\n")
|
||
|
||
loaded, err := NewLoader(testLogger()).LoadDirDetailed(dir)
|
||
if err != nil {
|
||
t.Fatalf("LoadDirDetailed: %v", err)
|
||
}
|
||
if len(loaded) != 3 {
|
||
t.Fatalf("加载了 %d 份,want 3(both/brief/legacy)", len(loaded))
|
||
}
|
||
byID := make(map[string]Loaded, len(loaded))
|
||
for _, item := range loaded {
|
||
byID[item.ID] = item
|
||
}
|
||
if got := byID["both"].Skill.Name(); got != "from-dir" {
|
||
t.Fatalf("同 id 目录与文件并存时取到 %q,want from-dir(目录胜出)", got)
|
||
}
|
||
if !strings.HasSuffix(filepath.ToSlash(byID["both"].File), "both/skill.yaml") {
|
||
t.Fatalf("both 的来源 = %q, want 目录式定义文件", byID["both"].File)
|
||
}
|
||
if !strings.HasSuffix(filepath.ToSlash(byID["brief"].Dir), "brief") {
|
||
t.Fatalf("brief 的 Dir = %q, want 指向技能目录(支持文件按它解析)", byID["brief"].Dir)
|
||
}
|
||
if !strings.HasSuffix(filepath.ToSlash(byID["legacy"].File), "legacy.yaml") {
|
||
t.Fatalf("legacy 的来源 = %q, want 单文件", byID["legacy"].File)
|
||
}
|
||
}
|
||
|
||
// TestLoader_LoadDir_坏定义即整体失败 断言目录加载是 fail fast:一份坏定义不会
|
||
// 被跳过(那会让「配置写错」看起来像「没这个技能」)。
|
||
func TestLoader_LoadDir_坏定义即整体失败(t *testing.T) {
|
||
t.Parallel()
|
||
dir := t.TempDir()
|
||
writeFile(t, dir+"/ok.yaml", "name: ok\nprompt: p\n")
|
||
writeFile(t, dir+"/bad.yaml", "name: bad\nsteps:\n - name: a\n prompt: p\n depends: [ghost]\n")
|
||
if _, err := NewLoader(testLogger()).LoadDir(dir); !errors.Is(err, ErrStepDependsMissing) {
|
||
t.Fatalf("LoadDir err = %v, want ErrStepDependsMissing", err)
|
||
}
|
||
}
|
||
|
||
// TestTopoOrder_顺序与环 断言拓扑序确定(就绪步骤按声明序出队),且成环时如实
|
||
// 列出没排进去的步骤。
|
||
func TestTopoOrder_顺序与环(t *testing.T) {
|
||
t.Parallel()
|
||
order, err := TopoOrder([]sdk.Step{
|
||
{Name: "a", Prompt: "p"},
|
||
{Name: "b", Prompt: "p", Depends: []string{"a"}},
|
||
{Name: "c", Prompt: "p", Depends: []string{"b", "a"}},
|
||
{Name: "z", Prompt: "p"},
|
||
{Name: "y", Prompt: "p", Depends: []string{"z"}},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("TopoOrder: %v", err)
|
||
}
|
||
if got := strings.Join(order, ","); got != "a,z,b,y,c" {
|
||
t.Fatalf("TopoOrder = %q, want a,z,b,y,c", got)
|
||
}
|
||
|
||
_, err = TopoOrder([]sdk.Step{
|
||
{Name: "a", Prompt: "p", Depends: []string{"b"}},
|
||
{Name: "b", Prompt: "p", Depends: []string{"a"}},
|
||
{Name: "free", Prompt: "p"},
|
||
})
|
||
if !errors.Is(err, ErrStepCycle) {
|
||
t.Fatalf("TopoOrder(cycle) err = %v, want ErrStepCycle", err)
|
||
}
|
||
if !strings.Contains(err.Error(), "a") || !strings.Contains(err.Error(), "b") {
|
||
t.Fatalf("环错误没列出相关步骤:%v", err)
|
||
}
|
||
}
|
||
|
||
// writeNested 写文件并自动建父目录(目录式技能要的是 <id>/skill.yaml)。
|
||
func writeNested(t *testing.T, path, content string) {
|
||
t.Helper()
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
||
}
|
||
writeFile(t, path, content)
|
||
}
|