From 653c2489bf1eedf9f090964d8dc1b4c96e50ff58 Mon Sep 17 00:00:00 2001 From: Aska Date: Sun, 6 Sep 2026 04:35:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(capability):=20=E8=83=BD=E5=8A=9B=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E4=B8=AD=E5=BF=83=EF=BC=88=E7=B1=BB=E5=9E=8B=E5=8C=96?= =?UTF-8?q?=E6=B3=A8=E5=86=8C/=E6=8C=89=E7=B1=BB=E6=9F=A5=E8=AF=A2/?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E5=AE=89=E5=85=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/capability/registry/registry.go | 169 ++++++++++++ internal/capability/registry/registry_test.go | 248 ++++++++++++++++++ 2 files changed, 417 insertions(+) create mode 100644 internal/capability/registry/registry.go create mode 100644 internal/capability/registry/registry_test.go diff --git a/internal/capability/registry/registry.go b/internal/capability/registry/registry.go new file mode 100644 index 0000000..7ea2085 --- /dev/null +++ b/internal/capability/registry/registry.go @@ -0,0 +1,169 @@ +// Package registry provides the global capability registry. Every capability a +// character can use — tools, skills, channels, MCP servers and IoT providers — +// is registered here so it is globally shared and always available to any +// character and mode (DevDocs/design/01-角色人格与行为模式.md §4). +// +// It is the single source of truth that tool dispatch (M2) and plugin/skill/MCP +// wiring (M5) read from. The registry is safe for concurrent use; it implements +// the pkg/plugins/sdk Registrar interface so plugins register through the SDK +// helpers without knowing this concrete type. +package registry + +import ( + "errors" + "fmt" + "sync" + + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// Kind classifies a registered capability. +type Kind string + +const ( + // KindTool is a callable function with a JSON Schema (sdk.Tool). + KindTool Kind = "tool" + // KindSkill is a reusable workflow with steps, prompts, refs and guardrails + // (sdk.Skill). + KindSkill Kind = "skill" + // KindChannel is a platform adapter (sdk.Channel). + KindChannel Kind = "channel" + // KindMCP is a capability exposed by an MCP server (subsystem-owned). + KindMCP Kind = "mcp" + // KindIoT is an IoT device or provider capability (subsystem-owned). + KindIoT Kind = "iot" +) + +// Capability is a globally shared, always-available capability registered in +// the registry. +type Capability struct { + // Name is the unique capability identifier. + Name string + // Kind classifies the capability. + Kind Kind + // Description is a human-readable summary used for selection and display. + Description string + // Value is the underlying capability object. For KindTool/KindSkill/ + // KindChannel it is the SDK interface from pkg/plugins/sdk; for KindMCP and + // KindIoT it is owned by the relevant subsystem. + Value any +} + +// Registry is the thread-safe global capability registry. +type Registry struct { + mu sync.RWMutex + caps map[string]Capability +} + +// Compile-time assertion that *Registry satisfies the SDK Registrar interface. +var _ sdk.Registrar = (*Registry)(nil) + +// Sentinel errors returned by the registry. Use errors.Is to test for them. +var ( + // ErrNotFound is returned by Get when no capability has the given name. + ErrNotFound = errors.New("registry: capability not found") + // ErrDuplicate is returned by Register when a capability with the same name + // is already registered. + ErrDuplicate = errors.New("registry: capability already registered") + // ErrInvalidArgument is returned when a capability has an empty name, an + // unknown kind, or a nil Value. + ErrInvalidArgument = errors.New("registry: invalid capability") +) + +// New returns an empty, concurrency-safe registry. +func New() *Registry { + return &Registry{caps: make(map[string]Capability)} +} + +// Register adds cap to the registry. It returns ErrInvalidArgument when the +// name is empty, the kind is unknown or the Value is nil, and ErrDuplicate when +// a capability with the same name already exists. +func (r *Registry) Register(cap Capability) error { + if cap.Name == "" || !validKind(cap.Kind) || cap.Value == nil { + return fmt.Errorf("registry: register %q: %w", cap.Name, ErrInvalidArgument) + } + r.mu.Lock() + defer r.mu.Unlock() + if _, exists := r.caps[cap.Name]; exists { + return fmt.Errorf("registry: register %q: %w", cap.Name, ErrDuplicate) + } + r.caps[cap.Name] = cap + return nil +} + +// Get returns the capability registered under name. It returns ErrNotFound when +// no such capability is registered. +func (r *Registry) Get(name string) (Capability, error) { + r.mu.RLock() + defer r.mu.RUnlock() + cap, ok := r.caps[name] + if !ok { + return Capability{}, fmt.Errorf("registry: get %q: %w", name, ErrNotFound) + } + return cap, nil +} + +// List returns a copy of the capabilities whose Kind matches kind. An empty +// kind returns every registered capability. The returned slice is owned by the +// caller and safe to mutate. +func (r *Registry) List(kind Kind) []Capability { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]Capability, 0, len(r.caps)) + for _, cap := range r.caps { + if kind != "" && cap.Kind != kind { + continue + } + out = append(out, cap) + } + return out +} + +// RegisterTool registers t as a KindTool capability. +func (r *Registry) RegisterTool(t sdk.Tool) error { + if t == nil { + return fmt.Errorf("registry: register tool: %w", ErrInvalidArgument) + } + return r.Register(Capability{ + Name: t.Name(), + Kind: KindTool, + Description: t.Description(), + Value: t, + }) +} + +// RegisterSkill registers s as a KindSkill capability. +func (r *Registry) RegisterSkill(s sdk.Skill) error { + if s == nil { + return fmt.Errorf("registry: register skill: %w", ErrInvalidArgument) + } + return r.Register(Capability{ + Name: s.Name(), + Kind: KindSkill, + Description: s.Description(), + Value: s, + }) +} + +// RegisterChannel registers c as a KindChannel capability. +func (r *Registry) RegisterChannel(c sdk.Channel) error { + if c == nil { + return fmt.Errorf("registry: register channel: %w", ErrInvalidArgument) + } + return r.Register(Capability{ + Name: c.Name(), + Kind: KindChannel, + Description: c.Name(), + Value: c, + }) +} + +// validKind reports whether k is one of the known capability kinds. +func validKind(k Kind) bool { + switch k { + case KindTool, KindSkill, KindChannel, KindMCP, KindIoT: + return true + default: + return false + } +} diff --git a/internal/capability/registry/registry_test.go b/internal/capability/registry/registry_test.go new file mode 100644 index 0000000..adb58d8 --- /dev/null +++ b/internal/capability/registry/registry_test.go @@ -0,0 +1,248 @@ +package registry + +import ( + "context" + "errors" + "strconv" + "sync" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// regTool is a mock Tool for registry tests. +type regTool struct{ name string } + +func (t regTool) Name() string { return t.name } +func (t regTool) Description() string { return "desc " + t.name } +func (t regTool) Parameters() (map[string]any, error) { return map[string]any{}, nil } +func (t regTool) Execute(_ context.Context, _ any) (any, error) { return nil, nil } + +// regSkill is a mock Skill for registry tests. +type regSkill struct{ name string } + +func (s regSkill) Name() string { return s.name } +func (s regSkill) Description() string { return "desc " + s.name } +func (s regSkill) Prompt() string { return "prompt" } +func (s regSkill) Steps() []sdk.Step { return []sdk.Step{{Name: "s1", Prompt: "p1"}} } +func (s regSkill) Refs() []sdk.Ref { return nil } +func (s regSkill) Guardrails() []sdk.Guardrail { return nil } + +// regChannel is a mock Channel for registry tests. +type regChannel struct{ name string } + +func (c regChannel) Name() string { return c.name } +func (c regChannel) Connect(_ context.Context, _ any) error { return nil } +func (c regChannel) Disconnect(_ context.Context) error { return nil } +func (c regChannel) IsConnected() bool { return true } +func (c regChannel) HealthCheck(_ context.Context) error { return nil } + +func TestNew_Empty(t *testing.T) { + t.Parallel() + r := New() + if got := r.List(KindTool); len(got) != 0 { + t.Fatalf("List(KindTool) = %v, want empty", got) + } + if _, err := r.Get("missing"); !errors.Is(err, ErrNotFound) { + t.Fatalf("Get(missing) error = %v, want ErrNotFound", err) + } +} + +func TestRegister_Get(t *testing.T) { + t.Parallel() + r := New() + if err := r.Register(Capability{Name: "t1", Kind: KindTool, Description: "d", Value: regTool{name: "t1"}}); err != nil { + t.Fatalf("Register: %v", err) + } + got, err := r.Get("t1") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Name != "t1" || got.Kind != KindTool || got.Description != "d" { + t.Fatalf("Get = %+v, want name=t1 kind=tool desc=d", got) + } +} + +func TestRegister_Duplicate(t *testing.T) { + t.Parallel() + r := New() + cap := Capability{Name: "dup", Kind: KindTool, Value: regTool{name: "dup"}} + if err := r.Register(cap); err != nil { + t.Fatalf("first Register: %v", err) + } + err := r.Register(Capability{Name: "dup", Kind: KindSkill, Value: regSkill{name: "dup"}}) + if !errors.Is(err, ErrDuplicate) { + t.Fatalf("second Register error = %v, want ErrDuplicate", err) + } +} + +func TestRegister_InvalidArgument(t *testing.T) { + t.Parallel() + r := New() + tests := []struct { + name string + cap Capability + }{ + {name: "empty name", cap: Capability{Kind: KindTool, Value: regTool{name: "x"}}}, + {name: "unknown kind", cap: Capability{Name: "x", Kind: "bogus", Value: regTool{name: "x"}}}, + {name: "nil value", cap: Capability{Name: "x", Kind: KindTool}}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if err := r.Register(tt.cap); !errors.Is(err, ErrInvalidArgument) { + t.Fatalf("Register(%+v) error = %v, want ErrInvalidArgument", tt.cap, err) + } + }) + } +} + +func TestGet_NotFound(t *testing.T) { + t.Parallel() + r := New() + got, err := r.Get("nope") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("Get error = %v, want ErrNotFound", err) + } + if got != (Capability{}) { + t.Fatalf("Get returned %+v on error, want zero Capability", got) + } +} + +func TestList_ByKind(t *testing.T) { + t.Parallel() + r := New() + caps := []Capability{ + {Name: "t1", Kind: KindTool, Value: regTool{name: "t1"}}, + {Name: "s1", Kind: KindSkill, Value: regSkill{name: "s1"}}, + {Name: "c1", Kind: KindChannel, Value: regChannel{name: "c1"}}, + {Name: "m1", Kind: KindMCP, Value: "mcp"}, + {Name: "i1", Kind: KindIoT, Value: "iot"}, + } + for _, c := range caps { + if err := r.Register(c); err != nil { + t.Fatalf("Register(%s): %v", c.Name, err) + } + } + if got := r.List(KindTool); len(got) != 1 || got[0].Name != "t1" { + t.Fatalf("List(KindTool) = %+v, want [t1]", got) + } + if got := r.List(KindMCP); len(got) != 1 || got[0].Name != "m1" { + t.Fatalf("List(KindMCP) = %+v, want [m1]", got) + } + if got := r.List(""); len(got) != len(caps) { + t.Fatalf("List(\"\") length = %d, want %d", len(got), len(caps)) + } +} + +func TestRegister_SDKWrappers(t *testing.T) { + t.Parallel() + r := New() + + if err := r.RegisterTool(regTool{name: "calc"}); err != nil { + t.Fatalf("RegisterTool: %v", err) + } + tool, err := r.Get("calc") + if err != nil { + t.Fatalf("Get tool: %v", err) + } + if tool.Kind != KindTool { + t.Fatalf("tool kind = %q, want %q", tool.Kind, KindTool) + } + if got := tool.Value.(sdk.Tool).Name(); got != "calc" { + t.Fatalf("tool Value.Name() = %q, want calc", got) + } + + if err := r.RegisterSkill(regSkill{name: "review"}); err != nil { + t.Fatalf("RegisterSkill: %v", err) + } + skill, err := r.Get("review") + if err != nil { + t.Fatalf("Get skill: %v", err) + } + if skill.Kind != KindSkill { + t.Fatalf("skill kind = %q, want %q", skill.Kind, KindSkill) + } + if got := skill.Value.(sdk.Skill).Name(); got != "review" { + t.Fatalf("skill Value.Name() = %q, want review", got) + } + + if err := r.RegisterChannel(regChannel{name: "telegram"}); err != nil { + t.Fatalf("RegisterChannel: %v", err) + } + channel, err := r.Get("telegram") + if err != nil { + t.Fatalf("Get channel: %v", err) + } + if channel.Kind != KindChannel { + t.Fatalf("channel kind = %q, want %q", channel.Kind, KindChannel) + } + if got := channel.Value.(sdk.Channel).Name(); got != "telegram" { + t.Fatalf("channel Value.Name() = %q, want telegram", got) + } +} + +func TestRegister_NilSDKValue(t *testing.T) { + t.Parallel() + r := New() + tests := []struct { + name string + call func() error + }{ + {name: "tool", call: func() error { return r.RegisterTool(nil) }}, + {name: "skill", call: func() error { return r.RegisterSkill(nil) }}, + {name: "channel", call: func() error { return r.RegisterChannel(nil) }}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.call() + if !errors.Is(err, ErrInvalidArgument) { + t.Fatalf("err = %v, want ErrInvalidArgument", err) + } + }) + } +} + +func TestRegistry_ConcurrentSafety(t *testing.T) { + t.Parallel() + r := New() + + const workers = 8 + const perWorker = 100 + var wg sync.WaitGroup + for w := 0; w < workers; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + for i := 0; i < perWorker; i++ { + name := kn(w, i) + if err := r.RegisterTool(regTool{name: name}); err != nil { + t.Errorf("RegisterTool(%s): %v", name, err) + return + } + if _, err := r.Get(name); err != nil { + t.Errorf("Get(%s): %v", name, err) + } + // Concurrent reads and a miss do not race with writes. + _ = r.List("") + _, _ = r.Get("missing-" + name) + } + }(w) + } + wg.Wait() + + if got := len(r.List("")); got != workers*perWorker { + t.Fatalf("registered %d capabilities, want %d", got, workers*perWorker) + } + if _, err := r.Get(kn(workers-1, perWorker-1)); err != nil { + t.Fatalf("Get last capability: %v", err) + } +} + +// kn builds a capability name unique to a worker/iteration pair. +func kn(w, i int) string { + return "cap" + strconv.Itoa(w) + "-" + strconv.Itoa(i) +}