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 的分阶段)。
This commit is contained in:
1 parent
29181245ce
commit
f75e7a9c9b
17 files changed
+3067
-164
No files matched your search
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -13,19 +14,25 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// Loader loads Skill definitions from YAML — a single stream (Load), a file
|
||||
// (LoadFile), or a directory of configs/skills/*.yaml files (LoadDir). It does
|
||||
// not persist; persist via Store or publish via Register.
|
||||
// 技能定义的磁盘文件名(目录式)。目录式优先于单文件式(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 builds a Loader. log may be nil.
|
||||
// NewLoader 构造 Loader。log 可以为 nil。
|
||||
func NewLoader(log *slog.Logger) *Loader {
|
||||
return &Loader{log: sourcedLog(log)}
|
||||
}
|
||||
|
||||
// skillYAML is the on-disk YAML shape of a skill.
|
||||
// skillYAML 是技能定义的磁盘 YAML 形状(design/09 §6.2 定稿字段)。
|
||||
type skillYAML struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
@@ -33,6 +40,21 @@ type skillYAML struct {
|
||||
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 {
|
||||
@@ -54,8 +76,22 @@ type yamlGuardrail struct {
|
||||
Enforced bool `yaml:"enforced"`
|
||||
}
|
||||
|
||||
// Load parses one skill from r. It returns ErrInvalidSkill when the document has
|
||||
// a blank name.
|
||||
// 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)
|
||||
@@ -68,11 +104,15 @@ func (l *Loader) Load(r io.Reader) (*Skill, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.log.Info("skill loaded", "skill", skill.Name())
|
||||
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 loads the single skill YAML file at path.
|
||||
// LoadFile 加载 path 指向的单份技能定义文件。
|
||||
func (l *Loader) LoadFile(path string) (*Skill, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
@@ -86,30 +126,116 @@ func (l *Loader) LoadFile(path string) (*Skill, error) {
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
// LoadDir loads every *.yaml/*.yml skill file in dir, in readdir order. A file
|
||||
// that fails to parse stops the load. Non-YAML files are skipped.
|
||||
// LoadDir 加载 dir 下的全部技能定义,返回解析并校验后的技能(按技能 id 升序)。
|
||||
// 任一文件解码/校验失败即整体失败(fail fast),绝不半加载。
|
||||
func (l *Loader) LoadDir(dir string) ([]*Skill, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
loaded, err := l.LoadDirDetailed(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skills: loader: read dir %q: %w", dir, err)
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*Skill, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !isYAML(e.Name()) {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
skill, err := l.LoadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skills: loader: dir %q: %w", path, err)
|
||||
}
|
||||
out = append(out, skill)
|
||||
out := make([]*Skill, 0, len(loaded))
|
||||
for _, item := range loaded {
|
||||
out = append(out, item.Skill)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// toSkill converts the YAML document into a Skill, validating the required name.
|
||||
// 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})
|
||||
@@ -122,13 +248,27 @@ func (d skillYAML) toSkill() (*Skill, error) {
|
||||
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)
|
||||
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")
|
||||
|
||||
Reference in new issue
Block a user