Files
Cyrene_v2/internal/capability/registry/registry.go
T
AskaEth a30f4a49ff feat(search): provider 热重载 + primary/fallback(W3)
- 保存被包成事务:快照 → 落盘 → 按新配置重建工具 → 原子替换注册表条目(registry.ReplaceTool
  窄方法,保留运行期 Policy);下一轮即生效,不要求重启
- 若新配置会让后端下次起不来(主家未配齐 fail-fast)⇒ 文件按字节回滚 + 409 config_unstartable
- 声明面补 primary + fallback[](可配可生效);否决「另开 form」方案(会破坏既有 reauth 回填)
- 主 provider 解析口径核查结论:没被 PLG-4 改坏(顺手修了一处日志缺陷)
- 4 个变异自检(丢 Policy/不换条目/回滚 no-op/忽略 fallback)
2026-10-06 15:59:12 +08:00

313 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
)
// ToolPolicy holds the editable Boolean markers that gate a tool/capability in
// non-owner environments (DevDocs/design/11 §3). Both fields default to false,
// so a capability is owner-only until a marker is explicitly enabled. Policies
// are per-capability and editable at runtime (the admin panel, M10).
type ToolPolicy struct {
// AllowInGroup permits invocation from a group chat environment.
AllowInGroup bool
// AllowInStranger permits invocation from a stranger's private chat
// environment.
AllowInStranger bool
}
// 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
// Policy is the editable reachability markers for this capability. It
// defaults to zero, which the policy engine reads as owner-only.
Policy ToolPolicy
// 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
// store 是策略落盘载体(design/11 §3、裁决 10.4.2)。为 nil 时注册表
// 退回纯内存态(既有行为);AttachPolicyStore 装上后,SetPolicy 系列
// 先落库再改内存。
store PolicyStore
// persisted 是最近一次落盘成功的策略快照(能力名 → 标记)。注册表在
// Register 时用它覆盖能力的初始 Policy,因此「策略在能力注册之前就已落盘」
// 与「能力先注册、策略后落盘」两种顺序都得到同一个结果。
persisted map[string]ToolPolicy
}
// 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)
}
// 落盘策略覆盖注册时带的初始标记:DB 是策略的权威载体(裁决 10.4.2),
// 因此重启后先 Load、再热注册(MCP 热装等)也能拿到已配置的值。
if pol, ok := r.persisted[cap.Name]; ok {
cap.Policy = pol
}
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
}
// Remove deletes the capability registered under name. It returns ErrNotFound
// when no such capability is registered.
func (r *Registry) Remove(name string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.caps[name]; !ok {
return fmt.Errorf("registry: remove %q: %w", name, ErrNotFound)
}
delete(r.caps, name)
return 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
}
// Policy returns the editable reachability markers for the capability named
// name. It returns ErrNotFound when no such capability is registered.
func (r *Registry) Policy(name string) (ToolPolicy, error) {
r.mu.RLock()
defer r.mu.RUnlock()
cap, ok := r.caps[name]
if !ok {
return ToolPolicy{}, fmt.Errorf("registry: policy %q: %w", name, ErrNotFound)
}
return cap.Policy, nil
}
// SetPolicy replaces the reachability markers for the capability named name. It
// returns ErrNotFound when no such capability is registered.
//
// 落盘(裁决 1.3/10.4.2):已装上 PolicyStore 时先写库,成功后才改内存——
// 写库失败即返回错误、内存保持旧值,绝不产生「面板说保存成功、重启却复位」。
// 顺序是「存在性读 → 落库(锁外做 SQL)→ 持锁套用」。
func (r *Registry) SetPolicy(name string, p ToolPolicy) error {
if _, err := r.Policy(name); err != nil {
return fmt.Errorf("registry: set policy %q: %w", name, err)
}
if err := r.persist(name, p); err != nil {
return err
}
r.mu.Lock()
defer r.mu.Unlock()
cap, ok := r.caps[name]
if !ok {
return fmt.Errorf("registry: set policy %q: %w", name, ErrNotFound)
}
cap.Policy = p
r.caps[name] = cap
r.remember(name, p)
return nil
}
// SetAllowInGroup toggles the group-chat marker for the capability named name.
// It returns ErrNotFound when no such capability is registered. 它复用
// setPolicyField,因此同样先落库再改内存(裁决 10.4.2)。
func (r *Registry) SetAllowInGroup(name string, allow bool) error {
return r.setPolicyField(name, "allow_in_group", func(p *ToolPolicy) { p.AllowInGroup = allow })
}
// SetAllowInStranger toggles the stranger-chat marker for the capability named
// name. It returns ErrNotFound when no such capability is registered. 同
// SetAllowInGroup:先落库再改内存。
func (r *Registry) SetAllowInStranger(name string, allow bool) error {
return r.setPolicyField(name, "allow_in_stranger", func(p *ToolPolicy) { p.AllowInStranger = allow })
}
// setPolicyField 是三个策略写入口共用的「读当前值 → 改一位 → SetPolicy」路径
// (先落库再改内存,见 SetPolicy)。field 只用于错误消息里的操作名。
func (r *Registry) setPolicyField(name, field string, apply func(*ToolPolicy)) error {
cur, err := r.Policy(name)
if err != nil {
return fmt.Errorf("registry: set %s %q: %w", field, name, err)
}
apply(&cur)
if err := r.SetPolicy(name, cur); err != nil {
return fmt.Errorf("registry: set %s %q: %w", field, name, err)
}
return nil
}
// 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,
})
}
// ReplaceTool 用**同名**的 t 就地换掉已登记的工具条目(热替换,design/09 §5.5:
// 面板保存后下一轮即用新接线,不必重启)。
//
// 与「Remove + Register」的两处实质差别,正是这个窄方法存在的理由:
// - **原子**:替换在一次写锁内完成,读者中间看不到「这个名字不存在」的空窗
// (Remove+Register 之间若有 Invoke 落到这个名字上,会得到 ErrUnknownTool);
// - **保留 Policy**:分组/陌生人的可达标记是**运行期可改的配置**(§11 §3),
// 不属于工具实现。初始注册时带的标记没有进 persisted 快照,Remove+Register
// 会把它悄悄重置成零值(= 退回 owner-only),用户会看到「面板保存后某个群
// 忽然不能用了」。这里原样沿用内存里的既有标记。
//
// 没有同名条目时返回 ErrNotFound(**不**顺手新增:热替换的语义是「换掉现在这把」,
// 名字打错时静默插入会掩盖调用方的错);既有条目不是 KindTool 时返回 ErrInvalidArgument。
func (r *Registry) ReplaceTool(t sdk.Tool) error {
if t == nil || t.Name() == "" {
return fmt.Errorf("registry: replace tool: %w", ErrInvalidArgument)
}
name := t.Name()
r.mu.Lock()
defer r.mu.Unlock()
cur, ok := r.caps[name]
if !ok {
return fmt.Errorf("registry: replace %q: %w", name, ErrNotFound)
}
if cur.Kind != KindTool {
return fmt.Errorf("registry: replace %q: 既有能力类型是 %q,不是工具: %w", name, cur.Kind, ErrInvalidArgument)
}
r.caps[name] = Capability{
Name: name,
Kind: KindTool,
Description: t.Description(),
// Policy 沿用旧条目的:它是可编辑的运行期配置,不属于工具的构造产出。
Policy: cur.Policy,
Value: t,
}
return nil
}
// 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
}
}