feat(backthink): 思考轮工具面补齐(裁决 15)—— 能力面接入 + 只读 capability_list + 本轮状态快照
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 缺口 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:
AskaEth committed 2026-10-05 13:54:57 +08:00
1 parent 34cef95e17
commit 321cc3b763
14 files changed
+1664 -73

No files matched your search

+183
View File
@@ -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]) + "…"
}