From 915aa8a100ab4cb01a9bc083b3e2b6233c1744bf Mon Sep 17 00:00:00 2001 From: Aska Date: Sun, 6 Sep 2026 06:25:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(capability/skills):=20=E5=8F=AF=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E5=B7=A5=E4=BD=9C=E6=B5=81=E6=8A=80=E8=83=BD=EF=BC=88?= =?UTF-8?q?Skill=20=E6=A8=A1=E5=9E=8B=20+=20Store=20+=20YAML=20Loader=20+?= =?UTF-8?q?=20Runner=20=E5=B1=95=E5=BC=80=20+=20=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/capability/skills/helpers_test.go | 68 ++++++ internal/capability/skills/loader.go | 135 +++++++++++ internal/capability/skills/loader_test.go | 176 +++++++++++++++ internal/capability/skills/register.go | 28 +++ internal/capability/skills/register_test.go | 56 +++++ internal/capability/skills/runner.go | 86 +++++++ internal/capability/skills/runner_test.go | 115 ++++++++++ internal/capability/skills/skill.go | 134 +++++++++++ internal/capability/skills/store.go | 236 ++++++++++++++++++++ internal/capability/skills/store_test.go | 201 +++++++++++++++++ 10 files changed, 1235 insertions(+) create mode 100644 internal/capability/skills/helpers_test.go create mode 100644 internal/capability/skills/loader.go create mode 100644 internal/capability/skills/loader_test.go create mode 100644 internal/capability/skills/register.go create mode 100644 internal/capability/skills/register_test.go create mode 100644 internal/capability/skills/runner.go create mode 100644 internal/capability/skills/runner_test.go create mode 100644 internal/capability/skills/skill.go create mode 100644 internal/capability/skills/store.go create mode 100644 internal/capability/skills/store_test.go diff --git a/internal/capability/skills/helpers_test.go b/internal/capability/skills/helpers_test.go new file mode 100644 index 0000000..31f5a3d --- /dev/null +++ b/internal/capability/skills/helpers_test.go @@ -0,0 +1,68 @@ +package skills + +import ( + "context" + "io" + "log/slog" + "os" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/db" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// testLogger returns a logger that writes nowhere, keeping test output clean. +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// makeSkill builds a deterministic skill populated with one step, one ref, and +// one guardrail, so tests can assert the full round trip. +func makeSkill(t *testing.T, name string) *Skill { + t.Helper() + sk, err := New(name, "desc "+name, "prompt "+name, + []sdk.Step{{Name: "s1", Prompt: "step prompt"}}, + []sdk.Ref{{Name: "r1", Type: "tool", Value: "v1", Optional: false}}, + []sdk.Guardrail{{ID: "g1", Message: "msg", Enforced: true}}, + ) + if err != nil { + t.Fatalf("New(%q): %v", name, err) + } + return sk +} + +// mockSkill is a minimal sdk.Skill used to feed invalid inputs (blank name) and +// to exercise the store's generic sdk.Skill handling. +type mockSkill struct{ name string } + +func (m mockSkill) Name() string { return m.name } +func (m mockSkill) Description() string { return "desc" } +func (m mockSkill) Prompt() string { return "prompt" } +func (m mockSkill) Steps() []sdk.Step { return nil } +func (m mockSkill) Refs() []sdk.Ref { return nil } +func (m mockSkill) Guardrails() []sdk.Guardrail { return nil } + +// newTestStore opens an isolated in-memory SQLite database and builds a Store +// over it. It is cleaned up when the test finishes. +func newTestStore(t *testing.T) *Store { + t.Helper() + ctx := context.Background() + database, err := db.Open(ctx, ":memory:", testLogger()) + if err != nil { + t.Fatalf("db.Open(:memory:): %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + store, err := NewStore(ctx, database, testLogger()) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + return store +} + +// writeFile writes content to path, failing the test on error. +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/internal/capability/skills/loader.go b/internal/capability/skills/loader.go new file mode 100644 index 0000000..f19af64 --- /dev/null +++ b/internal/capability/skills/loader.go @@ -0,0 +1,135 @@ +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") +} diff --git a/internal/capability/skills/loader_test.go b/internal/capability/skills/loader_test.go new file mode 100644 index 0000000..739e668 --- /dev/null +++ b/internal/capability/skills/loader_test.go @@ -0,0 +1,176 @@ +package skills + +import ( + "errors" + "path/filepath" + "strings" + "testing" +) + +const validSkillYAML = ` +name: daily-brief +description: Morning briefing +prompt: You are a briefing assistant. +steps: + - name: collect + prompt: Gather data + depends: [] + - name: summarize + prompt: Summarize the picks + depends: [collect] +refs: + - name: calendar + type: tool + value: calendar-tool + optional: false +guardrails: + - id: no-pii + message: Never output PII + enforced: true +` + +func TestLoader_Load_正常(t *testing.T) { + t.Parallel() + l := NewLoader(testLogger()) + sk, err := l.Load(strings.NewReader(validSkillYAML)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if sk.Name() != "daily-brief" { + t.Fatalf("Name = %q, want daily-brief", sk.Name()) + } + if sk.Description() != "Morning briefing" { + t.Fatalf("Description = %q, want Morning briefing", sk.Description()) + } + if sk.Prompt() != "You are a briefing assistant." { + t.Fatalf("Prompt = %q, want base prompt", sk.Prompt()) + } + steps := sk.Steps() + if len(steps) != 2 { + t.Fatalf("Steps length = %d, want 2", len(steps)) + } + if steps[0].Name != "collect" || steps[0].Prompt != "Gather data" { + t.Fatalf("Steps[0] = %+v, want collect/Gather data", steps[0]) + } + if len(steps[0].Depends) != 0 { + t.Fatalf("Steps[0].Depends = %+v, want empty", steps[0].Depends) + } + if steps[1].Name != "summarize" || len(steps[1].Depends) != 1 || steps[1].Depends[0] != "collect" { + t.Fatalf("Steps[1] = %+v, want summarize depending on collect", steps[1]) + } + refs := sk.Refs() + if len(refs) != 1 || refs[0].Name != "calendar" || refs[0].Type != "tool" || refs[0].Value != "calendar-tool" || refs[0].Optional { + t.Fatalf("Refs = %+v, want calendar/tool/calendar-tool/non-optional", refs) + } + guards := sk.Guardrails() + if len(guards) != 1 || guards[0].ID != "no-pii" || guards[0].Message != "Never output PII" || !guards[0].Enforced { + t.Fatalf("Guardrails = %+v, want no-pii/Never output PII/enforced", guards) + } +} + +func TestLoader_Load_MissingName_异常(t *testing.T) { + t.Parallel() + l := NewLoader(testLogger()) + src := "description: x\nprompt: y\n" + _, err := l.Load(strings.NewReader(src)) + if !errors.Is(err, ErrInvalidSkill) { + t.Fatalf("Load err = %v, want ErrInvalidSkill", err) + } +} + +func TestLoader_Load_InvalidYAML_异常(t *testing.T) { + t.Parallel() + l := NewLoader(testLogger()) + if _, err := l.Load(strings.NewReader("name: [unclosed")); err == nil { + t.Fatalf("expected a YAML decode error, got nil") + } +} + +func TestLoader_Load_NilReader_异常(t *testing.T) { + t.Parallel() + l := NewLoader(testLogger()) + if _, err := l.Load(nil); !errors.Is(err, ErrInvalidSkill) { + t.Fatalf("Load(nil) err = %v, want ErrInvalidSkill", err) + } +} + +func TestLoader_LoadFile_正常(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "skill.yaml") + writeFile(t, path, validSkillYAML) + l := NewLoader(testLogger()) + sk, err := l.LoadFile(path) + if err != nil { + t.Fatalf("LoadFile: %v", err) + } + if sk.Name() != "daily-brief" { + t.Fatalf("Name = %q, want daily-brief", sk.Name()) + } +} + +func TestLoader_LoadFile_Missing_异常(t *testing.T) { + t.Parallel() + l := NewLoader(testLogger()) + if _, err := l.LoadFile(filepath.Join(t.TempDir(), "nope.yaml")); err == nil { + t.Fatalf("expected error for missing file, got nil") + } +} + +func TestLoader_LoadDir_正常(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "a.yaml"), validSkillYAML) + writeFile(t, filepath.Join(dir, "b.yml"), strings.Replace(validSkillYAML, "daily-brief", "code-review", 1)) + writeFile(t, filepath.Join(dir, "notes.txt"), "not a skill") // skipped + + l := NewLoader(testLogger()) + got, err := l.LoadDir(dir) + if err != nil { + t.Fatalf("LoadDir: %v", err) + } + if len(got) != 2 { + t.Fatalf("LoadDir returned %d skills, want 2", len(got)) + } + if got[0].Name() != "daily-brief" || got[1].Name() != "code-review" { + t.Fatalf("LoadDir names = %q,%q, want daily-brief,code-review", got[0].Name(), got[1].Name()) + } +} + +func TestLoader_LoadDir_BadFile_异常(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "bad.yaml"), "name: [broken") + writeFile(t, filepath.Join(dir, "ok.yaml"), validSkillYAML) + + l := NewLoader(testLogger()) + if _, err := l.LoadDir(dir); err == nil { + t.Fatalf("expected error for malformed yaml in dir, got nil") + } +} + +func TestLoader_LoadDir_NotFound_异常(t *testing.T) { + t.Parallel() + l := NewLoader(testLogger()) + if _, err := l.LoadDir(filepath.Join(t.TempDir(), "nothing")); err == nil { + t.Fatalf("expected error for missing dir, got nil") + } +} + +// TestLoader_LoadDir_OnlyYAML confirms non-YAML files are skipped rather than +// treated as skills. +func TestLoader_LoadDir_OnlyYAML_正常(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "one.yaml"), validSkillYAML) + writeFile(t, filepath.Join(dir, "two.yml"), validSkillYAML) + writeFile(t, filepath.Join(dir, "readme.md"), "# docs") + + l := NewLoader(testLogger()) + got, err := l.LoadDir(dir) + if err != nil { + t.Fatalf("LoadDir: %v", err) + } + if len(got) != 2 { + t.Fatalf("LoadDir returned %d skills, want 2", len(got)) + } +} diff --git a/internal/capability/skills/register.go b/internal/capability/skills/register.go new file mode 100644 index 0000000..b4a2124 --- /dev/null +++ b/internal/capability/skills/register.go @@ -0,0 +1,28 @@ +package skills + +import ( + "fmt" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// Register publishes list into reg as KindSkill capabilities so they become +// globally available to every character and mode. It returns ErrNilRegistrar +// when reg is nil, ErrInvalidSkill when a skill is nil, and the registry's error +// (for example registry.ErrDuplicate) on the first failed registration; earlier +// skills in list stay registered. +func Register(reg *registry.Registry, list ...sdk.Skill) error { + if reg == nil { + return ErrNilRegistrar + } + for _, s := range list { + if s == nil { + return fmt.Errorf("skills: register: %w", ErrInvalidSkill) + } + if err := reg.RegisterSkill(s); err != nil { + return fmt.Errorf("skills: register %q: %w", s.Name(), err) + } + } + return nil +} diff --git a/internal/capability/skills/register_test.go b/internal/capability/skills/register_test.go new file mode 100644 index 0000000..031aed8 --- /dev/null +++ b/internal/capability/skills/register_test.go @@ -0,0 +1,56 @@ +package skills + +import ( + "errors" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +func TestRegister_正常(t *testing.T) { + t.Parallel() + reg := registry.New() + if err := Register(reg, makeSkill(t, "a"), makeSkill(t, "b")); err != nil { + t.Fatalf("Register: %v", err) + } + cap, err := reg.Get("a") + if err != nil { + t.Fatalf("Get a: %v", err) + } + if cap.Kind != registry.KindSkill { + t.Fatalf("Kind = %q, want %q", cap.Kind, registry.KindSkill) + } + sk, ok := cap.Value.(sdk.Skill) + if !ok { + t.Fatalf("Value is %T, want sdk.Skill", cap.Value) + } + if sk.Name() != "a" || sk.Description() != "desc a" { + t.Fatalf("registered skill = %q/%q, want a/desc a", sk.Name(), sk.Description()) + } +} + +func TestRegister_NilRegistrar_异常(t *testing.T) { + t.Parallel() + if err := Register(nil, makeSkill(t, "a")); !errors.Is(err, ErrNilRegistrar) { + t.Fatalf("Register(nil) err = %v, want ErrNilRegistrar", err) + } +} + +func TestRegister_NilSkill_异常(t *testing.T) { + t.Parallel() + if err := Register(registry.New(), nil); !errors.Is(err, ErrInvalidSkill) { + t.Fatalf("Register(nil skill) err = %v, want ErrInvalidSkill", err) + } +} + +func TestRegister_Duplicate_异常(t *testing.T) { + t.Parallel() + reg := registry.New() + if err := Register(reg, makeSkill(t, "review")); err != nil { + t.Fatalf("first Register: %v", err) + } + if err := Register(reg, makeSkill(t, "review")); !errors.Is(err, registry.ErrDuplicate) { + t.Fatalf("second Register err = %v, want registry.ErrDuplicate", err) + } +} diff --git a/internal/capability/skills/runner.go b/internal/capability/skills/runner.go new file mode 100644 index 0000000..21a0297 --- /dev/null +++ b/internal/capability/skills/runner.go @@ -0,0 +1,86 @@ +package skills + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// Workflow is a Skill expanded into its executable form: the base system prompt +// plus the ordered steps, references, and guardrails, ready to be handed to the +// loop/agent executor (M2). The Runner only assembles workflows; it does not +// execute commands. +type Workflow struct { + Name string + Description string + Prompt string + Steps []sdk.Step + Refs []sdk.Ref + Guardrails []sdk.Guardrail +} + +// Runner expands skills into workflows. It is the M5 placeholder for the +// skill-execution wrapper: expansion (the real deliverable) is wired now, while +// Run awaits M12/loop integration. +type Runner struct { + reg *registry.Registry + log *slog.Logger +} + +// NewRunner builds a Runner over the given registry, used by ExpandByName to +// look up registered skills. log may be nil. +func NewRunner(reg *registry.Registry, log *slog.Logger) *Runner { + return &Runner{reg: reg, log: sourcedLog(log)} +} + +// Expand converts s into a Workflow, copying the step, ref, and guardrail slices +// so the caller owns the result. It returns ErrInvalidSkill when s is nil or has +// a blank name. +func (r *Runner) Expand(ctx context.Context, s sdk.Skill) (Workflow, error) { + if err := ctx.Err(); err != nil { + return Workflow{}, fmt.Errorf("skills: runner: expand: %w", err) + } + if s == nil || strings.TrimSpace(s.Name()) == "" { + return Workflow{}, fmt.Errorf("skills: runner: expand: %w", ErrInvalidSkill) + } + wf := Workflow{ + Name: s.Name(), + Description: s.Description(), + Prompt: s.Prompt(), + Steps: cloneSteps(s.Steps()), + Refs: cloneRefs(s.Refs()), + Guardrails: cloneGuardrails(s.Guardrails()), + } + r.log.Info("skill expanded", "skill", wf.Name) + return wf, nil +} + +// ExpandByName looks up a skill in the registry by name and expands it. It +// returns ErrNilRegistrar when the runner has no registry, the registry's error +// (registry.ErrNotFound) when the name is absent, and ErrNotFound when the +// registered value is not a Skill. +func (r *Runner) ExpandByName(ctx context.Context, name string) (Workflow, error) { + if r.reg == nil { + return Workflow{}, fmt.Errorf("skills: runner: expand %q: %w", name, ErrNilRegistrar) + } + cap, err := r.reg.Get(name) + if err != nil { + return Workflow{}, fmt.Errorf("skills: runner: expand %q: %w", name, err) + } + skill, ok := cap.Value.(sdk.Skill) + if !ok { + return Workflow{}, fmt.Errorf("skills: runner: expand %q: %w", name, ErrNotFound) + } + return r.Expand(ctx, skill) +} + +// Run is a placeholder for executing a registered skill end to end. Execution +// lands in the loop/agent executor (M2) and the full wiring (M12); it always +// returns ErrRunPending. +func (r *Runner) Run(_ context.Context, _ string, _ any) (any, error) { + return nil, ErrRunPending +} diff --git a/internal/capability/skills/runner_test.go b/internal/capability/skills/runner_test.go new file mode 100644 index 0000000..7d438e5 --- /dev/null +++ b/internal/capability/skills/runner_test.go @@ -0,0 +1,115 @@ +package skills + +import ( + "context" + "errors" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" +) + +func TestRunner_Expand_正常(t *testing.T) { + t.Parallel() + r := NewRunner(registry.New(), testLogger()) + wf, err := r.Expand(context.Background(), makeSkill(t, "review")) + if err != nil { + t.Fatalf("Expand: %v", err) + } + if wf.Name != "review" { + t.Fatalf("Name = %q, want review", wf.Name) + } + if wf.Description != "desc review" { + t.Fatalf("Description = %q, want desc review", wf.Description) + } + if wf.Prompt != "prompt review" { + t.Fatalf("Prompt = %q, want prompt review", wf.Prompt) + } + if len(wf.Steps) != 1 || wf.Steps[0].Name != "s1" { + t.Fatalf("Steps = %+v, want one step s1", wf.Steps) + } + if len(wf.Refs) != 1 || wf.Refs[0].Name != "r1" { + t.Fatalf("Refs = %+v, want one ref r1", wf.Refs) + } + if len(wf.Guardrails) != 1 || wf.Guardrails[0].ID != "g1" { + t.Fatalf("Guardrails = %+v, want one guardrail g1", wf.Guardrails) + } +} + +func TestRunner_Expand_Nil_异常(t *testing.T) { + t.Parallel() + r := NewRunner(registry.New(), testLogger()) + if _, err := r.Expand(context.Background(), nil); !errors.Is(err, ErrInvalidSkill) { + t.Fatalf("Expand(nil) err = %v, want ErrInvalidSkill", err) + } +} + +func TestRunner_Expand_BlankName_异常(t *testing.T) { + t.Parallel() + r := NewRunner(registry.New(), testLogger()) + if _, err := r.Expand(context.Background(), mockSkill{name: " "}); !errors.Is(err, ErrInvalidSkill) { + t.Fatalf("Expand(blank) err = %v, want ErrInvalidSkill", err) + } +} + +func TestRunner_Expand_ContextCanceled_异常(t *testing.T) { + t.Parallel() + r := NewRunner(registry.New(), testLogger()) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := r.Expand(ctx, makeSkill(t, "review")); !errors.Is(err, context.Canceled) { + t.Fatalf("Expand(canceled) err = %v, want context.Canceled", err) + } +} + +func TestRunner_ExpandByName_正常(t *testing.T) { + t.Parallel() + reg := registry.New() + if err := Register(reg, makeSkill(t, "review")); err != nil { + t.Fatalf("Register: %v", err) + } + r := NewRunner(reg, testLogger()) + wf, err := r.ExpandByName(context.Background(), "review") + if err != nil { + t.Fatalf("ExpandByName: %v", err) + } + if wf.Name != "review" || len(wf.Steps) != 1 { + t.Fatalf("Workflow = %+v, want review with one step", wf) + } +} + +func TestRunner_ExpandByName_NotSkill_异常(t *testing.T) { + t.Parallel() + reg := registry.New() + // Register a capability whose Value is not a Skill (rendered via the raw API). + if err := reg.Register(registry.Capability{Name: "notaskill", Kind: registry.KindTool, Value: "x"}); err != nil { + t.Fatalf("register tool-cap: %v", err) + } + r := NewRunner(reg, testLogger()) + if _, err := r.ExpandByName(context.Background(), "notaskill"); !errors.Is(err, ErrNotFound) { + t.Fatalf("ExpandByName(non-skill) err = %v, want ErrNotFound", err) + } +} + +func TestRunner_ExpandByName_NotFound_异常(t *testing.T) { + t.Parallel() + r := NewRunner(registry.New(), testLogger()) + if _, err := r.ExpandByName(context.Background(), "missing"); !errors.Is(err, registry.ErrNotFound) { + t.Fatalf("ExpandByName(missing) err = %v, want registry.ErrNotFound", err) + } +} + +func TestRunner_ExpandByName_NilRegistrar_异常(t *testing.T) { + t.Parallel() + r := NewRunner(nil, testLogger()) + if _, err := r.ExpandByName(context.Background(), "x"); !errors.Is(err, ErrNilRegistrar) { + t.Fatalf("ExpandByName(nil reg) err = %v, want ErrNilRegistrar", err) + } +} + +func TestRunner_Run_Placeholder_异常(t *testing.T) { + t.Parallel() + r := NewRunner(registry.New(), testLogger()) + if _, err := r.Run(context.Background(), "review", nil); !errors.Is(err, ErrRunPending) { + t.Fatalf("Run err = %v, want ErrRunPending", err) + } +} diff --git a/internal/capability/skills/skill.go b/internal/capability/skills/skill.go new file mode 100644 index 0000000..9ea34c1 --- /dev/null +++ b/internal/capability/skills/skill.go @@ -0,0 +1,134 @@ +// Package skills implements the M5 reusable-workflow capability: a Skill is a +// higher-order, persistable workflow — an ordered set of steps plus a base +// prompt, references, and guardrails — that builds on pkg/plugins/sdk and is +// more structured than a Tool (DevDocs/design/09 §0/§3). +// +// The package provides five pieces: +// +// - Skill: the concrete in-memory model implementing pkg/plugins/sdk.Skill. +// - Store: persistence of skills in a SQLite skills table. +// - Loader: loading Skill definitions from YAML (a single stream or a +// configs/skills/*.yaml directory). +// - Runner: expanding a Skill into an executable Workflow for the loop/agent +// executor (the actual execution itself lands in M2/M12). +// - Register: publishing skills to the global capability registry +// (internal/capability/registry) as KindSkill capabilities. +package skills + +import ( + "errors" + "fmt" + "io" + "log/slog" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// Sentinel errors returned by the skills package. Use errors.Is to test. +var ( + // ErrInvalidSkill is returned when a skill is nil or has a blank name. + ErrInvalidSkill = errors.New("skills: invalid skill") + // ErrNotFound is returned by Store when a skill is absent. + ErrNotFound = errors.New("skills: skill not found") + // ErrNilRegistrar is returned by Register/NewRunner when no registry was + // supplied. + ErrNilRegistrar = errors.New("skills: nil registrar") + // ErrNilDB is returned by NewStore when no *sql.DB was supplied. + ErrNilDB = errors.New("skills: nil db") + // ErrRunPending is returned by Runner.Run, which awaits M12 wiring. + ErrRunPending = errors.New("skills: run pending") +) + +// Skill is the concrete, persistable form of a skill (see pkg/plugins/sdk.Skill): +// a reusable workflow (ordered steps with dependencies), a base prompt, +// references, and guardrails. It is more structured than a Tool and is the +// higher-order capability described in DevDocs/design/09 §0. +// +// Skill is immutable after construction; its getters return copies of the slice +// fields, so a *Skill is safe for concurrent use as the sdk.Skill contract +// requires. +type Skill struct { + name string + description string + prompt string + steps []sdk.Step + refs []sdk.Ref + guardrails []sdk.Guardrail +} + +// compile-time assertion that *Skill satisfies the SDK Skill interface. +var _ sdk.Skill = (*Skill)(nil) + +// New builds a Skill. It returns ErrInvalidSkill when name is blank. The step, +// ref, and guardrail slices are copied, so the caller remains free to mutate +// them afterwards. +func New(name, description, prompt string, steps []sdk.Step, refs []sdk.Ref, guardrails []sdk.Guardrail) (*Skill, error) { + if strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("skills: new: %w", ErrInvalidSkill) + } + return &Skill{ + name: name, + description: description, + prompt: prompt, + steps: cloneSteps(steps), + refs: cloneRefs(refs), + guardrails: cloneGuardrails(guardrails), + }, nil +} + +// Name returns the unique skill identifier. +func (s *Skill) Name() string { return s.name } + +// Description explains what the Skill accomplishes; the model uses it to select +// a Skill for a task. +func (s *Skill) Description() string { return s.description } + +// Prompt returns the base system prompt for the Skill. +func (s *Skill) Prompt() string { return s.prompt } + +// Steps returns a copy of the ordered workflow steps. +func (s *Skill) Steps() []sdk.Step { return cloneSteps(s.steps) } + +// Refs returns a copy of the references the Skill may draw on. +func (s *Skill) Refs() []sdk.Ref { return cloneRefs(s.refs) } + +// Guardrails returns a copy of the constraints the Skill enforces. +func (s *Skill) Guardrails() []sdk.Guardrail { return cloneGuardrails(s.guardrails) } + +func cloneSteps(in []sdk.Step) []sdk.Step { + if len(in) == 0 { + return nil + } + out := make([]sdk.Step, len(in)) + copy(out, in) + return out +} + +func cloneRefs(in []sdk.Ref) []sdk.Ref { + if len(in) == 0 { + return nil + } + out := make([]sdk.Ref, len(in)) + copy(out, in) + return out +} + +func cloneGuardrails(in []sdk.Guardrail) []sdk.Guardrail { + if len(in) == 0 { + return nil + } + out := make([]sdk.Guardrail, len(in)) + copy(out, in) + return out +} + +// sourcedLog returns a source-tagged logger suitable for this package, falling +// back to a discard logger when l is nil so callers can pass nil safely. +func sourcedLog(l *slog.Logger) *slog.Logger { + if l == nil { + l = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + return logger.WithSource(l, "capability/skills") +} diff --git a/internal/capability/skills/store.go b/internal/capability/skills/store.go new file mode 100644 index 0000000..ace6f51 --- /dev/null +++ b/internal/capability/skills/store.go @@ -0,0 +1,236 @@ +package skills + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// Store persists skill definitions in SQLite (the skills table). It is safe for +// concurrent use — the underlying *sql.DB serializes writes — and every query is +// parameterized, so values are never interpolated into SQL. +type Store struct { + db *sql.DB + log *slog.Logger +} + +// createSkillsTable creates the skills schema. id is an opaque primary key; +// name is the stable, unique skill identifier used for Upsert/Get/Delete +// lookups. The desc column is double-quoted because DESC is a SQL keyword in +// ordering context. +const createSkillsTable = ` +CREATE TABLE IF NOT EXISTS skills ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + "desc" TEXT NOT NULL DEFAULT '', + prompt TEXT NOT NULL DEFAULT '', + steps_json TEXT NOT NULL DEFAULT '[]', + refs_json TEXT NOT NULL DEFAULT '[]', + guardrails_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_skills_name ON skills (name); +` + +// upsertSkillSQL inserts a skill, updating the existing row when the name +// already exists; created_at is preserved across an update. +const upsertSkillSQL = ` +INSERT INTO skills (id, name, "desc", prompt, steps_json, refs_json, guardrails_json, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(name) DO UPDATE SET + "desc" = excluded."desc", + prompt = excluded.prompt, + steps_json = excluded.steps_json, + refs_json = excluded.refs_json, + guardrails_json = excluded.guardrails_json, + updated_at = excluded.updated_at +` + +const ( + getSkillSQL = `SELECT name, "desc", prompt, steps_json, refs_json, guardrails_json FROM skills WHERE name = ?` + listSkillsSQL = `SELECT name, "desc", prompt, steps_json, refs_json, guardrails_json FROM skills ORDER BY name` + deleteSkillSQL = `DELETE FROM skills WHERE name = ?` +) + +// idByteLen is the size in bytes of the random skill id (128-bit). +const idByteLen = 16 + +// NewStore builds a Store, creating the skills table if needed. It returns +// ErrNilDB when db is nil. ctx is used for the schema creation statement. +func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) { + if db == nil { + return nil, fmt.Errorf("skills: store: %w", ErrNilDB) + } + s := &Store{db: db, log: sourcedLog(log)} + if _, err := db.ExecContext(ctx, createSkillsTable); err != nil { + return nil, fmt.Errorf("skills: store: create skills table: %w", err) + } + s.log.Info("skills table ready") + return s, nil +} + +// Upsert stores skill, inserting a new row or updating the existing row that +// shares its name. It returns ErrInvalidSkill when skill is nil or has a blank +// name. +func (s *Store) Upsert(ctx context.Context, skill sdk.Skill) error { + if skill == nil || strings.TrimSpace(skill.Name()) == "" { + return fmt.Errorf("skills: store: upsert: %w", ErrInvalidSkill) + } + name := skill.Name() + stepsJSON, err := marshalJSON(skill.Steps()) + if err != nil { + return fmt.Errorf("skills: store: upsert %q: steps: %w", name, err) + } + refsJSON, err := marshalJSON(skill.Refs()) + if err != nil { + return fmt.Errorf("skills: store: upsert %q: refs: %w", name, err) + } + guardrailsJSON, err := marshalJSON(skill.Guardrails()) + if err != nil { + return fmt.Errorf("skills: store: upsert %q: guardrails: %w", name, err) + } + now := time.Now().UTC().Format(time.RFC3339Nano) + if _, err := s.db.ExecContext(ctx, upsertSkillSQL, + newID(), name, skill.Description(), skill.Prompt(), + stepsJSON, refsJSON, guardrailsJSON, now, now, + ); err != nil { + return fmt.Errorf("skills: store: upsert %q: %w", name, err) + } + s.log.Info("skill upserted", "skill", name) + return nil +} + +// Get returns the skill registered under name, or ErrNotFound when absent. +func (s *Store) Get(ctx context.Context, name string) (*Skill, error) { + row := s.db.QueryRowContext(ctx, getSkillSQL, name) + skill, err := scanSkill(row, name) + if err != nil { + return nil, err + } + return skill, nil +} + +// List returns every stored skill, ordered by name. +func (s *Store) List(ctx context.Context) ([]*Skill, error) { + rows, err := s.db.QueryContext(ctx, listSkillsSQL) + if err != nil { + return nil, fmt.Errorf("skills: store: list: %w", err) + } + defer rows.Close() + + out := make([]*Skill, 0) + for rows.Next() { + skill, err := scanSkill(rows, "") + if err != nil { + return nil, err + } + out = append(out, skill) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("skills: store: list: iterate: %w", err) + } + return out, nil +} + +// Delete removes the skill named name. It returns ErrNotFound when no such skill +// exists. +func (s *Store) Delete(ctx context.Context, name string) error { + res, err := s.db.ExecContext(ctx, deleteSkillSQL, name) + if err != nil { + return fmt.Errorf("skills: store: delete %q: %w", name, err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("skills: store: delete %q: rows affected: %w", name, err) + } + if n == 0 { + return fmt.Errorf("skills: store: delete %q: %w", name, ErrNotFound) + } + s.log.Info("skill deleted", "skill", name) + return nil +} + +// rowScanner is the subset of *sql.Row and *sql.Rows that scanSkill needs. +type rowScanner interface{ Scan(dest ...any) error } + +// scanSkill reads one skills row and builds a *Skill. key is the lookup name +// used only in error messages. +func scanSkill(sc rowScanner, key string) (*Skill, error) { + var name, desc, prompt, stepsJSON, refsJSON, guardrailsJSON string + if err := sc.Scan(&name, &desc, &prompt, &stepsJSON, &refsJSON, &guardrailsJSON); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("skills: store: get %q: %w", key, ErrNotFound) + } + return nil, fmt.Errorf("skills: store: scan skill: %w", err) + } + steps, err := unmarshalSteps(stepsJSON) + if err != nil { + return nil, fmt.Errorf("skills: store: skill %q: steps: %w", name, err) + } + refs, err := unmarshalRefs(refsJSON) + if err != nil { + return nil, fmt.Errorf("skills: store: skill %q: refs: %w", name, err) + } + guardrails, err := unmarshalGuardrails(guardrailsJSON) + if err != nil { + return nil, fmt.Errorf("skills: store: skill %q: guardrails: %w", name, err) + } + skill, err := New(name, desc, prompt, steps, refs, guardrails) + if err != nil { + return nil, fmt.Errorf("skills: store: skill %q: %w", name, err) + } + return skill, nil +} + +func marshalJSON(v any) (string, error) { + b, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(b), nil +} + +func unmarshalSteps(s string) ([]sdk.Step, error) { + var out []sdk.Step + if err := json.Unmarshal([]byte(s), &out); err != nil { + return nil, err + } + return out, nil +} + +func unmarshalRefs(s string) ([]sdk.Ref, error) { + var out []sdk.Ref + if err := json.Unmarshal([]byte(s), &out); err != nil { + return nil, err + } + return out, nil +} + +func unmarshalGuardrails(s string) ([]sdk.Guardrail, error) { + var out []sdk.Guardrail + if err := json.Unmarshal([]byte(s), &out); err != nil { + return nil, err + } + return out, nil +} + +// newID returns a random 128-bit hex identifier for a skill row. +func newID() string { + var b [idByteLen]byte + if _, err := rand.Read(b[:]); err != nil { + // crypto/rand is effectively infallible; fall back to a nanosecond + // timestamp so the store never blocks on a missing identifier. + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b[:]) +} diff --git a/internal/capability/skills/store_test.go b/internal/capability/skills/store_test.go new file mode 100644 index 0000000..04e6e9d --- /dev/null +++ b/internal/capability/skills/store_test.go @@ -0,0 +1,201 @@ +package skills + +import ( + "context" + "errors" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +func TestStore_UpsertGet_正常(t *testing.T) { + t.Parallel() + s := newTestStore(t) + ctx := context.Background() + + if err := s.Upsert(ctx, makeSkill(t, "daily-brief")); err != nil { + t.Fatalf("Upsert: %v", err) + } + got, err := s.Get(ctx, "daily-brief") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Name() != "daily-brief" { + t.Fatalf("Name = %q, want daily-brief", got.Name()) + } + if got.Description() != "desc daily-brief" { + t.Fatalf("Description = %q, want desc daily-brief", got.Description()) + } + if got.Prompt() != "prompt daily-brief" { + t.Fatalf("Prompt = %q, want prompt daily-brief", got.Prompt()) + } + steps := got.Steps() + if len(steps) != 1 || steps[0].Name != "s1" || steps[0].Prompt != "step prompt" { + t.Fatalf("Steps = %+v, want one step s1/step prompt", steps) + } + refs := got.Refs() + if len(refs) != 1 || refs[0].Name != "r1" || refs[0].Type != "tool" || refs[0].Value != "v1" || refs[0].Optional { + t.Fatalf("Refs = %+v, want r1/tool/v1/non-optional", refs) + } + guards := got.Guardrails() + if len(guards) != 1 || guards[0].ID != "g1" || guards[0].Message != "msg" || !guards[0].Enforced { + t.Fatalf("Guardrails = %+v, want g1/msg/enforced", guards) + } +} + +func TestStore_Upsert_Update_正常(t *testing.T) { + t.Parallel() + s := newTestStore(t) + ctx := context.Background() + + if err := s.Upsert(ctx, makeSkill(t, "review")); err != nil { + t.Fatalf("first Upsert: %v", err) + } + // Same name, changed description/prompt — must update in place, not duplicate. + updated, err := New("review", "v2 desc", "v2 prompt", nil, nil, nil) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := s.Upsert(ctx, updated); err != nil { + t.Fatalf("second Upsert: %v", err) + } + got, err := s.Get(ctx, "review") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Description() != "v2 desc" || got.Prompt() != "v2 prompt" { + t.Fatalf("updated skill = %q/%q, want v2 desc/v2 prompt", got.Description(), got.Prompt()) + } + all, err := s.List(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(all) != 1 { + t.Fatalf("List after update has %d rows, want 1", len(all)) + } +} + +func TestStore_Get_NotFound_异常(t *testing.T) { + t.Parallel() + s := newTestStore(t) + _, err := s.Get(context.Background(), "missing") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("Get(missing) err = %v, want ErrNotFound", err) + } +} + +func TestStore_List_正常(t *testing.T) { + t.Parallel() + s := newTestStore(t) + ctx := context.Background() + for _, n := range []string{"b", "a", "c"} { + if err := s.Upsert(ctx, makeSkill(t, n)); err != nil { + t.Fatalf("Upsert(%s): %v", n, err) + } + } + got, err := s.List(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(got) != 3 { + t.Fatalf("List length = %d, want 3", len(got)) + } + if got[0].Name() != "a" || got[1].Name() != "b" || got[2].Name() != "c" { + t.Fatalf("List order = %q,%q,%q, want a,b,c", got[0].Name(), got[1].Name(), got[2].Name()) + } +} + +func TestStore_Delete_正常(t *testing.T) { + t.Parallel() + s := newTestStore(t) + ctx := context.Background() + if err := s.Upsert(ctx, makeSkill(t, "x")); err != nil { + t.Fatalf("Upsert: %v", err) + } + if err := s.Delete(ctx, "x"); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := s.Get(ctx, "x"); !errors.Is(err, ErrNotFound) { + t.Fatalf("Get after Delete err = %v, want ErrNotFound", err) + } +} + +func TestStore_Delete_NotFound_异常(t *testing.T) { + t.Parallel() + s := newTestStore(t) + if err := s.Delete(context.Background(), "missing"); !errors.Is(err, ErrNotFound) { + t.Fatalf("Delete(missing) err = %v, want ErrNotFound", err) + } +} + +func TestStore_Upsert_Invalid_异常(t *testing.T) { + t.Parallel() + s := newTestStore(t) + ctx := context.Background() + cases := []struct { + name string + sk sdk.Skill + }{ + {name: "nil skill", sk: nil}, + {name: "blank name", sk: mockSkill{name: " "}}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if err := s.Upsert(ctx, tc.sk); !errors.Is(err, ErrInvalidSkill) { + t.Fatalf("Upsert(%s) err = %v, want ErrInvalidSkill", tc.name, err) + } + }) + } +} + +// TestStore_SQLInjection_安全 stores a name/description that looks like SQL and +// verifies the values round-trip verbatim (parameterized statements), that the +// table survives, and that unrelated rows are unaffected. +func TestStore_SQLInjection_安全(t *testing.T) { + t.Parallel() + s := newTestStore(t) + ctx := context.Background() + name := "x'); DROP TABLE skills; --" + desc := "'; SELECT * FROM skills; --" + + if err := s.Upsert(ctx, mockSkill{name: name}); err != nil { + t.Fatalf("Upsert injection name: %v", err) + } + got, err := s.Get(ctx, name) + if err != nil { + t.Fatalf("Get injection name: %v", err) + } + if got.Name() != name { + t.Fatalf("Name = %q, want the injection string stored verbatim", got.Name()) + } + + // A description carrying SQL must also be stored as-is. + sk, err := New("inject-desc", desc, "p", nil, nil, nil) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := s.Upsert(ctx, sk); err != nil { + t.Fatalf("Upsert injection desc: %v", err) + } + got2, err := s.Get(ctx, "inject-desc") + if err != nil { + t.Fatalf("Get inject-desc: %v", err) + } + if got2.Description() != desc { + t.Fatalf("Description = %q, want verbatim injection", got2.Description()) + } + + // The table must still be intact and usable. + if err := s.Upsert(ctx, makeSkill(t, "after")); err != nil { + t.Fatalf("Upsert after injection: %v", err) + } + all, err := s.List(ctx) + if err != nil { + t.Fatalf("List after injection: %v", err) + } + if len(all) != 3 { + t.Fatalf("List after injection has %d rows, want 3", len(all)) + } +}