feat(capability/skills): 可复用工作流技能(Skill 模型 + Store + YAML Loader + Runner 展开 + 注册)
This commit is contained in:
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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[:])
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user