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,183 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// 本文件是 X1(思考轮工具面补齐 / 主人裁决 15)的组合根接线:让**外围子系统**真的
|
||||
// 出现在能力面里,并给她一把只读的自查工具。它只装配,不新造注册机制、不开端口、
|
||||
// 不跨进程(单二进制红线)。
|
||||
|
||||
// capabilitySurfaceLogSource 是本步日志的来源标记。
|
||||
const capabilitySurfaceLogSource = "cmd/cyrene"
|
||||
|
||||
// iotCapabilityName 是 IoT 子系统在能力注册表里的条目名。
|
||||
const iotCapabilityName = "iot"
|
||||
|
||||
// IoT 只读状态摘要的边界(编译期常量):台数、每台的关键状态键数、单个键值长度与
|
||||
// 整段摘要字符数。整段上限必须**小于** registry 侧 statusCharLimit,否则真正生效的
|
||||
// 是那边的截断,而这里以为没截断。
|
||||
const (
|
||||
iotStatusMaxDevices = 20
|
||||
iotStatusMaxKeys = 6
|
||||
iotStatusMaxValueLen = 40
|
||||
iotStatusMaxChars = 380
|
||||
)
|
||||
|
||||
// wireCapabilitySurface 把「她能看见自己有什么、外围在什么状态」这条面接进组合根
|
||||
// (X1 / 裁决 15;design/12 §1「允许调用工具(查设备/心率/日历/天气等)」、
|
||||
// design/20 §2「能调用工具:与主人会话同等的工具集」):
|
||||
//
|
||||
// 1. **外围子系统进能力面**:IoT 以它既有的 `KindIoT` 身份注册进能力注册表 ——
|
||||
// 此前全仓**无人注册 KindIoT**,于是外围子系统在能力面里根本不存在(面板与
|
||||
// 清单都列不出);
|
||||
// 2. **只读自查工具**:`capability_list`(internal/capability/registry 的
|
||||
// ListTool)注册进同一张注册表,并注入 IoT 的只读状态源,使她无论走 chat 还是
|
||||
// 主动思考轮,都能列出「有哪些能力/插件 + 外围状态」。
|
||||
//
|
||||
// 「chat 与 backthink 同一套工具面」不靠本文件成立,而靠**同一个注册表 / 分发器**:
|
||||
// backthink 的 LoopThinker 包的就是 `a.loop`(wire_services.go),而 `a.loop` 的工具面
|
||||
// 就是 `a.dispatcher`(wire_runtime.go),它建立在 `a.caps` 之上。因此这里绝不再造
|
||||
// 第二套注册机制 —— 把工具塞进 `a.caps` 就同时被两条车道看见。
|
||||
//
|
||||
// 失败只告警、绝不阻塞启动:本步只影响「清单可见性」,其余子系统不受影响。
|
||||
func (a *app) wireCapabilitySurface() {
|
||||
log := logger.WithSource(a.log, capabilitySurfaceLogSource)
|
||||
if a.caps == nil {
|
||||
log.Warn("capability surface skipped: no capability registry")
|
||||
return
|
||||
}
|
||||
a.registerIoTCapability(log)
|
||||
|
||||
opts := make([]registry.ListOption, 0, 2)
|
||||
if a.iot != nil {
|
||||
opts = append(opts, registry.WithStatusSource(iotStatusSource{mgr: a.iot}))
|
||||
}
|
||||
opts = append(opts, registry.WithListLogger(a.log))
|
||||
|
||||
listTool, err := registry.NewListTool(a.caps, opts...)
|
||||
if err != nil {
|
||||
log.Warn("capability list tool build skipped", "error", err)
|
||||
return
|
||||
}
|
||||
if a.dispatcher == nil {
|
||||
log.Warn("capability list tool register skipped: no dispatcher")
|
||||
return
|
||||
}
|
||||
if err := a.dispatcher.Register(listTool); err != nil {
|
||||
log.Warn("capability list tool register skipped", "error", err)
|
||||
return
|
||||
}
|
||||
log.Info("capability list tool registered", "tool", registry.ToolName, "iot_status", a.iot != nil)
|
||||
}
|
||||
|
||||
// registerIoTCapability 把 IoT 门面以 KindIoT 注册进能力注册表。已注册(重名)时
|
||||
// 只告警:能力面里已经有一条,不是启动失败的理由。
|
||||
func (a *app) registerIoTCapability(log *slog.Logger) {
|
||||
if a.iot == nil {
|
||||
return
|
||||
}
|
||||
err := a.caps.Register(registry.Capability{
|
||||
Name: iotCapabilityName,
|
||||
Kind: registry.KindIoT,
|
||||
Description: "IoT peripherals (device list, status, control)",
|
||||
Value: a.iot,
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn("iot capability register skipped", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// iotStatusSource 把 IoT 子系统接成清单工具的**只读**状态源:它只调 Manager.List
|
||||
// (纯读),按固定上限渲染设备清单,绝不 Control、绝不改任何设备状态。
|
||||
type iotStatusSource struct {
|
||||
mgr *iot.Manager
|
||||
}
|
||||
|
||||
// Name 返回状态分组名。
|
||||
func (s iotStatusSource) Name() string { return iotCapabilityName }
|
||||
|
||||
// Status 渲染一份有界的只读摘要:设备/在线台数,然后是每台设备一行
|
||||
// (名称/类型/在线/关键状态键值)。超过上限的台数与字符都被截断并显式标注。
|
||||
func (s iotStatusSource) Status(ctx context.Context) (string, error) {
|
||||
if s.mgr == nil {
|
||||
return "", fmt.Errorf("capability surface: iot status: %w", iot.ErrProviderNotRegistered)
|
||||
}
|
||||
devices, err := s.mgr.List(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("capability surface: iot status: %w", err)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "devices=%d online=%d", len(devices), onlineCount(devices))
|
||||
rendered := 0
|
||||
for _, d := range devices {
|
||||
if rendered >= iotStatusMaxDevices {
|
||||
break
|
||||
}
|
||||
line := "\n" + deviceLine(d)
|
||||
if b.Len()+len(line) > iotStatusMaxChars {
|
||||
break
|
||||
}
|
||||
b.WriteString(line)
|
||||
rendered++
|
||||
}
|
||||
if rendered < len(devices) {
|
||||
fmt.Fprintf(&b, "\n…(另有 %d 台未列出)", len(devices)-rendered)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// onlineCount 统计在线设备台数。
|
||||
func onlineCount(devices []iot.Device) int {
|
||||
n := 0
|
||||
for _, d := range devices {
|
||||
if d.Online {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// deviceLine 把一台设备渲染成单行只读摘要:名称(类型,在线/离线):键=值,…
|
||||
// 状态键按字典序取前 iotStatusMaxKeys 个,保证同一台设备每次渲染一致。
|
||||
func deviceLine(d iot.Device) string {
|
||||
status := "离线"
|
||||
if d.Online {
|
||||
status = "在线"
|
||||
}
|
||||
keys := make([]string, 0, len(d.State))
|
||||
for k := range d.State {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
pairs := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if len(pairs) >= iotStatusMaxKeys {
|
||||
break
|
||||
}
|
||||
pairs = append(pairs, k+"="+truncateValue(d.State[k]))
|
||||
}
|
||||
line := fmt.Sprintf("%s(%s,%s)", d.Name, d.Type, status)
|
||||
if len(pairs) > 0 {
|
||||
line += ":" + strings.Join(pairs, ",")
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// truncateValue 把设备状态值渲染成有界文本:非文本值走 %v,超长即截断。
|
||||
func truncateValue(v any) string {
|
||||
s := fmt.Sprintf("%v", v)
|
||||
if len([]rune(s)) <= iotStatusMaxValueLen {
|
||||
return s
|
||||
}
|
||||
return string([]rune(s)[:iotStatusMaxValueLen]) + "…"
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
// surfaceApp 组装一个只含能力面所需的 app:注册表 + 分发表 + mock IoT 子系统。
|
||||
// 它走的是与生产相同的两个入口(wireTranscode / wireCapabilitySurface),
|
||||
// 因此断言的是真实接线,而不是测试自造的替身。
|
||||
func surfaceApp(t *testing.T) *app {
|
||||
t.Helper()
|
||||
|
||||
caps := registry.New()
|
||||
d, err := tool.New(caps)
|
||||
if err != nil {
|
||||
t.Fatalf("tool.New: %v", err)
|
||||
}
|
||||
mgr, err := iot.New(config.IoTConfig{Provider: "mock"}, testLog())
|
||||
if err != nil {
|
||||
t.Fatalf("iot.New: %v", err)
|
||||
}
|
||||
a := &app{log: testLog(), caps: caps, dispatcher: d, iot: mgr}
|
||||
a.cfg = &config.Config{}
|
||||
a.cfg.Capacity.Enabled = true
|
||||
a.wireTranscode()
|
||||
a.wireCapabilitySurface()
|
||||
return a
|
||||
}
|
||||
|
||||
// toolNames 返回注册表里当前所有**可调用工具**的名字(模型真正会看到的那一面)。
|
||||
func toolNames(a *app) []string {
|
||||
caps := a.caps.List(registry.KindTool)
|
||||
out := make([]string, 0, len(caps))
|
||||
for _, c := range caps {
|
||||
out = append(out, c.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestWireCapabilitySurface_RegistersIoTCapabilityAndListTool 断言 (a)+(c) 的组合根
|
||||
// 落地:IoT 子系统以 KindIoT 进能力面(此前全仓无人注册该 kind)、只读的
|
||||
// capability_list 进注册表并被广告给模型,且它读出的外围状态经真实分发器可用。
|
||||
func TestWireCapabilitySurface_RegistersIoTCapabilityAndListTool(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := surfaceApp(t)
|
||||
|
||||
iotCap, err := a.caps.Get(iotCapabilityName)
|
||||
if err != nil {
|
||||
t.Fatalf("IoT 子系统没进能力注册表: %v", err)
|
||||
}
|
||||
if iotCap.Kind != registry.KindIoT {
|
||||
t.Fatalf("IoT 能力类型 = %q, want %q", iotCap.Kind, registry.KindIoT)
|
||||
}
|
||||
if _, ok := iotCap.Value.(*iot.Manager); !ok {
|
||||
t.Fatalf("IoT 能力值类型 = %T, want *iot.Manager", iotCap.Value)
|
||||
}
|
||||
|
||||
listCap, err := a.caps.Get(registry.ToolName)
|
||||
if err != nil {
|
||||
t.Fatalf("capability_list 没进能力注册表: %v", err)
|
||||
}
|
||||
if listCap.Kind != registry.KindTool {
|
||||
t.Fatalf("清单能力类型 = %q, want %q", listCap.Kind, registry.KindTool)
|
||||
}
|
||||
if listCap.Policy.AllowInGroup || listCap.Policy.AllowInStranger {
|
||||
t.Fatalf("清单工具的策略必须保持 fail-closed(owner-only),got %+v", listCap.Policy)
|
||||
}
|
||||
|
||||
advertised := false
|
||||
for _, s := range a.dispatcher.ToolSchemas() {
|
||||
if s.Name == registry.ToolName {
|
||||
advertised = true
|
||||
}
|
||||
}
|
||||
if !advertised {
|
||||
t.Fatalf("capability_list 未被广告给模型:%v", toolNames(a))
|
||||
}
|
||||
|
||||
// 走真实分发器(含 JSON-Schema 校验与工具执行),断言外围只读状态真的读得出来。
|
||||
devices, err := a.iot.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("iot.List: %v", err)
|
||||
}
|
||||
val, err := a.dispatcher.Invoke(context.Background(), registry.ToolName, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("Invoke(%s): %v", registry.ToolName, err)
|
||||
}
|
||||
raw, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
text := string(raw)
|
||||
if !strings.Contains(text, fmt.Sprintf(`"devices=%d`, len(devices))) {
|
||||
t.Fatalf("清单没报出外围设备台数(want devices=%d):%s", len(devices), text)
|
||||
}
|
||||
if !strings.Contains(text, `"name":"iot"`) || !strings.Contains(text, `"kind":"iot"`) {
|
||||
t.Fatalf("清单没列出 IoT 子系统条目:%s", text)
|
||||
}
|
||||
if !strings.Contains(text, `"name":"transcode"`) {
|
||||
t.Fatalf("清单没列出已注册的 transcode 工具:%s", text)
|
||||
}
|
||||
|
||||
// 只读:清单调用(含外围状态读取)不得改动任何设备。
|
||||
after, err := a.iot.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("iot.List (after): %v", err)
|
||||
}
|
||||
if got, want := deviceSnapshot(after), deviceSnapshot(devices); got != want {
|
||||
t.Fatalf("清单调用改动了设备状态(只读红线):\nwant=%s\ngot=%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// deviceSnapshot 把设备清单渲染成可比较的快照(按 id 排序,状态键排序)。
|
||||
func deviceSnapshot(devices []iot.Device) string {
|
||||
lines := make([]string, 0, len(devices))
|
||||
for _, d := range devices {
|
||||
keys := make([]string, 0, len(d.State))
|
||||
for k := range d.State {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
pairs := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
pairs = append(pairs, fmt.Sprintf("%s=%v", k, d.State[k]))
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%s|%s|%s|%t|%s", d.ID, d.Name, d.Type, d.Online, strings.Join(pairs, ",")))
|
||||
}
|
||||
sort.Strings(lines)
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// TestWireCapabilitySurface_NewToolsReachableUnderCapacity 断言 (b):CAPACITY_ENABLED=true
|
||||
// 时**新工具**(transcode / capability_list)在静息与唤醒两个内置档位集的允许集内。
|
||||
//
|
||||
// 判据用**注册表里的真实工具名**驱动判定器:若 signals.go 的档位集字面量与注册名
|
||||
// 漂移(例如把 capability_list 写成 capability-list),该名字就不在 AllToolNames 里、
|
||||
// 交集后消失 ⇒ 本用例变红。
|
||||
func TestWireCapabilitySurface_NewToolsReachableUnderCapacity(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := surfaceApp(t)
|
||||
dec := a.buildCapacity()
|
||||
if dec == nil || !dec.Enabled() {
|
||||
t.Fatal("CAPACITY_ENABLED=true 时判定器必须启用")
|
||||
}
|
||||
names := toolNames(a)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
msg string
|
||||
want capacity.State
|
||||
}{
|
||||
{"静息", "你好", capacity.StateQuiet},
|
||||
{"唤醒", "帮我查一下资料", capacity.StateAwake},
|
||||
}
|
||||
for _, c := range cases {
|
||||
dec := dec.Decide(context.Background(), capacity.Input{
|
||||
Messages: []string{c.msg},
|
||||
CurrentState: capacity.StateQuiet,
|
||||
AllToolNames: names,
|
||||
})
|
||||
if dec.State != c.want {
|
||||
t.Fatalf("输入 %q 判定态 = %s, want %s", c.msg, dec.State, c.want)
|
||||
}
|
||||
for _, want := range []string{"transcode", registry.ToolName} {
|
||||
if !containsTool(dec.AllowedTools, want) {
|
||||
t.Fatalf("%s档位允许集缺 %q:%v(新工具被挡住 = 裁决 15 在安静态失效)",
|
||||
c.name, want, dec.AllowedTools)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// containsTool 报告名字是否在允许集里。
|
||||
func containsTool(names []string, want string) bool {
|
||||
for _, n := range names {
|
||||
if n == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -83,6 +83,10 @@ func (a *app) wireAuxiliary() error {
|
||||
a.wireTerminalTool()
|
||||
a.wireSubagentTool()
|
||||
a.wireSessionReadTool()
|
||||
// X1(裁决 15):把外围子系统(IoT)以 KindIoT 放进能力面,并注册只读的
|
||||
// capability_list 工具。排在最后,是为了让它列出的清单已经包含前面各步
|
||||
// 注册进来的工具(terminal / subagent / session_read 等)。
|
||||
a.wireCapabilitySurface()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user