Files
AskaEth f75e7a9c9b feat(skills): 技能加载/校验/真执行第一片(design/09 §6 定稿落地)
- 加载器/校验:目录式 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 的分阶段)。
2026-10-06 09:03:35 +08:00

245 lines
8.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}