Files
Cyrene_v2/internal/capability/skills/loader.go
T

136 lines
3.8 KiB
Go

package skills
import (
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"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.
type Loader struct {
log *slog.Logger
}
// NewLoader builds a Loader. log may be nil.
func NewLoader(log *slog.Logger) *Loader {
return &Loader{log: sourcedLog(log)}
}
// skillYAML is the on-disk YAML shape of a skill.
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"`
}
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"`
}
// Load parses one skill from r. It returns ErrInvalidSkill when the document has
// a blank name.
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
}
l.log.Info("skill loaded", "skill", skill.Name())
return skill, nil
}
// LoadFile loads the single skill YAML file at 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 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.
func (l *Loader) LoadDir(dir string) ([]*Skill, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("skills: loader: read dir %q: %w", dir, 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)
}
return out, nil
}
// toSkill converts the YAML document into a Skill, validating the required name.
func (d skillYAML) toSkill() (*Skill, error) {
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)
if err != nil {
return nil, fmt.Errorf("skills: loader: invalid skill %q: %w", d.Name, err)
}
return skill, nil
}
func isYAML(name string) bool {
lower := strings.ToLower(name)
return strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".yml")
}