- 加载器/校验:目录式 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 的分阶段)。
276 lines
9.4 KiB
Go
276 lines
9.4 KiB
Go
package skills
|
||
|
||
import (
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
|
||
"gopkg.in/yaml.v3"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||
)
|
||
|
||
// 技能定义的磁盘文件名(目录式)。目录式优先于单文件式(design/09 §6.1)。
|
||
const skillDefinitionFile = "skill.yaml"
|
||
|
||
// 目录式技能除 skill.yaml 之外的文件(支持文件)不由加载器读入 —— 它们只作
|
||
// 只读引用素材,由调用方按 Loaded.Dir 解析(design/09 §6.5)。
|
||
|
||
// Loader 从 YAML 加载技能定义:单流(Load)、单文件(LoadFile)、或
|
||
// configs/skills 目录(LoadDir / LoadDirDetailed)。它只读盘、不落库:需要
|
||
// 持久化走 Store,需要发布走 Register。
|
||
type Loader struct {
|
||
log *slog.Logger
|
||
}
|
||
|
||
// NewLoader 构造 Loader。log 可以为 nil。
|
||
func NewLoader(log *slog.Logger) *Loader {
|
||
return &Loader{log: sourcedLog(log)}
|
||
}
|
||
|
||
// skillYAML 是技能定义的磁盘 YAML 形状(design/09 §6.2 定稿字段)。
|
||
type skillYAML struct {
|
||
Name string `yaml:"name"`
|
||
Description string `yaml:"description"`
|
||
Prompt string `yaml:"prompt"`
|
||
Steps []yamlStep `yaml:"steps"`
|
||
Refs []yamlRef `yaml:"refs"`
|
||
Guardrails []yamlGuardrail `yaml:"guardrails"`
|
||
|
||
// 以下为 design/09 §6.2 新增字段。字符串键一律小写连字符,对齐 Agent Skills
|
||
// 的既有惯例。枚举先按字符串收下,再经 sdk.Parse* 校验(非法值直接拒)。
|
||
Version int `yaml:"version"`
|
||
WhenToUse string `yaml:"when-to-use"`
|
||
Invocation string `yaml:"invocation"`
|
||
Triggers []string `yaml:"triggers"`
|
||
Paths []string `yaml:"paths"`
|
||
AllowedTools []string `yaml:"allowed-tools"`
|
||
DisallowedTools []string `yaml:"disallowed-tools"`
|
||
Context string `yaml:"context"`
|
||
Agent string `yaml:"agent"`
|
||
Model string `yaml:"model"`
|
||
Effort string `yaml:"effort"`
|
||
Metadata map[string]string `yaml:"metadata"`
|
||
}
|
||
|
||
type yamlStep struct {
|
||
Name string `yaml:"name"`
|
||
Prompt string `yaml:"prompt"`
|
||
Depends []string `yaml:"depends"`
|
||
}
|
||
|
||
type yamlRef struct {
|
||
Name string `yaml:"name"`
|
||
Type string `yaml:"type"`
|
||
Value string `yaml:"value"`
|
||
Optional bool `yaml:"optional"`
|
||
}
|
||
|
||
type yamlGuardrail struct {
|
||
ID string `yaml:"id"`
|
||
Message string `yaml:"message"`
|
||
Enforced bool `yaml:"enforced"`
|
||
}
|
||
|
||
// Loaded 是一份技能定义及其磁盘来源。Dir 让调用方能够解析目录式技能的**支持
|
||
// 文件**(只读引用素材)。
|
||
type Loaded struct {
|
||
// ID 是磁盘上的技能 id(目录名,或单文件去掉扩展名的文件名)。
|
||
ID string
|
||
// Dir 是技能目录(目录式)或定义文件所在目录(单文件式)。
|
||
Dir string
|
||
// File 是实际读到的定义文件路径。
|
||
File string
|
||
// Skill 是解析并校验后的技能。
|
||
Skill *Skill
|
||
}
|
||
|
||
// Load 从 r 读一份技能定义。名字为空返回 ErrInvalidSkill;YAML 解码失败返回
|
||
// 解码错误;结构约束不满足返回 Validate 的可分类错误(ErrStepDependsMissing /
|
||
// ErrStepCycle / ErrToolConflict / ...)。
|
||
func (l *Loader) Load(r io.Reader) (*Skill, error) {
|
||
if r == nil {
|
||
return nil, fmt.Errorf("skills: loader: load: %w", ErrInvalidSkill)
|
||
}
|
||
var doc skillYAML
|
||
if err := yaml.NewDecoder(r).Decode(&doc); err != nil {
|
||
return nil, fmt.Errorf("skills: loader: decode: %w", err)
|
||
}
|
||
skill, err := doc.toSkill()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := Validate(skill); err != nil {
|
||
return nil, fmt.Errorf("skills: loader: %w", err)
|
||
}
|
||
l.log.Info("skill loaded",
|
||
"skill", skill.Name(), "steps", len(skill.Steps()), "invocation", string(skill.Invocation()))
|
||
return skill, nil
|
||
}
|
||
|
||
// LoadFile 加载 path 指向的单份技能定义文件。
|
||
func (l *Loader) LoadFile(path string) (*Skill, error) {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("skills: loader: open %q: %w", path, err)
|
||
}
|
||
defer f.Close()
|
||
skill, err := l.Load(f)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("skills: loader: file %q: %w", path, err)
|
||
}
|
||
return skill, nil
|
||
}
|
||
|
||
// LoadDir 加载 dir 下的全部技能定义,返回解析并校验后的技能(按技能 id 升序)。
|
||
// 任一文件解码/校验失败即整体失败(fail fast),绝不半加载。
|
||
func (l *Loader) LoadDir(dir string) ([]*Skill, error) {
|
||
loaded, err := l.LoadDirDetailed(dir)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]*Skill, 0, len(loaded))
|
||
for _, item := range loaded {
|
||
out = append(out, item.Skill)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// LoadDirDetailed 与 LoadDir 相同,但额外返回每份定义的磁盘来源(id / 目录 /
|
||
// 文件),供调用方解析目录式技能的支持文件。
|
||
//
|
||
// 解析顺序(design/09 §6.1「先目录、后文件」):
|
||
//
|
||
// - 子目录 <id>/ 含 skill.yaml(或 skill.yml)⇒ 目录式,id = 目录名;
|
||
// - <id>.yaml / <id>.yml ⇒ 单文件式,id = 去扩展名的文件名;
|
||
// - 同一 id 两者都在 ⇒ **目录式胜出**,并记一条 WARN(不静默覆盖);
|
||
// - 子目录里没有 skill.yaml ⇒ 跳过(它可能是支持文件目录);
|
||
// - 其它文件(含 . 开头的隐藏文件)⇒ 跳过。
|
||
func (l *Loader) LoadDirDetailed(dir string) ([]Loaded, error) {
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("skills: loader: read dir %q: %w", dir, err)
|
||
}
|
||
byDir, byFile := scanSkillEntries(dir, entries)
|
||
ids := make([]string, 0, len(byDir)+len(byFile))
|
||
for id := range byDir {
|
||
ids = append(ids, id)
|
||
}
|
||
for id := range byFile {
|
||
if _, dup := byDir[id]; !dup {
|
||
ids = append(ids, id)
|
||
}
|
||
}
|
||
sort.Strings(ids)
|
||
|
||
out := make([]Loaded, 0, len(ids))
|
||
for _, id := range ids {
|
||
path, fromDir := byDir[id]
|
||
if !fromDir {
|
||
path = byFile[id]
|
||
} else if filePath, both := byFile[id]; both {
|
||
l.log.Warn("skill defined as both directory and file; directory wins",
|
||
"skill", id, "dir_file", path, "file", filePath)
|
||
}
|
||
skill, err := l.LoadFile(path)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("skills: loader: dir %q: %w", dir, err)
|
||
}
|
||
out = append(out, Loaded{ID: id, Dir: filepath.Dir(path), File: path, Skill: skill})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// scanSkillEntries 把目录扫成两张表:目录式技能的 id→skill.yaml 路径,单文件式
|
||
// 技能的 id→文件路径。
|
||
func scanSkillEntries(dir string, entries []os.DirEntry) (byDir, byFile map[string]string) {
|
||
byDir = make(map[string]string)
|
||
byFile = make(map[string]string)
|
||
for _, e := range entries {
|
||
name := e.Name()
|
||
if e.IsDir() {
|
||
if path := definitionInDir(filepath.Join(dir, name)); path != "" {
|
||
byDir[name] = path
|
||
}
|
||
continue
|
||
}
|
||
if e.Type()&os.ModeSymlink != 0 {
|
||
// 符号链接不追(避免把目录外的定义悄悄引进来);如目标是普通文件,
|
||
// ReadDir 的 Type 会带 ModeSymlink,这里一律跳过并留给调用方显式加载。
|
||
continue
|
||
}
|
||
if strings.HasPrefix(name, ".") || !isYAML(name) {
|
||
continue
|
||
}
|
||
byFile[strings.TrimSuffix(name, filepath.Ext(name))] = filepath.Join(dir, name)
|
||
}
|
||
return byDir, byFile
|
||
}
|
||
|
||
// definitionInDir 返回子目录里的定义文件路径(skill.yaml 优先,其次 skill.yml);
|
||
// 都没有则返回空串。
|
||
func definitionInDir(dir string) string {
|
||
primary := filepath.Join(dir, skillDefinitionFile)
|
||
if info, err := os.Stat(primary); err == nil && info.Mode().IsRegular() {
|
||
return primary
|
||
}
|
||
alt := filepath.Join(dir, strings.TrimSuffix(skillDefinitionFile, ".yaml")+".yml")
|
||
if info, err := os.Stat(alt); err == nil && info.Mode().IsRegular() {
|
||
return alt
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// toSkill 把 YAML 文档转成 Skill:枚举经 sdk.Parse* 校验(非法值直接拒),
|
||
// 其余字段原样搬运;结构约束由 Validate 负责。
|
||
func (d skillYAML) toSkill() (*Skill, error) {
|
||
invocation, err := sdk.ParseInvocation(d.Invocation)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("skills: loader: %q: %w", d.Name, err)
|
||
}
|
||
contextMode, err := sdk.ParseContextMode(d.Context)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("skills: loader: %q: %w", d.Name, err)
|
||
}
|
||
steps := make([]sdk.Step, 0, len(d.Steps))
|
||
for _, st := range d.Steps {
|
||
steps = append(steps, sdk.Step{Name: st.Name, Prompt: st.Prompt, Depends: st.Depends})
|
||
}
|
||
refs := make([]sdk.Ref, 0, len(d.Refs))
|
||
for _, rf := range d.Refs {
|
||
refs = append(refs, sdk.Ref{Name: rf.Name, Type: rf.Type, Value: rf.Value, Optional: rf.Optional})
|
||
}
|
||
guardrails := make([]sdk.Guardrail, 0, len(d.Guardrails))
|
||
for _, g := range d.Guardrails {
|
||
guardrails = append(guardrails, sdk.Guardrail{ID: g.ID, Message: g.Message, Enforced: g.Enforced})
|
||
}
|
||
skill, err := New(d.Name, d.Description, d.Prompt, steps, refs, guardrails, WithSpec(Spec{
|
||
Version: d.Version,
|
||
WhenToUse: d.WhenToUse,
|
||
Invocation: invocation,
|
||
Triggers: d.Triggers,
|
||
Paths: d.Paths,
|
||
AllowedTools: d.AllowedTools,
|
||
DisallowedTools: d.DisallowedTools,
|
||
Context: contextMode,
|
||
Agent: d.Agent,
|
||
Model: d.Model,
|
||
Effort: d.Effort,
|
||
Metadata: d.Metadata,
|
||
}))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("skills: loader: invalid skill %q: %w", d.Name, err)
|
||
}
|
||
return skill, nil
|
||
}
|
||
|
||
// isYAML 报告文件名是否是 YAML(.yaml/.yml,大小写不敏感)。
|
||
func isYAML(name string) bool {
|
||
lower := strings.ToLower(name)
|
||
return strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".yml")
|
||
}
|