From e1fac5864df08e6a85a4523e97e12026c3a7596a Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 7 Oct 2026 13:51:10 +0000 Subject: [PATCH] Validate skills against the open Agent Skills spec (N2 WP4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: the completion doc's N2 requires skills validated per the open spec; today frontmatter was a description-only line hack with silent skips, no name field, and no error surface. Evidence: GOWORK=off go vet + go test -count=1 ./... green; parser units cover all field forms and reject malformed shapes with located errors; the validation matrix covers kebab-case/name-equals-dir/ required description/limits; Store.Diagnostics surfaces per-skill reasons and Load returns the located validation error for same-named invalid skills; existing fixtures updated to spec form. Impact: stricter by design — skills without name/description or with unknown frontmatter keys now produce located errors instead of silently loading or silently skipping; ValidateSkillDocument is exported so the product plugin/seed paths share the same validator. Refs: Agent-System-Documents#42 (N2 plan WP4) --- adapters/native/exec_tools_test.go | 4 +- runtime/resources/resources_test.go | 2 +- runtime/skills/frontmatter.go | 184 ++++++++++++++++++++++++++++ runtime/skills/frontmatter_test.go | 110 +++++++++++++++++ runtime/skills/skills.go | 93 +++++++------- runtime/skills/skills_test.go | 28 +++-- 6 files changed, 360 insertions(+), 61 deletions(-) create mode 100644 runtime/skills/frontmatter.go create mode 100644 runtime/skills/frontmatter_test.go diff --git a/adapters/native/exec_tools_test.go b/adapters/native/exec_tools_test.go index 9536072..5a8a015 100644 --- a/adapters/native/exec_tools_test.go +++ b/adapters/native/exec_tools_test.go @@ -260,7 +260,7 @@ func TestBackendSkillsIndexAndLoader(t *testing.T) { if err := os.MkdirAll(skillDir, 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\ndescription: 生成文档交付物\n---\n\n配方正文"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: docx-author\ndescription: 生成文档交付物\n---\n\n配方正文"), 0o644); err != nil { t.Fatal(err) } stream, c := captureStream( @@ -347,7 +347,7 @@ func TestBackendWorkspaceSkillsRootAutoMerged(t *testing.T) { if err := os.MkdirAll(wsSkill, 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(wsSkill, "SKILL.md"), []byte("---\ndescription: 工作区技能\n---\n正文"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(wsSkill, "SKILL.md"), []byte("---\nname: ws-skill\ndescription: 工作区技能\n---\n正文"), 0o644); err != nil { t.Fatal(err) } res, err := b.Execute(context.Background(), exec.Request{ diff --git a/runtime/resources/resources_test.go b/runtime/resources/resources_test.go index 7080749..1ca6fbf 100644 --- a/runtime/resources/resources_test.go +++ b/runtime/resources/resources_test.go @@ -61,7 +61,7 @@ func TestPiAliasesFilterAndSkillCommand(t *testing.T) { if err := os.MkdirAll(skillDir, 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("be thorough"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: review\ndescription: 评审技能\n---\n\nbe thorough"), 0o644); err != nil { t.Fatal(err) } store := skills.NewStore(dir) diff --git a/runtime/skills/frontmatter.go b/runtime/skills/frontmatter.go new file mode 100644 index 0000000..7ae1ae4 --- /dev/null +++ b/runtime/skills/frontmatter.go @@ -0,0 +1,184 @@ +package skills + +import ( + "fmt" + "strings" +) + +// frontmatter.go — SKILL.md frontmatter 的开放规范校验(N2 WP4)。 +// 坚持不引入 YAML 依赖(skills.go 包注释的既有约束):手写严格子集, +// 只支持规范字段形态——标量、metadata 字符串映射、allowed-tools 空格 +// 分隔列表;形态不合法即错误,不猜测。 + +// 已知键白名单(agentskills.io 开放规范);未知键带字段定位拒绝。 +var knownFrontmatterKeys = map[string]bool{ + "name": true, "description": true, "license": true, + "compatibility": true, "metadata": true, "allowed-tools": true, +} + +const ( + maxNameRunes = 64 + maxDescription = 1024 // 规范上限(替换旧的 200 截断) + maxCompatibility = 500 + maxMetadataEntries = 32 + maxAllowedTools = 64 +) + +// ValidationError 是一条带字段定位的技能校验错误。 +type ValidationError struct { + Path string // SKILL.md 路径 + Field string // frontmatter 键("name"、"metadata.author"…);整体错误用 "-" + Msg string +} + +func (e ValidationError) Error() string { + return fmt.Sprintf("%s: frontmatter %s: %s", e.Path, e.Field, e.Msg) +} + +// Frontmatter 是解析+校验后的 SKILL.md 头。 +type Frontmatter struct { + Name string + Description string + License string + Compatibility string + Metadata map[string]string + AllowedTools []string +} + +// parseFrontmatter 解析 frontmatter 子集。无 frontmatter 时返回 nil(由 +// 调用方按「name/description 必填」判错)。data 是完整文件内容。 +func parseFrontmatter(path, text string) (*Frontmatter, string, error) { + fm := &Frontmatter{Metadata: map[string]string{}} + lines := strings.Split(text, "\n") + if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { + return fm, text, nil + } + end := -1 + for i := 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "---" { + end = i + break + } + } + if end < 0 { + return nil, "", ValidationError{Path: path, Field: "-", Msg: "frontmatter 起始 --- 后找不到结束 ---"} + } + body := strings.Join(lines[end+1:], "\n") + for i := 1; i < end; i++ { + line := lines[i] + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + // metadata 的缩进映射行在父键循环里处理。 + key, value, found := strings.Cut(trimmed, ":") + if !found { + return nil, "", ValidationError{Path: path, Field: "-", Msg: fmt.Sprintf("第 %d 行不是 key: value 形态", i)} + } + key = strings.TrimSpace(key) + if !knownFrontmatterKeys[key] { + return nil, "", ValidationError{Path: path, Field: key, Msg: "未知 frontmatter 键(开放规范白名单:name/description/license/compatibility/metadata/allowed-tools)"} + } + value = strings.TrimSpace(value) + switch key { + case "metadata": + if value != "" { + return nil, "", ValidationError{Path: path, Field: "metadata", Msg: "metadata 必须是缩进映射,不接受行内值"} + } + j := i + 1 + for ; j < end; j++ { + sub := lines[j] + if strings.TrimSpace(sub) == "" { + continue + } + if !strings.HasPrefix(sub, " ") && !strings.HasPrefix(sub, "\t") { + break + } + sk, sv, ok := strings.Cut(strings.TrimSpace(sub), ":") + if !ok || strings.TrimSpace(sk) == "" { + return nil, "", ValidationError{Path: path, Field: "metadata", Msg: fmt.Sprintf("第 %d 行不是 key: value 形态", j)} + } + if len(fm.Metadata) >= maxMetadataEntries { + return nil, "", ValidationError{Path: path, Field: "metadata." + strings.TrimSpace(sk), Msg: fmt.Sprintf("metadata 超过 %d 条上限", maxMetadataEntries)} + } + fm.Metadata[strings.TrimSpace(sk)] = strings.TrimSpace(sv) + } + i = j - 1 + case "name": + fm.Name = value + case "description": + fm.Description = value + case "license": + fm.License = value + case "compatibility": + fm.Compatibility = value + case "allowed-tools": + if value == "" { + return nil, "", ValidationError{Path: path, Field: "allowed-tools", Msg: "allowed-tools 为空(应为空格分隔的工具名列表)"} + } + for _, t := range strings.Fields(value) { + fm.AllowedTools = append(fm.AllowedTools, t) + } + if len(fm.AllowedTools) > maxAllowedTools { + return nil, "", ValidationError{Path: path, Field: "allowed-tools", Msg: fmt.Sprintf("allowed-tools 超过 %d 条上限", maxAllowedTools)} + } + } + } + return fm, body, nil +} + +// validateFrontmatter 按开放规范校验(dirName 是目录名,name 须与之相等)。 +func validateFrontmatter(path, dirName string, fm *Frontmatter) error { + if fm.Name == "" { + return ValidationError{Path: path, Field: "name", Msg: "name 必填(开放规范)"} + } + if !validSkillName(fm.Name) { + return ValidationError{Path: path, Field: "name", Msg: fmt.Sprintf("name %q 不是 kebab-case(小写字母数字与单连字符,1-64 字符)", fm.Name)} + } + if fm.Name != dirName { + return ValidationError{Path: path, Field: "name", Msg: fmt.Sprintf("name %q 与目录名 %q 不一致(规范要求同名)", fm.Name, dirName)} + } + if strings.TrimSpace(fm.Description) == "" { + return ValidationError{Path: path, Field: "description", Msg: "description 必填且非空(开放规范)"} + } + if len([]rune(fm.Description)) > maxDescription { + return ValidationError{Path: path, Field: "description", Msg: fmt.Sprintf("description 超过 %d 字符上限", maxDescription)} + } + if len([]rune(fm.Compatibility)) > maxCompatibility { + return ValidationError{Path: path, Field: "compatibility", Msg: fmt.Sprintf("compatibility 超过 %d 字符上限", maxCompatibility)} + } + return nil +} + +// validSkillName 是开放规范的 kebab-case:小写字母/数字段,段间单连字符, +// 无首尾/连续连字符,1-64 字符。 +func validSkillName(s string) bool { + if s == "" || len(s) > maxNameRunes { + return false + } + if strings.HasPrefix(s, "-") || strings.HasSuffix(s, "-") || strings.Contains(s, "--") { + return false + } + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-': + default: + return false + } + } + return true +} + +// ValidateSkillDocument 解析并校验一份 SKILL.md(供组合根以外的消费方 +// 复用同一校验器:插件技能装载、种子写入前置检查)。dirName 是技能目录名 +// (name 必须与之相等);path 只用于错误定位。 +func ValidateSkillDocument(dirName, path string, data []byte) error { + if len(data) > maxBodyBytes { + data = data[:maxBodyBytes] + } + fm, _, err := parseFrontmatter(path, string(data)) + if err != nil { + return err + } + return validateFrontmatter(path, dirName, fm) +} diff --git a/runtime/skills/frontmatter_test.go b/runtime/skills/frontmatter_test.go new file mode 100644 index 0000000..a93e9b4 --- /dev/null +++ b/runtime/skills/frontmatter_test.go @@ -0,0 +1,110 @@ +package skills + +import ( + "strings" + "testing" +) + +// ---- N2 WP4:开放规范校验矩阵 ---- + +func TestParseFrontmatterAllFields(t *testing.T) { + text := "---\n" + + "name: docx-author\n" + + "description: 生成 Word\n" + + "license: Apache-2.0\n" + + "compatibility: 需要 python-docx\n" + + "metadata:\n" + + " author: 交付组\n" + + " version: \"1.0\"\n" + + "allowed-tools: read_file write_file\n" + + "---\n\n正文" + fm, body, err := parseFrontmatter("x/SKILL.md", text) + if err != nil { + t.Fatal(err) + } + if fm.Name != "docx-author" || fm.Description != "生成 Word" || fm.License != "Apache-2.0" || + fm.Compatibility != "需要 python-docx" || body == "" || !strings.Contains(body, "正文") { + t.Fatalf("%+v body=%q", fm, body) + } + if fm.Metadata["author"] != "交付组" || fm.Metadata["version"] != "\"1.0\"" { + t.Fatalf("metadata: %+v", fm.Metadata) + } + if len(fm.AllowedTools) != 2 || fm.AllowedTools[0] != "read_file" || fm.AllowedTools[1] != "write_file" { + t.Fatalf("allowed-tools: %+v", fm.AllowedTools) + } +} + +func TestValidateFrontmatterMatrix(t *testing.T) { + mk := func(mutate func(*Frontmatter)) *Frontmatter { + fm := &Frontmatter{Name: "good-name", Description: "d", Metadata: map[string]string{}} + mutate(fm) + return fm + } + cases := []struct { + name string + fm *Frontmatter + dir string + wantKey string + }{ + {"缺 name", mk(func(f *Frontmatter) { f.Name = "" }), "good-name", "name"}, + {"name 大写", mk(func(f *Frontmatter) { f.Name = "Bad-Name" }), "good-name", "name"}, + {"name 下划线", mk(func(f *Frontmatter) { f.Name = "bad_name" }), "good-name", "name"}, + {"name 连续连字符", mk(func(f *Frontmatter) { f.Name = "bad--name" }), "good-name", "name"}, + {"name 尾连字符", mk(func(f *Frontmatter) { f.Name = "bad-" }), "good-name", "name"}, + {"name≠目录", mk(func(f *Frontmatter) {}), "other-dir", "name"}, + {"缺 description", mk(func(f *Frontmatter) { f.Description = " " }), "good-name", "description"}, + {"description 超长", mk(func(f *Frontmatter) { f.Description = strings.Repeat("长", 1025) }), "good-name", "description"}, + {"compatibility 超长", mk(func(f *Frontmatter) { f.Compatibility = strings.Repeat("长", 501) }), "good-name", "compatibility"}, + } + for _, tc := range cases { + err := validateFrontmatter("p/SKILL.md", tc.dir, tc.fm) + ve, ok := err.(ValidationError) + if err == nil || !ok || ve.Field != tc.wantKey { + t.Fatalf("%s: 期望 %s 字段错误,得到 %v", tc.name, tc.wantKey, err) + } + if !strings.Contains(ve.Error(), "p/SKILL.md") { + t.Fatalf("%s: 错误必须带路径:%v", tc.name, ve) + } + } + // 合法形态通过。 + if err := validateFrontmatter("p/SKILL.md", "good-name", mk(func(f *Frontmatter) {})); err != nil { + t.Fatalf("合法形态被误拒:%v", err) + } +} + +func TestParseFrontmatterRejectsBadShapes(t *testing.T) { + cases := map[string]string{ + "未知键": "---\nname: a\ndescription: d\nbogus: x\n---\n", + "非 kv 行": "---\nname: a\n这是垃圾\n---\n", + "metadata 行内值": "---\nname: a\ndescription: d\nmetadata: {a: b}\n---\n", + "allowed-tools 空": "---\nname: a\ndescription: d\nallowed-tools:\n---\n", + "无结束围栏": "---\nname: a\n", + } + for name, text := range cases { + if _, _, err := parseFrontmatter("p/SKILL.md", text); err == nil { + t.Fatalf("%s:应拒绝", name) + } else if ve, ok := err.(ValidationError); !ok || ve.Msg == "" { + t.Fatalf("%s:错误必须带定位:%v", name, err) + } + } +} + +func TestStoreDiagnosticsSurface(t *testing.T) { + root := t.TempDir() + writeSkill(t, root, "good", "x") + writeRawSkill(t, root, "bad-skill", "---\nname: bad-skill\n---\n缺 description") + store := NewStore(root) + list := store.List() + if len(list) != 1 { + t.Fatalf("good only: %d", len(list)) + } + diags := store.Diagnostics() + if len(diags) != 1 || diags[0].Field != "description" { + t.Fatalf("diagnostics: %+v", diags) + } + // Load 同名返回诊断原文(含路径与字段)。 + _, err := store.Load("bad-skill") + if err == nil || !strings.Contains(err.Error(), "description") { + t.Fatalf("load error: %v", err) + } +} diff --git a/runtime/skills/skills.go b/runtime/skills/skills.go index d2158e9..08f29c5 100644 --- a/runtime/skills/skills.go +++ b/runtime/skills/skills.go @@ -1,8 +1,10 @@ // Package skills 实现 SKILL.md 技能的加载与按需展开。 // -// 目录约定://SKILL.md,正文前可选 frontmatter -//(--- 包围,行前缀 description:,不引入 YAML 依赖)。 -// 目录名是技能名的权威来源(避免目录与 frontmatter 漂移)。 +// 目录约定://SKILL.md,frontmatter 按开放规范校验 +//(agentskills.io;name 必填且与目录同名、description 必填;已知键白名单; +// 不引入 YAML 依赖,用 frontmatter.go 的严格子集解析)。 +// 校验失败不静默:记入 Store.Diagnostics(),Load 同名技能 fail-closed +// 带字段定位错误。 // // 信任边界:技能正文进入模型上下文,属提示注入面——只加载组合根显式 // 配置的根目录与工作区 .agent/skills/;正文与索引总量受限。 @@ -22,7 +24,6 @@ import ( const ( maxSkillsPerRoot = 100 maxBodyBytes = 64 << 10 - maxDescription = 200 ) // Skill 是一个可加载技能。 @@ -31,12 +32,15 @@ type Skill struct { Description string Body string Source string + // Frontmatter 是校验后的头(License/Compatibility/Metadata/AllowedTools)。 + Frontmatter Frontmatter } // Store 扫描若干根目录并按名加载技能。 type Store struct { - roots []string - cache map[string]Skill + roots []string + cache map[string]Skill + diagnostics []ValidationError } func NewStore(roots ...string) *Store { @@ -49,6 +53,14 @@ func NewStore(roots ...string) *Store { return &Store{roots: clean, cache: nil} } +// Diagnostics 返回最近一次 List 的校验诊断(不合规技能的逐条原因)。 +// 加载器不因单技能坏而中断索引,但原因必须可查(开放规范:错误上浮)。 +func (s *Store) Diagnostics() []ValidationError { + out := make([]ValidationError, len(s.diagnostics)) + copy(out, s.diagnostics) + return out +} + // Roots 返回配置的根目录(只读副本)。 func (s *Store) Roots() []string { out := make([]string, len(s.roots)) @@ -58,6 +70,7 @@ func (s *Store) Roots() []string { // List 扫描全部根目录(同名技能先配置者优先),返回索引。 func (s *Store) List() []Skill { + s.diagnostics = nil cache := map[string]Skill{} var out []Skill for _, root := range s.roots { @@ -77,7 +90,13 @@ func (s *Store) List() []Skill { } skill, err := parse(e.Name(), path, data) if err != nil { - continue // 单个坏技能跳过,不拖垮整个索引 + // 单个坏技能跳过(不拖垮索引),但原因记入诊断(错误上浮)。 + if ve, ok := err.(ValidationError); ok { + s.diagnostics = append(s.diagnostics, ve) + } else { + s.diagnostics = append(s.diagnostics, ValidationError{Path: path, Field: "-", Msg: err.Error()}) + } + continue } if _, dup := cache[skill.Name]; dup { continue @@ -91,7 +110,8 @@ func (s *Store) List() []Skill { return out } -// Load 按名加载技能正文(fail-closed:未知名返回错误,不猜测近似名)。 +// Load 按名加载技能正文(fail-closed:未知名返回错误,不猜测近似名; +// 同名但校验失败时返回带字段定位的校验错误而非笼统 unknown)。 func (s *Store) Load(name string) (Skill, error) { if s.cache == nil { s.List() @@ -104,6 +124,11 @@ func (s *Store) Load(name string) (Skill, error) { if skill, ok := s.cache[name]; ok { return skill, nil } + for _, d := range s.diagnostics { + if strings.Contains(d.Path, string(filepath.Separator)+name+string(filepath.Separator)) { + return Skill{}, d + } + } return Skill{}, fmt.Errorf("unknown skill %q (not installed in any configured root)", name) } @@ -136,53 +161,23 @@ func (s *Store) ExpandCommands(text string) (string, error) { return b.String(), nil } -// parse 解析一份 SKILL.md:目录名权威,frontmatter 可选。 +// parse 解析并校验一份 SKILL.md(开放规范:name/description 必填、 +// name 与目录同名、已知键白名单、错误带字段定位)。 func parse(dirName, path string, data []byte) (Skill, error) { - if !validSkillName(dirName) { - return Skill{}, fmt.Errorf("invalid skill dir name %q", dirName) - } if len(data) > maxBodyBytes { data = data[:maxBodyBytes] } - text := string(data) - desc := "" - body := text - lines := strings.Split(text, "\n") - if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" { - end := -1 - for i := 1; i < len(lines); i++ { - if strings.TrimSpace(lines[i]) == "---" { - end = i - break - } - } - if end > 0 { - for _, line := range lines[1:end] { - if v, ok := strings.CutPrefix(strings.TrimSpace(line), "description:"); ok { - desc = strings.TrimSpace(v) - } - } - body = strings.Join(lines[end+1:], "\n") - } + fm, body, err := parseFrontmatter(path, string(data)) + if err != nil { + return Skill{}, err } - if len(desc) > maxDescription { - desc = desc[:maxDescription] + "…" + if err := validateFrontmatter(path, dirName, fm); err != nil { + return Skill{}, err } - return Skill{Name: dirName, Description: desc, Body: body, Source: path}, nil -} - -func validSkillName(s string) bool { - if s == "" || len(s) > 64 { - return false - } - for _, r := range s { - switch { - case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': - default: - return false - } - } - return true + return Skill{ + Name: fm.Name, Description: fm.Description, Body: body, + Source: path, Frontmatter: *fm, + }, nil } // ---- load_skill 工具 ---- diff --git a/runtime/skills/skills_test.go b/runtime/skills/skills_test.go index 8b90451..6d85533 100644 --- a/runtime/skills/skills_test.go +++ b/runtime/skills/skills_test.go @@ -11,46 +11,56 @@ import ( "agent-native/runtime/tools" ) +// writeSkill 写一份符合开放规范的 SKILL.md(name/description 齐备)。 func writeSkill(t *testing.T, root, name, body string) { + t.Helper() + writeRawSkill(t, root, name, "---\nname: "+name+"\ndescription: 测试技能 "+name+"\n---\n\n"+body) +} + +func writeRawSkill(t *testing.T, root, name, text string) { t.Helper() dir := filepath.Join(root, name) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(text), 0o644); err != nil { t.Fatal(err) } } func TestStoreListAndLoad(t *testing.T) { root := t.TempDir() - writeSkill(t, root, "docx-author", "---\ndescription: 生成 docx 交付物\n---\n\n## 步骤\n1. 用 run_python") + writeSkill(t, root, "docx-author", "## 步骤\n1. 用 run_python") writeSkill(t, root, "xlsx-author", "没有 frontmatter 的技能正文") writeSkill(t, root, "bad name", "目录名不合法") //nolint + // 无 frontmatter(缺 name/description):开放规范下不合规,进诊断不进索引。 + writeRawSkill(t, root, "nofm", "没有任何头部") store := NewStore(root) list := store.List() if len(list) != 2 { - t.Fatalf("want 2 valid skills (bad dir skipped), got %d: %+v", len(list), list) + t.Fatalf("want 2 valid skills (bad dir + nofm skipped), got %d: %+v", len(list), list) } byName := map[string]Skill{} for _, s := range list { byName[s.Name] = s } - if byName["docx-author"].Description != "生成 docx 交付物" { + if byName["docx-author"].Description != "测试技能 docx-author" { t.Fatalf("frontmatter description wrong: %+v", byName["docx-author"]) } if !strings.Contains(byName["docx-author"].Body, "run_python") { t.Fatalf("body must exclude frontmatter, got %q", byName["docx-author"].Body) } - if byName["xlsx-author"].Description != "" || !strings.Contains(byName["xlsx-author"].Body, "没有 frontmatter") { - t.Fatalf("no-frontmatter skill wrong: %+v", byName["xlsx-author"]) - } skill, err := store.Load("docx-author") if err != nil || !strings.Contains(skill.Body, "步骤") { t.Fatalf("load failed: %v %+v", err, skill) } + // 同名不合规:Load 返回带字段定位的校验错误(不是笼统 unknown)。 + _, err = store.Load("nofm") + if err == nil || !strings.Contains(err.Error(), "name") { + t.Fatalf("invalid skill must surface its validation error, got %v", err) + } _, err = store.Load("nope") if err == nil || !strings.Contains(err.Error(), "unknown skill") { t.Fatalf("unknown skill must fail-closed, got %v", err) @@ -68,14 +78,14 @@ func TestStoreRootPriorityAndMissingRoot(t *testing.T) { t.Fatalf("want 2 skills, got %d", len(list)) } skill, _ := store.Load("dup") - if skill.Body != "from r1" { + if !strings.Contains(skill.Body, "from r1") { t.Fatalf("earlier root must win, got %q", skill.Body) } } func TestRegisterLoaderTool(t *testing.T) { root := t.TempDir() - writeSkill(t, root, "docx-author", "---\ndescription: d\n---\n正文指令") + writeSkill(t, root, "docx-author", "正文指令") reg := tools.NewRegistry(t.TempDir()) RegisterLoader(reg, NewStore(root)) var found bool