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