feat(capability/skills): 可复用工作流技能(Skill 模型 + Store + YAML Loader + Runner 展开 + 注册)

This commit is contained in:
Aska committed 2026-09-06 06:25:42 +08:00
1 parent d947ec41a9
commit 915aa8a100
10 files changed
+1235

No files matched your search

@@ -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)
}
}
+135
View File
@@ -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")
}
+176
View File
@@ -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))
}
}
+28
View File
@@ -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
}
@@ -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)
}
}
+86
View File
@@ -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
}
+115
View File
@@ -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)
}
}
+134
View File
@@ -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")
}
+236
View File
@@ -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[:])
}
+201
View File
@@ -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))
}
}