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:
AskaEth committed 2026-10-06 09:03:35 +08:00
1 parent 29181245ce
commit f75e7a9c9b
17 files changed
+3067 -164

No files matched your search

+166 -26
View File
@@ -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")