package registry import ( "context" "encoding/json" "fmt" "io" "log/slog" "sort" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // 本文件把全局能力注册表暴露成一把**只读**工具(X1 / 主人裁决 15:她的主动思考轮 // 可以主动获取外围插件数据和状态)。它只回答「我现在有哪些能力/插件、它们什么状态」, // 不注册、不修改、不调用任何能力,因此它是她自查的抓手,不是第二条写路径。 // // 范式与 `internal/capability/transcode/tool.go` 一致:能力包自带它的对外工具, // 经同一个 capability registry 注册,因此 chat 与 backthink 共用同一个 dispatcher // 时看到的是**同一张**工具面(单二进制、零新机制)。 // ToolName 是只读清单工具的注册名。 const ToolName = "capability_list" // 清单的输出边界。三条一起构成「输出有界」的硬保证,越界即截断并显式标注 // (truncated / note): // // - limit:条目数上限(默认 50,模型显式要求时最多 maxListLimit 条); // - maxDescriptionChars:单条描述的渲染长度; // - summaryCharBudget:整份结果的字符预算,兜住「条数合法但描述极长」的情形。 // // 预算要**大于**满额短条目的开销(maxListLimit × 最小条目开销 ≈ 21KiB),否则条目 // 上限永远咬不住、limit 参数形同虚设,而边界就只剩一条看不出原因的总量检查。 const ( defaultListLimit = 50 maxListLimit = 200 maxDescriptionChars = 160 statusCharLimit = 400 summaryCharBudget = 40000 maxStatusSourceCount = 8 maxListKindNames = 5 ) // listKinds 是清单可以按 kind 过滤的闭集(与 validKind 同口径,顺序固定, // 因此 JSON Schema 的 enum 与错误文案都是确定的)。 func listKinds() []string { return []string{ string(KindTool), string(KindSkill), string(KindChannel), string(KindMCP), string(KindIoT), } } // ListEntry 是清单里的一个能力条目(只读快照)。 type ListEntry struct { // Name 是能力注册名。 Name string `json:"name"` // Kind 是能力种类(tool / skill / channel / mcp / iot)。 Kind string `json:"kind"` // Description 是注册时带的人类可读说明(按 maxDescriptionChars 截断)。 Description string `json:"description,omitempty"` // OwnerOnly 报告该能力当前是否仅主人环境可达(两个标记都 false = owner-only, // 即 design/11 §3 的 fail-closed 默认)。它是**可达性**事实,不是能力边界。 OwnerOnly bool `json:"ownerOnly"` } // ListStatus 是一个子系统自报的只读状态摘要。 type ListStatus struct { // Name 是子系统名(如 "iot")。 Name string `json:"name"` // Text 是状态摘要(按 statusCharLimit 截断);读取失败时为空。 Text string `json:"text,omitempty"` // Error 是状态源读取失败的说明;成功时为空。绝不悄悄吞掉失败。 Error string `json:"error,omitempty"` } // ListSummary 是清单工具的返回结构。 type ListSummary struct { // Counts 是各 kind 的能力数量(全量统计,不受条目截断影响)。 Counts map[string]int `json:"counts"` // Total 是注册表里的能力总数。 Total int `json:"total"` // Entries 是本次返回的条目(按 kind + name 排序,因此输出确定)。 Entries []ListEntry `json:"entries"` // Statuses 是注入的子系统状态摘要(未注入状态源时缺席)。 Statuses []ListStatus `json:"statuses,omitempty"` // Truncated 报告 entries 是否因 limit / 字符预算被截断。 Truncated bool `json:"truncated"` // Note 是截断或过滤的人类可读说明(无截断时为空)。 Note string `json:"note,omitempty"` } // StatusSource 为清单补充子系统级的只读状态(如 IoT 设备清单)。由组合根注入, // 因此本工具不依赖任何具体外围子系统。实现必须**只读、无副作用、有界且并发安全**: // 它只能读,返回文本会被截断到 statusCharLimit。 type StatusSource interface { // Name 是状态分组名(如 "iot")。 Name() string // Status 返回该子系统的只读状态摘要。 Status(ctx context.Context) (string, error) } // ListOption 定制清单工具。 type ListOption func(*ListTool) // WithStatusSource 注入一个只读状态源。可多次调用,按注入顺序输出; // 超过 maxStatusSourceCount 个的调用方参数被忽略(并记一条 warn)。 func WithStatusSource(s StatusSource) ListOption { return func(t *ListTool) { if s != nil { t.sources = append(t.sources, s) } } } // WithListLogger 注入日志器(来源统一标为 capability/registry)。 func WithListLogger(l *slog.Logger) ListOption { return func(t *ListTool) { t.log = listLog(l) } } // ListTool 是只读清单工具的 sdk.Tool 实现。它不持有可变状态(sources/log 只在 // 构造期写入),因此并发安全;注册时策略取零值 = owner-only(fail-closed)。 type ListTool struct { reg *Registry sources []StatusSource log *slog.Logger } // 编译期断言:*ListTool 实现 sdk.Tool。 var _ sdk.Tool = (*ListTool)(nil) // NewListTool 在 reg 之上构建只读清单工具。reg 为 nil 时返回包装 ErrInvalidArgument // 的错误(没有注册表就没有清单可列,绝不返回一份空清单冒充成功)。 func NewListTool(reg *Registry, opts ...ListOption) (*ListTool, error) { if reg == nil { return nil, fmt.Errorf("registry: new list tool: %w", ErrInvalidArgument) } t := &ListTool{reg: reg, log: listLog(nil)} for _, opt := range opts { if opt != nil { opt(t) } } if len(t.sources) > maxStatusSourceCount { t.log.Warn("capability list status sources truncated", "sources", len(t.sources), "limit", maxStatusSourceCount) t.sources = t.sources[:maxStatusSourceCount] } return t, nil } // Name 返回工具名。 func (t *ListTool) Name() string { return ToolName } // Description 说明该工具是**只读**的:列出当前已注册的能力/插件(含种类、说明与 // 是否仅主人可用)与外围子系统的只读状态;它不注册、不修改、不调用任何能力。 func (t *ListTool) Description() string { return "List the capabilities and plugins currently registered for you, read-only: every tool, " + "skill, channel, MCP server and peripheral subsystem, with its kind, description, whether it is " + "owner-only, and the read-only status each peripheral subsystem reports (e.g. devices). " + "Use it to find out what you can actually do right now. It never registers, removes, edits or " + "invokes anything, and its output is bounded." } // Parameters 返回入参 JSON Schema:两个可选参数(kind 过滤、limit 条数上限)。 // 不声明 limit 的 maximum:超上限的值在 Execute 里被夹到 maxListLimit,而不是 // 当作非法参数拒掉(与 session_read 的 limit 同口径)。 func (t *ListTool) Parameters() (map[string]any, error) { return map[string]any{ "type": "object", "properties": map[string]any{ "kind": map[string]any{ "type": "string", "enum": listKinds(), "description": "Optional capability kind filter: tool, skill, channel, mcp or iot.", }, "limit": map[string]any{ "type": "integer", "description": "Max entries to return (optional, default 50, capped at 200).", }, }, }, nil } // Execute 组装一份**只读**清单:注册表快照 + 各状态源的摘要。它只读注册表与状态源, // 绝不写入、绝不调用被列出的能力,且输出经 limit 与字符预算双重截断。 func (t *ListTool) Execute(ctx context.Context, args any) (any, error) { m, ok := args.(map[string]any) if !ok { return nil, fmt.Errorf("%s: args must be an object", ToolName) } kind, err := listKindArg(m) if err != nil { return nil, err } out := t.summary(ctx, kind, listLimitArg(m)) t.log.Debug("capability list served", "kind", string(kind), "total", out.Total, "entries", len(out.Entries), "truncated", out.Truncated) return out, nil } // summary 组装清单:先统计全量 kinds,再按 kind 过滤出确定序的条目并施加两道边界, // 最后附上状态源摘要。它不修改注册表(List 返回的是调用方所有的副本)。 func (t *ListTool) summary(ctx context.Context, kind Kind, limit int) ListSummary { all := t.reg.List("") counts := make(map[string]int, maxListKindNames) for _, cap := range all { counts[string(cap.Kind)]++ } out := ListSummary{Counts: counts, Total: len(all), Entries: []ListEntry{}} picked, truncated := boundedEntries(all, kind, limit) out.Entries = picked out.Truncated = truncated if truncated { out.Note = fmt.Sprintf("entries truncated to %d of %d matching capabilities", len(picked), matchingCount(all, kind)) } out.Statuses = t.statuses(ctx) return out } // boundedEntries 按 kind 过滤、按 (kind, name) 排序,并在条目数与字符预算两处截断。 func boundedEntries(all []Capability, kind Kind, limit int) ([]ListEntry, bool) { filtered := make([]Capability, 0, len(all)) for _, cap := range all { if kind != "" && cap.Kind != kind { continue } filtered = append(filtered, cap) } sort.Slice(filtered, func(i, j int) bool { if filtered[i].Kind != filtered[j].Kind { return filtered[i].Kind < filtered[j].Kind } return filtered[i].Name < filtered[j].Name }) out := make([]ListEntry, 0, len(filtered)) budget := summaryCharBudget truncated := false for _, cap := range filtered { if len(out) >= limit { truncated = true break } entry := ListEntry{ Name: cap.Name, Kind: string(cap.Kind), Description: truncateText(cap.Description, maxDescriptionChars), OwnerOnly: ownerOnly(cap.Policy), } cost := entryCost(entry) if cost > budget { truncated = true break } budget -= cost out = append(out, entry) } return out, truncated } // matchingCount 返回按 kind 过滤后匹配的能力数(截断说明里用;kind 为空即全部)。 func matchingCount(all []Capability, kind Kind) int { n := 0 for _, cap := range all { if kind == "" || cap.Kind == kind { n++ } } return n } // entryCost 估算一个条目在 JSON 里的字符开销(字段名 + 值 + 结构符), // 用于字符预算截断:它必须**高估**而非低估,否则预算会被绕过。 func entryCost(e ListEntry) int { return len(e.Name) + len(e.Kind) + len(e.Description) + 96 } // statuses 逐个读取注入的状态源(读失败如实回报 error,绝不吞掉), // 每个摘要单独截断到 statusCharLimit。 func (t *ListTool) statuses(ctx context.Context) []ListStatus { if len(t.sources) == 0 { return nil } out := make([]ListStatus, 0, len(t.sources)) for _, src := range t.sources { name := src.Name() text, err := src.Status(ctx) if err != nil { t.log.Warn("capability list status source failed", "status_source", name, "error", err) out = append(out, ListStatus{Name: name, Error: truncateText(err.Error(), statusCharLimit)}) continue } out = append(out, ListStatus{Name: name, Text: truncateText(text, statusCharLimit)}) } return out } // listKindArg 解析可选的 kind 过滤参数:空/缺失 = 不过滤;不在闭集里则报错, // 绝不静默当成「不过滤」(那是把错参数伪装成成功)。 func listKindArg(m map[string]any) (Kind, error) { raw, _ := m["kind"].(string) raw = strings.TrimSpace(raw) if raw == "" { return "", nil } if !validKind(Kind(raw)) { return "", fmt.Errorf("%s: unknown kind %q (want one of %s)", ToolName, raw, strings.Join(listKinds(), ", ")) } return Kind(raw), nil } // listLimitArg 解析可选的 limit 参数并夹到 [1, maxListLimit];缺失或类型不对 // 一律用 defaultListLimit(这是本工具自己的边界口径,不是通用类型强转)。 func listLimitArg(m map[string]any) int { switch v := m["limit"].(type) { case int: return clampListLimit(v) case int32: return clampListLimit(int(v)) case int64: return clampListLimit(int(v)) case float32: return clampListLimit(int(v)) case float64: return clampListLimit(int(v)) case json.Number: n, err := v.Int64() if err != nil { return defaultListLimit } return clampListLimit(int(n)) default: return defaultListLimit } } // clampListLimit 把 limit 夹进 [1, maxListLimit]:非正值退回默认,超上限夹到上限。 func clampListLimit(n int) int { if n <= 0 { return defaultListLimit } if n > maxListLimit { return maxListLimit } return n } // ownerOnly 报告一份策略是否 owner-only(两个标记都 false,fail-closed 默认)。 func ownerOnly(p ToolPolicy) bool { return !p.AllowInGroup && !p.AllowInStranger } // truncateText 把 s 截断到 n 个字符以内(按 rune 计,避免切断多字节字符), // 超长时追加省略号。n 非正表示不截断。 func truncateText(s string, n int) string { if n <= 0 || len(s) <= n { return s } runes := []rune(s) if len(runes) <= n { return s } return string(runes[:n]) + "…" } // listLog 返回来源标为 capability/registry 的日志器,nil 换成丢弃输出的日志器, // 使清单工具在叶子测试里也能安全记日志(项目规范 §五:禁止无来源裸日志)。 func listLog(l *slog.Logger) *slog.Logger { if l == nil { l = slog.New(slog.NewTextHandler(io.Discard, nil)) } return logger.WithSource(l, "capability/registry") }