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 同惯例)
This commit is contained in:
1 parent
34cef95e17
commit
321cc3b763
14 files changed
+1664
-73
No files matched your search
@@ -0,0 +1,374 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// listProbeTool 是只为「进注册表」而存在的 sdk.Tool 替身:清单工具只读它的
|
||||
// 名字/说明,从不执行它。
|
||||
type listProbeTool string
|
||||
|
||||
func (t listProbeTool) Name() string { return string(t) }
|
||||
func (t listProbeTool) Description() string { return "probe tool " + string(t) }
|
||||
func (t listProbeTool) Parameters() (map[string]any, error) {
|
||||
return map[string]any{"type": "object"}, nil
|
||||
}
|
||||
func (t listProbeTool) Execute(context.Context, any) (any, error) { return nil, nil }
|
||||
|
||||
// listProbeSource 是一个只读状态源替身,记录被读次数并返回固定文本。
|
||||
type listProbeSource struct {
|
||||
name string
|
||||
text string
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *listProbeSource) Name() string { return s.name }
|
||||
|
||||
func (s *listProbeSource) Status(context.Context) (string, error) {
|
||||
s.calls++
|
||||
return s.text, s.err
|
||||
}
|
||||
|
||||
// seedListRegistry 造一个装了若干能力(含非工具类)的注册表。
|
||||
func seedListRegistry(t *testing.T) *Registry {
|
||||
t.Helper()
|
||||
r := New()
|
||||
for _, name := range []string{"zeta", "alpha"} {
|
||||
if err := r.RegisterTool(listProbeTool(name)); err != nil {
|
||||
t.Fatalf("RegisterTool(%s): %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := r.Register(Capability{Name: "iot", Kind: KindIoT, Description: "peripherals", Value: struct{}{}}); err != nil {
|
||||
t.Fatalf("Register(iot): %v", err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// snapshotRegistry 记下注册表的可比较快照(名字 → 类型 + 策略),
|
||||
// 用于断言清单工具**没有**改动注册表(只读红线)。List 的遍历顺序来自 map,
|
||||
// 因此快照必须先排序,否则比较的是 map 迭代顺序而不是内容。
|
||||
func snapshotRegistry(r *Registry) string {
|
||||
caps := r.List("")
|
||||
lines := make([]string, 0, len(caps))
|
||||
for _, cap := range caps {
|
||||
lines = append(lines, fmt.Sprintf("%s|%s|%t|%t|%s",
|
||||
cap.Name, cap.Kind, cap.Policy.AllowInGroup, cap.Policy.AllowInStranger, cap.Description))
|
||||
}
|
||||
sort.Strings(lines)
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// TestListTool_IsReadOnly 断言「只读、无副作用」:执行前后注册表逐字段不变
|
||||
// (变异点:在 Execute 里加任何 Register/Remove/SetPolicy ⇒ 本用例变红)。
|
||||
func TestListTool_IsReadOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := seedListRegistry(t)
|
||||
before := snapshotRegistry(reg)
|
||||
|
||||
lt, err := NewListTool(reg, WithStatusSource(&listProbeSource{name: "iot", text: "devices=1"}))
|
||||
if err != nil {
|
||||
t.Fatalf("NewListTool: %v", err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := lt.Execute(context.Background(), map[string]any{}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
}
|
||||
if after := snapshotRegistry(reg); after != before {
|
||||
t.Fatalf("清单工具改动了注册表(只读红线):\nbefore=%s\nafter=%s", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListTool_OutputIsBounded 断言输出有界,且**三道边界各自独立可观测**
|
||||
// (entry 数上限、limit 夹持、字符预算):
|
||||
//
|
||||
// - 短描述下 limit 的两道口径都咬得住(默认 50 条 / 显式超大值被夹到 200 条);
|
||||
// - 长描述下由字符预算兜底(条数远小于 maxListLimit),序列化后仍装得进预算。
|
||||
func TestListTool_OutputIsBounded(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
short := seedListEntries(t, 400, "d")
|
||||
shortTool, err := NewListTool(short)
|
||||
if err != nil {
|
||||
t.Fatalf("NewListTool: %v", err)
|
||||
}
|
||||
|
||||
// 默认口径:不写 limit 时最多 defaultListLimit 条。
|
||||
def, err := shortTool.Execute(context.Background(), map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
defSum := def.(ListSummary)
|
||||
if len(defSum.Entries) != defaultListLimit {
|
||||
t.Fatalf("不写 limit 时 entries = %d, want %d(默认条目上限)", len(defSum.Entries), defaultListLimit)
|
||||
}
|
||||
if defSum.Total != 400 || !defSum.Truncated || defSum.Note == "" {
|
||||
t.Fatalf("截断必须显式标注且统计为全量:total=%d truncated=%v note=%q",
|
||||
defSum.Total, defSum.Truncated, defSum.Note)
|
||||
}
|
||||
|
||||
// 夹持口径:显式要一个荒谬大的 limit,也只能拿到 maxListLimit 条。
|
||||
clamped, err := shortTool.Execute(context.Background(), map[string]any{"limit": float64(maxListLimit * 25)})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if n := len(clamped.(ListSummary).Entries); n != maxListLimit {
|
||||
t.Fatalf("limit=%d 时 entries = %d, want %d(limit 未被夹到上限)", maxListLimit*25, n, maxListLimit)
|
||||
}
|
||||
|
||||
// 小 limit 口径:要几条就给几条。
|
||||
small, err := shortTool.Execute(context.Background(), map[string]any{"limit": float64(3)})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute(limit=3): %v", err)
|
||||
}
|
||||
if n := len(small.(ListSummary).Entries); n != 3 {
|
||||
t.Fatalf("limit=3 时 entries = %d, want 3", n)
|
||||
}
|
||||
|
||||
// 预算口径:长描述下由字符预算咬住(早于 maxListLimit),且序列化后装得进预算。
|
||||
longTool, err := NewListTool(seedListEntries(t, 400, strings.Repeat("很长的说明", 200)))
|
||||
if err != nil {
|
||||
t.Fatalf("NewListTool: %v", err)
|
||||
}
|
||||
huge, err := longTool.Execute(context.Background(), map[string]any{"limit": float64(maxListLimit * 25)})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
hugeSum := huge.(ListSummary)
|
||||
if len(hugeSum.Entries) >= maxListLimit {
|
||||
t.Fatalf("长描述下 entries = %d,字符预算没咬住(want < %d)", len(hugeSum.Entries), maxListLimit)
|
||||
}
|
||||
if !hugeSum.Truncated {
|
||||
t.Fatal("预算截断必须标注 truncated=true")
|
||||
}
|
||||
for _, e := range hugeSum.Entries {
|
||||
if len([]rune(e.Description)) > maxDescriptionChars+1 {
|
||||
t.Fatalf("description 未被截断:%d runes", len([]rune(e.Description)))
|
||||
}
|
||||
}
|
||||
raw, err := json.Marshal(hugeSum)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if len(raw) > summaryCharBudget+4096 {
|
||||
t.Fatalf("序列化后 %d 字节,超出字符预算 %d 的允许余量", len(raw), summaryCharBudget)
|
||||
}
|
||||
}
|
||||
|
||||
// seedListEntries 造一个装了 n 个同描述工具能力的注册表(描述长度决定咬住输出的
|
||||
// 是条目上限还是字符预算)。
|
||||
func seedListEntries(t *testing.T, n int, desc string) *Registry {
|
||||
t.Helper()
|
||||
reg := New()
|
||||
for i := 0; i < n; i++ {
|
||||
name := fmt.Sprintf("tool-%03d", i)
|
||||
if err := reg.Register(Capability{
|
||||
Name: name,
|
||||
Kind: KindTool,
|
||||
Description: desc,
|
||||
Value: listProbeTool(name),
|
||||
}); err != nil {
|
||||
t.Fatalf("Register(%s): %v", name, err)
|
||||
}
|
||||
}
|
||||
return reg
|
||||
}
|
||||
|
||||
// TestListTool_ListsKindsAndFilters 断言清单如实列出各 kind(含非工具类)并能按
|
||||
// kind 过滤;排序确定(kind → name),因此两次调用逐字一致。
|
||||
func TestListTool_ListsKindsAndFilters(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := seedListRegistry(t)
|
||||
lt, err := NewListTool(reg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewListTool: %v", err)
|
||||
}
|
||||
|
||||
all, err := lt.Execute(context.Background(), map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
sum := all.(ListSummary)
|
||||
if sum.Total != 3 || sum.Counts[string(KindTool)] != 2 || sum.Counts[string(KindIoT)] != 1 {
|
||||
t.Fatalf("counts = %+v total=%d, want tool=2 iot=1 total=3", sum.Counts, sum.Total)
|
||||
}
|
||||
names := make([]string, 0, len(sum.Entries))
|
||||
for _, e := range sum.Entries {
|
||||
names = append(names, e.Kind+":"+e.Name)
|
||||
}
|
||||
want := []string{"iot:iot", "tool:alpha", "tool:zeta"}
|
||||
if strings.Join(names, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("entries = %v, want %v(按 kind+name 确定序)", names, want)
|
||||
}
|
||||
for _, e := range sum.Entries {
|
||||
if !e.OwnerOnly {
|
||||
t.Fatalf("条目 %s 报告 ownerOnly=false,但零值策略应为 owner-only(fail-closed)", e.Name)
|
||||
}
|
||||
}
|
||||
|
||||
filtered, err := lt.Execute(context.Background(), map[string]any{"kind": string(KindIoT)})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute(kind=iot): %v", err)
|
||||
}
|
||||
onlyIoT := filtered.(ListSummary)
|
||||
if len(onlyIoT.Entries) != 1 || onlyIoT.Entries[0].Name != "iot" || onlyIoT.Entries[0].Kind != string(KindIoT) {
|
||||
t.Fatalf("kind=iot 过滤结果 = %+v", onlyIoT.Entries)
|
||||
}
|
||||
|
||||
if _, err := lt.Execute(context.Background(), map[string]any{"kind": "nope"}); err == nil {
|
||||
t.Fatal("未知 kind 必须报错,不得被静默当成不过滤")
|
||||
}
|
||||
if _, err := lt.Execute(context.Background(), "not-an-object"); err == nil {
|
||||
t.Fatal("非对象 args 必须报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestListTool_ReportsStatusSources 断言注入的只读状态源被读出并回报,
|
||||
// 失败**如实回报 error**、绝不吞掉(诚实红线)。
|
||||
func TestListTool_ReportsStatusSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := seedListRegistry(t)
|
||||
ok := &listProbeSource{name: "iot", text: " 洗衣机 · 运行中"}
|
||||
bad := &listProbeSource{name: "calendar", err: errors.New("provider offline")}
|
||||
lt, err := NewListTool(reg, WithStatusSource(ok), WithStatusSource(bad))
|
||||
if err != nil {
|
||||
t.Fatalf("NewListTool: %v", err)
|
||||
}
|
||||
got, err := lt.Execute(context.Background(), map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
sum := got.(ListSummary)
|
||||
if len(sum.Statuses) != 2 {
|
||||
t.Fatalf("statuses = %+v, want 2", sum.Statuses)
|
||||
}
|
||||
if sum.Statuses[0].Name != "iot" || !strings.Contains(sum.Statuses[0].Text, "洗衣机") || sum.Statuses[0].Error != "" {
|
||||
t.Fatalf("iot status = %+v", sum.Statuses[0])
|
||||
}
|
||||
if sum.Statuses[1].Error == "" {
|
||||
t.Fatalf("读取失败的状态源必须带 error,got %+v", sum.Statuses[1])
|
||||
}
|
||||
if ok.calls != 1 || bad.calls != 1 {
|
||||
t.Fatalf("status source calls = %d/%d, want 1/1", ok.calls, bad.calls)
|
||||
}
|
||||
|
||||
// 未注入状态源时该字段缺席(不伪造一份空状态)。
|
||||
plain, err := NewListTool(reg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewListTool: %v", err)
|
||||
}
|
||||
noStatus, err := plain.Execute(context.Background(), map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if s := noStatus.(ListSummary); len(s.Statuses) != 0 {
|
||||
t.Fatalf("未注入状态源时 statuses 必须为空, got %+v", s.Statuses)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListTool_ContractAndSchema 锁定工具面契约:名字、schema 形状(两个可选参数、
|
||||
// kind 是闭集 enum)与 nil 注册表拒绝。
|
||||
func TestListTool_ContractAndSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := NewListTool(nil); err == nil {
|
||||
t.Fatal("nil 注册表必须被拒(不得返回一份空清单冒充成功)")
|
||||
}
|
||||
lt, err := NewListTool(seedListRegistry(t))
|
||||
if err != nil {
|
||||
t.Fatalf("NewListTool: %v", err)
|
||||
}
|
||||
if lt.Name() != ToolName || ToolName != "capability_list" {
|
||||
t.Fatalf("Name = %q, want %q", lt.Name(), ToolName)
|
||||
}
|
||||
schema, err := lt.Parameters()
|
||||
if err != nil {
|
||||
t.Fatalf("Parameters: %v", err)
|
||||
}
|
||||
props, ok := schema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema.properties = %#v", schema["properties"])
|
||||
}
|
||||
if _, hasRequired := schema["required"]; hasRequired {
|
||||
t.Fatal("两个参数都是可选的,schema 不应声明 required")
|
||||
}
|
||||
kindProp, ok := props["kind"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema 缺 kind 属性: %#v", props)
|
||||
}
|
||||
enum, ok := kindProp["enum"].([]string)
|
||||
if !ok || len(enum) != maxListKindNames {
|
||||
t.Fatalf("kind enum = %#v, want %d 个闭集取值", kindProp["enum"], maxListKindNames)
|
||||
}
|
||||
if _, ok := props["limit"].(map[string]any); !ok {
|
||||
t.Fatalf("schema 缺 limit 属性: %#v", props)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user