Files
Cyrene_v2/cmd/cyrene/wire_capability_surface.go
T
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

184 lines
6.3 KiB
Go
Raw 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 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]) + "…"
}