Files
AskaEth 321cc3b763
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(backthink): 思考轮工具面补齐(裁决 15)—— 能力面接入 + 只读 capability_list + 本轮状态快照
- 缺口 d:状态在**思考前**读一次,同一份快照既喂思考轮又喂 Decide(读不到 = Known=false,绝不伪装成中性状态)
- 缺口 c:新增只读工具 capability_list(owner-only、三重输出边界、读写隔离),她可自查当下真实可用面
- 缺口 b:内置允许集补 transcode / capability_list(此前 CAPACITY_ENABLED=true 时新工具会从模型可见面消失)
- 缺口 e:guidancePrompt 旧文案承诺的「IoT/日历/提醒」在全仓根本没有对应工具(chat 侧也没有)⇒ 按诚实化改为「与主人对话同等工具面 + 指名自查入口」,不再有空头支票
- (a) 未做且**不自创**:那三把工具无任何设计规格(名字/schema/读写边界/输出上限)⇒ 已报回并登记进 design/20(裁决:走能力/插件/MCP 暴露路线)
- 变异实测:允许集去掉新工具 ⇒ TestDecide_AllowedSets + 组合根跨层测试红;丢掉状态快照 ⇒ TestLoopThinker_Think_InjectsRoundStateBlock 红
- 已知弱项(如实登记):组合根那行 wireCapabilitySurface() 调用本身无测试覆盖(与既有 wireTranscode 同惯例)
2026-10-05 13:54:57 +08:00

375 lines
13 KiB
Go
Raw Permalink 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
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")
}