// 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 // Origin 是能力的**上游来源章**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md // 方案①):由工具自己通过 sdk.OriginAware 声明,注册汇点 // RegisterTool/ReplaceTool 盖在这里。零值 = 宿主内置 / 来源未知 —— // 这枚章**只用于呈现归属**,不参与权限判定、不进模型可见的工具面 // (restrict 只按名字查策略,ToolSchemas 只取 Name/Description/Parameters)。 Origin sdk.Origin // 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. // // 它是**来源章的唯一汇点**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md §2 方案①): // MCP 工具的**两条接入路径**都从这里进注册表 —— 运行时热装 // (plugin.Manager.start → mcp.NewRegistry → mcp.Registry.Register)与 `.env` 的 // MCP_SERVERS_JSON 启动期装(cmd/cyrene/mcp.go,不经 Manager)—— 因此在这一处盖章 // 一次就覆盖两条路(以及 tools/list_changed 的增量 refresh)。 // 不从 Manager 现场反推归属:那会漏掉 env 路径,把现役接入方式误报成「无来源」。 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(), Origin: originOf(t), Value: t, }) } // originOf 返回 t 自报的来源章;没实现 sdk.OriginAware(宿主内置工具)或自报零值时 // 返回零值 Origin —— 零值在线上表现为**键缺席**,不是一个空对象。 func originOf(t sdk.Tool) sdk.Origin { origin, ok := sdk.OriginOf(t) if !ok { return sdk.Origin{} } return origin } // 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, // Origin **必须重新盖章**:这里逐字段重建条目,漏掉一位就等于「热替换把来源 // 抹掉」,面板上那把工具会从它的 server 分节里掉进「宿主内置」。来源是工具的 // 构造产出(跟 RegisterTool 同一口径取),不沿用旧条目 —— 换了实现就该换章。 Origin: originOf(t), 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, }) } // ReplaceChannel 用**同名**的 c 就地更新已登记的渠道条目(热重载,design/04 §3.1)。 // // 为什么必须是替换而不是 Register:渠道热重载**必然同名**(同名账号的账号名由 // channels.json 决定,重载前后不变),而 Register 遇重名返回 ErrDuplicate,会把 // 新适配器挡在门外 —— 注册表于是继续指向旧实例,管理面板照旧按旧配置读写它的状态 // (显示「未连接」、点「连接」拨的是旧地址)。 // // 语义是 **upsert**,与 ReplaceTool 有意不同(工具的窄方法是「只换现有这把」): // - 名字不存在 ⇒ 按 RegisterChannel 同口径新增(账号被新加进配置后,热重载是它 // 第一次出现在注册表里的时机,没有别处会替它注册); // - 名字已存在且 Kind == KindChannel ⇒ 覆盖 Value(指向新适配器),**保留既有 // Policy** —— 分组/陌生人的可达标记是运行期可编辑配置(design/11 §3),不属于 // 适配器的构造产出,热重载把它清掉等于「面板保存一次,某个群忽然不能用了」; // - 名字已存在但 Kind != KindChannel ⇒ 报错,绝不悄悄改掉那条能力的类型。 func (r *Registry) ReplaceChannel(c sdk.Channel) error { if c == nil || c.Name() == "" { return fmt.Errorf("registry: replace channel: %w", ErrInvalidArgument) } name := c.Name() r.mu.Lock() defer r.mu.Unlock() cur, ok := r.caps[name] if !ok { // 新增分支与 Register 同口径:落盘策略覆盖初始标记(DB 是策略的权威载体)。 added := Capability{ Name: name, Kind: KindChannel, Description: name, Value: c, } if pol, ok := r.persisted[name]; ok { added.Policy = pol } r.caps[name] = added return nil } if cur.Kind != KindChannel { return fmt.Errorf("registry: replace %q: 既有能力类型是 %q,不是渠道: %w", name, cur.Kind, ErrInvalidArgument) } r.caps[name] = Capability{ Name: name, Kind: KindChannel, // Description 与 RegisterChannel 一致地取适配器名(面板按注册名精确匹配)。 Description: name, // Policy 沿用旧条目:它是可编辑的运行期配置,不属于适配器的构造产出。 Policy: cur.Policy, Value: c, } return nil } // 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 } }