Validate skills against the open Agent Skills spec (N2 WP4)
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)
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+44
-49
@@ -1,8 +1,10 @@
|
||||
// Package skills 实现 SKILL.md 技能的加载与按需展开。
|
||||
//
|
||||
// 目录约定:<root>/<skill-name>/SKILL.md,正文前可选 frontmatter
|
||||
//(--- 包围,行前缀 description:,不引入 YAML 依赖)。
|
||||
// 目录名是技能名的权威来源(避免目录与 frontmatter 漂移)。
|
||||
// 目录约定:<root>/<skill-name>/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 工具 ----
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user