feat(capability): 能力注册中心(类型化注册/按类查询/并发安全)
This commit is contained in:
1 parent
7b8cfc664a
commit
653c2489bf
2 files changed
+417
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user