Why: Allow consumers to download an immutable module revision without nested source checkouts or local path replacements. Evidence: SELF package tests and the prior canonical-path rejection are recorded in the Dev dependency-workspace task card; remote consumption is replayed against the committed candidate. Impact: Change the module identity and its Go imports while preserving API behavior. Existing historical submodule revisions remain buildable. Refs: Versioned dependencies U1/U3; feature/dependency-workspace
217 lines
6.1 KiB
Go
217 lines
6.1 KiB
Go
// Package skills 实现 SKILL.md 技能的加载与按需展开。
|
||
//
|
||
// 目录约定:<root>/<skill-name>/SKILL.md,frontmatter 按开放规范校验
|
||
//(agentskills.io;name 必填且与目录同名、description 必填;已知键白名单;
|
||
// 不引入 YAML 依赖,用 frontmatter.go 的严格子集解析)。
|
||
// 校验失败不静默:记入 Store.Diagnostics(),Load 同名技能 fail-closed
|
||
// 带字段定位错误。
|
||
//
|
||
// 信任边界:技能正文进入模型上下文,属提示注入面——只加载组合根显式
|
||
// 配置的根目录与工作区 .agent/skills/;正文与索引总量受限。
|
||
package skills
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"git-ssh.u1680832.nyat.app/weichen/Agent-Native.git/runtime/tools"
|
||
)
|
||
|
||
const (
|
||
maxSkillsPerRoot = 100
|
||
maxBodyBytes = 64 << 10
|
||
)
|
||
|
||
// Skill 是一个可加载技能。
|
||
type Skill struct {
|
||
Name string
|
||
Description string
|
||
Body string
|
||
Source string
|
||
// Frontmatter 是校验后的头(License/Compatibility/Metadata/AllowedTools)。
|
||
Frontmatter Frontmatter
|
||
}
|
||
|
||
// Store 扫描若干根目录并按名加载技能。
|
||
type Store struct {
|
||
roots []string
|
||
cache map[string]Skill
|
||
diagnostics []ValidationError
|
||
}
|
||
|
||
func NewStore(roots ...string) *Store {
|
||
clean := make([]string, 0, len(roots))
|
||
for _, r := range roots {
|
||
if strings.TrimSpace(r) != "" {
|
||
clean = append(clean, r)
|
||
}
|
||
}
|
||
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))
|
||
copy(out, s.roots)
|
||
return out
|
||
}
|
||
|
||
// List 扫描全部根目录(同名技能先配置者优先),返回索引。
|
||
func (s *Store) List() []Skill {
|
||
s.diagnostics = nil
|
||
cache := map[string]Skill{}
|
||
var out []Skill
|
||
for _, root := range s.roots {
|
||
entries, err := os.ReadDir(root)
|
||
if err != nil {
|
||
continue // 根不存在:跳过(技能是可选能力)
|
||
}
|
||
count := 0
|
||
for _, e := range entries {
|
||
if !e.IsDir() || count >= maxSkillsPerRoot {
|
||
continue
|
||
}
|
||
path := filepath.Join(root, e.Name(), "SKILL.md")
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
skill, err := parse(e.Name(), path, data)
|
||
if err != nil {
|
||
// 单个坏技能跳过(不拖垮索引),但原因记入诊断(错误上浮)。
|
||
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
|
||
}
|
||
cache[skill.Name] = skill
|
||
out = append(out, skill)
|
||
count++
|
||
}
|
||
}
|
||
s.cache = cache
|
||
return out
|
||
}
|
||
|
||
// Load 按名加载技能正文(fail-closed:未知名返回错误,不猜测近似名;
|
||
// 同名但校验失败时返回带字段定位的校验错误而非笼统 unknown)。
|
||
func (s *Store) Load(name string) (Skill, error) {
|
||
if s.cache == nil {
|
||
s.List()
|
||
}
|
||
if skill, ok := s.cache[name]; ok {
|
||
return skill, nil
|
||
}
|
||
// 缓存可能过期(List 之后目录变化):重扫一遍再判。
|
||
s.List()
|
||
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)
|
||
}
|
||
|
||
// ExpandCommands replaces /skill:name with that skill's body.
|
||
// An unknown name returns an error and leaves the text unchanged.
|
||
func (s *Store) ExpandCommands(text string) (string, error) {
|
||
const marker = "/skill:"
|
||
var b strings.Builder
|
||
rest := text
|
||
for {
|
||
i := strings.Index(rest, marker)
|
||
if i < 0 {
|
||
b.WriteString(rest)
|
||
break
|
||
}
|
||
b.WriteString(rest[:i])
|
||
name := rest[i+len(marker):]
|
||
end := 0
|
||
for end < len(name) && name[end] != ' ' && name[end] != '\n' && name[end] != '\t' {
|
||
end++
|
||
}
|
||
skillName := name[:end]
|
||
skill, err := s.Load(skillName)
|
||
if err != nil {
|
||
return text, err
|
||
}
|
||
b.WriteString(skill.Body)
|
||
rest = name[end:]
|
||
}
|
||
return b.String(), nil
|
||
}
|
||
|
||
// parse 解析并校验一份 SKILL.md(开放规范:name/description 必填、
|
||
// name 与目录同名、已知键白名单、错误带字段定位)。
|
||
func parse(dirName, path string, data []byte) (Skill, error) {
|
||
if len(data) > maxBodyBytes {
|
||
data = data[:maxBodyBytes]
|
||
}
|
||
fm, body, err := parseFrontmatter(path, string(data))
|
||
if err != nil {
|
||
return Skill{}, err
|
||
}
|
||
if err := validateFrontmatter(path, dirName, fm); err != nil {
|
||
return Skill{}, err
|
||
}
|
||
return Skill{
|
||
Name: fm.Name, Description: fm.Description, Body: body,
|
||
Source: path, Frontmatter: *fm,
|
||
}, nil
|
||
}
|
||
|
||
// ---- load_skill 工具 ----
|
||
|
||
type loadSkillTool struct{ store *Store }
|
||
|
||
// RegisterLoader 注册 load_skill 工具(store 无技能时不注册,避免死工具)。
|
||
func RegisterLoader(r *tools.Registry, store *Store) {
|
||
if store == nil || len(store.List()) == 0 {
|
||
return
|
||
}
|
||
r.Register(&loadSkillTool{store: store})
|
||
}
|
||
|
||
func (t *loadSkillTool) Name() string { return "load_skill" }
|
||
func (t *loadSkillTool) Description() string {
|
||
return "Load the full instructions of a skill by name (names are listed in the system prompt skill index)."
|
||
}
|
||
|
||
func (t *loadSkillTool) Parameters() json.RawMessage {
|
||
return json.RawMessage(`{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}}`)
|
||
}
|
||
|
||
func (t *loadSkillTool) Execute(_ context.Context, args json.RawMessage) (tools.Result, error) {
|
||
var obj struct {
|
||
Name string `json:"name"`
|
||
}
|
||
if err := json.Unmarshal(args, &obj); err != nil {
|
||
return tools.Result{}, err
|
||
}
|
||
skill, err := t.store.Load(obj.Name)
|
||
if err != nil {
|
||
return tools.Result{Content: err.Error(), IsError: true}, nil
|
||
}
|
||
return tools.Result{Content: "# skill: " + skill.Name + "\n\n" + skill.Body}, nil
|
||
}
|