Files
AskaEth f75e7a9c9b 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 的分阶段)。
2026-10-06 09:03:35 +08:00

276 lines
9.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}