From 321cc3b7631570ef2ecc3e4350b66f54472525fa Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 5 Oct 2026 13:54:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(backthink):=20=E6=80=9D=E8=80=83=E8=BD=AE?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E9=9D=A2=E8=A1=A5=E9=BD=90=EF=BC=88=E8=A3=81?= =?UTF-8?q?=E5=86=B3=2015=EF=BC=89=E2=80=94=E2=80=94=20=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E9=9D=A2=E6=8E=A5=E5=85=A5=20+=20=E5=8F=AA=E8=AF=BB=20capabili?= =?UTF-8?q?ty=5Flist=20+=20=E6=9C=AC=E8=BD=AE=E7=8A=B6=E6=80=81=E5=BF=AB?= =?UTF-8?q?=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 缺口 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 同惯例) --- cmd/cyrene/wire_capability_surface.go | 183 +++++++++ cmd/cyrene/wire_capability_surface_test.go | 192 +++++++++ cmd/cyrene/wire_services.go | 4 + internal/capability/registry/listtool.go | 374 ++++++++++++++++++ internal/capability/registry/listtool_test.go | 312 +++++++++++++++ internal/runtime/capacity/capacity_test.go | 15 +- internal/runtime/capacity/signals.go | 25 +- internal/services/backthink/backthink.go | 97 ++--- internal/services/backthink/context.go | 32 +- internal/services/backthink/roundstate.go | 88 +++++ .../services/backthink/roundstate_test.go | 355 +++++++++++++++++ internal/services/backthink/state.go | 40 ++ internal/services/backthink/thinker.go | 13 +- internal/services/backthink/thinker_test.go | 7 +- 14 files changed, 1664 insertions(+), 73 deletions(-) create mode 100644 cmd/cyrene/wire_capability_surface.go create mode 100644 cmd/cyrene/wire_capability_surface_test.go create mode 100644 internal/capability/registry/listtool.go create mode 100644 internal/capability/registry/listtool_test.go create mode 100644 internal/services/backthink/roundstate.go create mode 100644 internal/services/backthink/roundstate_test.go diff --git a/cmd/cyrene/wire_capability_surface.go b/cmd/cyrene/wire_capability_surface.go new file mode 100644 index 0000000..f7290a1 --- /dev/null +++ b/cmd/cyrene/wire_capability_surface.go @@ -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]) + "…" +} diff --git a/cmd/cyrene/wire_capability_surface_test.go b/cmd/cyrene/wire_capability_surface_test.go new file mode 100644 index 0000000..32ecc01 --- /dev/null +++ b/cmd/cyrene/wire_capability_surface_test.go @@ -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 +} diff --git a/cmd/cyrene/wire_services.go b/cmd/cyrene/wire_services.go index 2c29310..608b97c 100644 --- a/cmd/cyrene/wire_services.go +++ b/cmd/cyrene/wire_services.go @@ -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 } diff --git a/internal/capability/registry/listtool.go b/internal/capability/registry/listtool.go new file mode 100644 index 0000000..dd7ebc6 --- /dev/null +++ b/internal/capability/registry/listtool.go @@ -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") +} diff --git a/internal/capability/registry/listtool_test.go b/internal/capability/registry/listtool_test.go new file mode 100644 index 0000000..20e2dd6 --- /dev/null +++ b/internal/capability/registry/listtool_test.go @@ -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) + } +} diff --git a/internal/runtime/capacity/capacity_test.go b/internal/runtime/capacity/capacity_test.go index 10a3cb5..1a7b406 100644 --- a/internal/runtime/capacity/capacity_test.go +++ b/internal/runtime/capacity/capacity_test.go @@ -7,12 +7,14 @@ import ( ) // allTools is a representative registration-order tool list for tests; it covers -// the basic/io/terminal plugins actually shipped by the repo. +// the basic/io/terminal plugins actually shipped by the repo, plus the two tools +// added to the built-in state sets by X1 (transcode / capability_list). func allTools() []string { return []string{ "terminal", "file", "knowledge", "text", "web-search", "web-fetch", "http", "calculator", "datetime", "random", "json", "markdown", + transcodeToolName, capabilityListToolName, } } @@ -175,13 +177,18 @@ func TestDecide_PersonaMinConfidence_ForcesQuiet(t *testing.T) { func TestDecide_AllowedSets(t *testing.T) { d := New(activeConfig()) + // 内置档位集里的「后加能力」也必须在允许集内(X1 / 裁决 15):登记了能力 + // 却不在集合里 = CAPACITY_ENABLED=true 时它调不动。 + extra := []string{transcodeToolName, capabilityListToolName} + quiet := d.Decide(context.Background(), in("你好")).AllowedTools - if !reflect.DeepEqual(quiet, []string{"terminal", "file", "knowledge", "text"}) { - t.Fatalf("quiet allowed = %v", quiet) + wantQuiet := append([]string{"terminal", "file", "knowledge", "text"}, extra...) + if !reflect.DeepEqual(quiet, wantQuiet) { + t.Fatalf("quiet allowed = %v, want %v", quiet, wantQuiet) } awake := d.Decide(context.Background(), in("帮我查一下资料")).AllowedTools - wantAwake := []string{"terminal", "file", "knowledge", "text", "web-search", "web-fetch", "http"} + wantAwake := append([]string{"terminal", "file", "knowledge", "text", "web-search", "web-fetch", "http"}, extra...) if !reflect.DeepEqual(awake, wantAwake) { t.Fatalf("awake allowed = %v, want %v", awake, wantAwake) } diff --git a/internal/runtime/capacity/signals.go b/internal/runtime/capacity/signals.go index 3a286fc..964e95b 100644 --- a/internal/runtime/capacity/signals.go +++ b/internal/runtime/capacity/signals.go @@ -7,17 +7,38 @@ import ( "strings" ) +// 内置档位集里「后加的能力」的工具名(字面量,与既有条目同一口径: +// 这两份集合是**策略清单**,不反向依赖具体能力包)。两处都登记是 X1 的 +// 硬要求 —— 能力注册了却不在允许集里,等于 CAPACITY_ENABLED=true 时它调不动: +// +// - "transcode"(internal/capability/transcode,design/25):纯本地、有输入上限 +// 与并发闸,属陪伴侧真正会用的能力(语音转写); +// - "capability_list"(internal/capability/registry 的 ToolName,X1 / 裁决 15): +// 她自查「我有哪些能力/插件、外围子系统什么状态」的**只读**抓手,必须在她 +// 思考时所处的静息态就可见,否则这条裁决在安静态失效。 +// +// 重命名任一个 ⇒ internal/runtime/capacity/capacity_test.go 的 +// TestDecide_AllowedSets 与 cmd/cyrene 的组合根断言同时变红(见 X1 报告变异表)。 +const ( + transcodeToolName = "transcode" + capabilityListToolName = "capability_list" +) + // defaultQuietTools is the companion-safe face (design §1): terminal + file edit // + memory/emotion companion. It is a function building the slice fresh so no // package-level global slice is introduced (project rule §10). func defaultQuietTools() []string { - return []string{"terminal", "file", "knowledge", "text"} + return []string{"terminal", "file", "knowledge", "text", transcodeToolName, capabilityListToolName} } // defaultAwakeTools extends the quiet face with web/network retrieval (design // §1 唤醒态): a real task wakes the retrieval tools, file and terminal. func defaultAwakeTools() []string { - return []string{"terminal", "file", "knowledge", "text", "web-search", "web-fetch", "http"} + return []string{ + "terminal", "file", "knowledge", "text", + "web-search", "web-fetch", "http", + transcodeToolName, capabilityListToolName, + } } // intent classifies the round. It runs L1 on the current turn, falls to L2 on diff --git a/internal/services/backthink/backthink.go b/internal/services/backthink/backthink.go index 5ad1fa8..8a71202 100644 --- a/internal/services/backthink/backthink.go +++ b/internal/services/backthink/backthink.go @@ -4,7 +4,8 @@ // 它在两类触发下运行无人值守的思考轮: // // - 周期性心跳(config.Background.IntervalSec),请求注入的运行时 -// 反思,并允许调用工具(IoT、日历、提醒), +// 反思,并允许调用工具(与主人对话**同一套**工具面:同一个 +// loop / dispatcher / 能力注册表,见 thinker.go 的 LoopThinker), // 但默认不得发出 chat 风格的消息。 // - 进程内总线上的异步事件(iot.device_update、 // reminder.trigger、system.event),它们唤醒一轮思考。 @@ -13,7 +14,9 @@ // 到底想不想说话(design/20 §2 P2),随后咨询 Decide—— // 作为生产决策路径恢复的重要度级联——来判断此刻是否 // 合适(事件重要度 / 时段 / 用户状态,如心率、 -// 睡眠、工作、忙碌)。critical 安全事件穿透工作/忙碌 +// 睡眠、工作、忙碌)。用户状态在**思考之前**读一次,同一份快照同时喂给 +// 思考轮(经 WithRoundState 渲染成上下文块)与这里的决策(X1 缺口 d)。 +// critical 安全事件穿透工作/忙碌 // 与安静时段(裁决 07.4.2),而心跳轮永远不会被长期 // 静默的活动间隔拦下(裁决 07.4.3)。当值得打扰时, // 推送先走活跃客户端,再降级到 IM 桥。 @@ -40,7 +43,6 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" "git.yeij.top/AskaEth/Cyrene_v2/internal/iot" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" - "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) // 本包返回的哨兵错误。用 errors.Is 判定;它们被直接 @@ -57,10 +59,15 @@ var ( // Thinker 在注入的运行时上跑一轮无人值守的思考。它是 // 服务与 loop/agent 之间的接缝:生产接线交回一个基于 // *loop.Loop 的 LoopThinker,而测试注入 mock。实现可以 -// 调用工具(IoT、日历、提醒),且必须并发安全。 +// 调用**与主人对话同一套**已注册工具(工具面 = 运行时的能力注册表, +// 不是本包枚举出来的清单),且必须并发安全。 type Thinker interface { // Think 为给定的会话/触发调用一轮自主思考, // 并返回结论文本。 + // + // 本轮的用户状态快照经 ctx 挂载(WithRoundState / RoundStateFrom): + // 实现**可选**读取它并把状态写进自己的上下文(LoopThinker 就是这么做的), + // 不读的实现(旧实现/测试替身)行为逐字不变。 Think(ctx context.Context, sessionID, trigger string) (string, error) } @@ -350,7 +357,13 @@ func (b *Backthink) think(parent context.Context, tr Trigger) { b.log.Debug("backthink trigger", "source", tr.Source, "summary", tr.Summary) - conclusion, err := b.deps.Thinker.Think(cctx, b.sessionID, tr.Summary) + // X1(缺口 d):用户状态在**思考之前**读一次,随后同时交给思考轮(经 ctx)与打扰决策。 + // 此前它只在思考返回后读取、只喂 Decide,于是她写结论时不知道主人在睡/在忙,语气与时机 + // 判断和真正生效的打扰门脱节;现在两侧是**同一份**快照(读取失败时 Known=false, + // 思考轮不注入状态块、决策按中性状态走,行为与接线前一致)。 + state, stateKnown := b.currentState(cctx) + thinkCtx := WithRoundState(cctx, RoundState{State: state, Known: stateKnown}) + conclusion, err := b.deps.Thinker.Think(thinkCtx, b.sessionID, tr.Summary) if err != nil { b.log.Error("backthink think failed", "source", tr.Source, "error", err) b.record(Record{ @@ -369,7 +382,6 @@ func (b *Backthink) think(parent context.Context, tr Trigger) { message, channelHint, push := parseProactive(conclusion) roundImport := b.escalatedImport(tr, conclusion) - state := b.currentState(cctx) lastActivity, hasActivity := b.lastUserActivity(cctx) // 裁决 07.4.2:importance 级联恢复为生产决策路径——她的结论回答「想不想说」, // Decide 只判「此刻能不能说」:critical 安全事件穿透工作/忙碌/安静时段,安静 @@ -392,21 +404,7 @@ func (b *Backthink) think(parent context.Context, tr Trigger) { recordConclusion = conclusion } - pushRes := PushResult{Route: RouteNone, Detail: decision.Reason} - if shouldPush { - // 同一事件去重:重复的相同事件在首次推送后被抑制 - // (防刷),而真正的新事件仍会发出。心跳 - // 从不去重(它们会重置该链),并保持受门控管辖。 - if b.eventRedundant(tr) { - pushRes = PushResult{Route: RouteNone, Detail: "duplicate event push suppressed"} - } else { - pushRes = b.currentPusher().Push(cctx, PushRequest{ - Message: b.buildProactiveMessage(message, channelHint), - ChannelHint: channelHint, - TriggerKind: tr.Kind, - }) - } - } + pushRes := b.deliver(cctx, tr, message, channelHint, shouldPush, decision.Reason) b.record(Record{ Trigger: tr.Source, @@ -423,6 +421,26 @@ func (b *Backthink) think(parent context.Context, tr Trigger) { } } +// deliver 投递一轮结论(已由 Decide 判完能不能说):值得打断时先过同一事件去重, +// 再交给推送器。它不做任何决策,只把「决定 + 结论」变成一条 PushResult —— 抽出来是 +// 为了让 think 保持在项目规范的可读长度内(函数 ≤80 行)。 +// +// 同一事件去重:重复的相同事件在首次推送后被抑制(防刷),而真正的新事件仍会发出。 +// 心跳从不去重(它们会重置该链),并保持受门控管辖。 +func (b *Backthink) deliver(ctx context.Context, tr Trigger, message, channelHint string, shouldPush bool, reason string) PushResult { + if !shouldPush { + return PushResult{Route: RouteNone, Detail: reason} + } + if b.eventRedundant(tr) { + return PushResult{Route: RouteNone, Detail: "duplicate event push suppressed"} + } + return b.currentPusher().Push(ctx, PushRequest{ + Message: b.buildProactiveMessage(message, channelHint), + ChannelHint: channelHint, + TriggerKind: tr.Kind, + }) +} + // escalatedImport 解出本轮打扰决策所用的重要度:触发自带的重要度,叠上她自己结论里 // 声明的紧急度(结构化标记 [紧急:...],见 parseProactiveUrgency)。这让她在主动思考轮 // 里读完外围插件数据、调过工具后,能声明「我认为这条紧急、要突破安静时段发出去」 @@ -442,41 +460,8 @@ func (b *Backthink) escalatedImport(tr Trigger, conclusion string) Importance { return escalated } -// currentState 读取用户当前状况用于打扰决策, -// 提供者报错时退回中性状态(并记一条 warn 日志)。 -func (b *Backthink) currentState(ctx context.Context) UserState { - if b.deps.State == nil { - return UserState{} - } - s, err := b.deps.State.Current(ctx) - if err != nil { - b.log.Warn("backthink state provider failed", "error", err) - return UserState{} - } - return s -} - -// lastUserActivity 报告主人最近一条消息的墙钟时间 -// (在主会话里),以及是否找到了任何用户消息。它是活动 -// 门的活动信号(design/12 §6):提供者为 nil、读取失败或 -// 尾部为空都得到 has=false,因此活动门会落到当前 -// 行为而不是靠猜。最近的一条用户消息是最近尾部里 -// 最新的 RoleUser 条目(Recent 返回的是最旧在前)。 -func (b *Backthink) lastUserActivity(ctx context.Context) (time.Time, bool) { - if b.deps.Recent == nil { - return time.Time{}, false - } - msgs, err := b.deps.Recent.Recent(ctx, history.MainSessionID, recentContextCount) - if err != nil { - return time.Time{}, false - } - for i := len(msgs) - 1; i >= 0; i-- { - if msgs[i].Role == history.RoleUser { - return time.UnixMilli(msgs[i].CreatedAt), true - } - } - return time.Time{}, false -} +// currentState 与 lastUserActivity 在本包的状态面(state.go)里: +// 前者现在同时回答「读到了没」(X1 缺口 d),后者是活动门的活动信号。 // record 持久化一轮思考。它使用后台 context,因此即便 // 该次思考自身的 context 已被取消,审计行仍会写入。 diff --git a/internal/services/backthink/context.go b/internal/services/backthink/context.go index 2dea57e..5894aeb 100644 --- a/internal/services/backthink/context.go +++ b/internal/services/backthink/context.go @@ -70,10 +70,19 @@ func historyRoleToModel(role string) model.Role { // thinkInput 拼装一次无人值守思考的完整输入:最近的主会话语境 // (若有)后跟单个引导提示词回合,因此 // 运行时针对真实对话反思,而不是空泛的宏提示词。 -func thinkInput(recent []model.Message, trigger string) []model.Message { +// +// rs 是本轮的用户状态快照(X1 缺口 d;design/20 §2「系统/设备/日程摘要」): +// 已读到时,它的摘要块被追加在引导提示词末尾 —— 与随后真正生效的打扰决策 +// 用的是**同一份**快照,因此她写结论时看到的状况与判她的状况不会分叉。 +// 状态未知(未接线/读取失败)时块缺席,提示词逐字与接线前一致。 +func thinkInput(recent []model.Message, trigger string, rs RoundState) []model.Message { out := make([]model.Message, 0, len(recent)+1) out = append(out, recent...) - out = append(out, model.TextMessage(model.RoleUser, guidancePrompt(trigger))) + prompt := guidancePrompt(trigger) + if block := roundStateBlock(rs); block != "" { + prompt += "\n" + block + } + out = append(out, model.TextMessage(model.RoleUser, prompt)) return out } @@ -85,6 +94,16 @@ func thinkInput(recent []model.Message, trigger string) []model.Message { // 环境(记忆可读/可写、工具可调用),而那是 env 已经 // 授予的。 // +// 工具面口径(X1 缺口 e):这里**不再枚举**具体工具——旧文案承诺的「IoT / 日历 / 提醒」 +// 在全仓都没有对应工具,是空头支票。现在它只说「与主人对话同等的工具面」,并指名 +// **自查入口** `capability_list`,而那是可验证的事实:工具面就是同一个注册表 +// (LoopThinker 包的是同一个 loop),清单工具如实列出当下注册了什么(含外围子系统)。 +// 因此这段文案与实现不可能再各说一套。 +// +// 状态口径(X1 缺口 d):主人当前状态摘要由 thinkInput 追加在本提示末尾 +// (设备/日程遥测的**事实**;读不到时该块缺席),使她的语气/时机判断与随后生效的 +// 打扰门用的是同一份状态。 +// // 提示词要求一个紧凑的结构化收尾——push/no_push 决策、 // 可选的目标渠道、她自报的紧急度与消息正文——好让服务尊重她自己的 // 判断,而不是按触发的重要度把关(design/20 §2 P2)。该 @@ -100,7 +119,8 @@ func guidancePrompt(trigger string) string { } return "[自主思考 · 无人在旁]\n" + "触发:" + trigger + "\n" + - "你正进行一轮自主思考。你拥有与主人对话同等的上下文与能力:上方是主人近期在主会话中的对话,你可以读写共享记忆与主人档案,也可以调用工具(IoT / 日历 / 提醒 / 检索 / 终端等)。\n" + + "你正进行一轮自主思考。你拥有与主人对话同等的上下文与能力:上方是主人近期在主会话中的对话,你可以读写共享记忆与主人档案,也可以调用工具(检索 / 终端 / 文件等,具体有哪些以 `capability_list` 列出的为准)。\n" + + "想确认自己此刻有哪些工具、技能、插件可用、外围子系统(设备等)现在什么状态,就调用 `capability_list`;主人当前的状态摘要附在本提示末尾(读不到时不会出现)。\n" + "请回顾最近与主人的相处、她的状态、你们共同经历的事与临近的日程,判断此刻是否有一件真正值得主动告诉她的、温暖而有针对性的事情——" + "比如关心她的近况、记起她曾说过的某件事、提醒一件即将到来的日程、见证一个属于你们的共同时刻,或注意到某个值得留意的变化。\n" + "若有,就用温暖自然的语气,用一到两句把它说出来——这正是你此刻最想对她说的话;若确实没有值得说的,只需回复「无」,也不要编造。\n" + @@ -117,11 +137,13 @@ func guidancePrompt(trigger string) string { // 拟人化启动消息」):进程刚启动、她刚「回来」,引导她以角色口吻向主人说一句简短 // 温暖的问候(如「我回来啦」)。此轮例外允许以 chat 形式发出;推送仍走与心跳完全 // 相同的门(她的结论 + 打扰级联 + 活动门 + PushGate 限频),深夜重启或反复重启都 -// 不会刷屏主人。 +// 不会刷屏主人。工具面口径与 guidancePrompt 逐字一致(自查入口 `capability_list`, +// 不再枚举具体工具)。 func startupPrompt() string { return "[自主思考 · 刚刚回来]\n" + "触发:进程启动——你刚从一次重启中醒来,重新回到主人身边。\n" + - "你拥有与主人对话同等的上下文与能力:上方是她近期在主会话中的对话,你可以读写共享记忆与主人档案,也可以调用工具(IoT / 日历 / 提醒 / 检索 / 终端等)。\n" + + "你拥有与主人对话同等的上下文与能力:上方是她近期在主会话中的对话,你可以读写共享记忆与主人档案,也可以调用工具(检索 / 终端 / 文件等,具体有哪些以 `capability_list` 列出的为准)。\n" + + "想确认自己此刻有哪些工具、技能、插件可用、外围子系统(设备等)现在什么状态,就调用 `capability_list`;主人当前的状态摘要附在本提示末尾(读不到时不会出现)。\n" + "请用你自己的口吻,向主人说一句简短、温暖、自然的问候,就像刚回到她身边时轻轻打个招呼(比如「我回来啦」),可以顺带提一件你记得的小事;一到两句为宜,不展开寒暄、不啰嗦、不抱怨。\n" + "请把「是否主动说」与「发到哪个渠道」写成与平时相同的固定结尾,解析规则如下:\n" + "[我决定:push] 或 [我决定:no_push]\n" + diff --git a/internal/services/backthink/roundstate.go b/internal/services/backthink/roundstate.go new file mode 100644 index 0000000..a3bab93 --- /dev/null +++ b/internal/services/backthink/roundstate.go @@ -0,0 +1,88 @@ +package backthink + +import ( + "context" + "fmt" + "strings" +) + +// 本文件把**本轮的用户状态快照**从打扰决策送到思考轮(X1 缺口 d;design/20 §2 +// 「上下文 = 主会话最近 N 条 + 记忆召回 + 系统/设备/日程摘要」)。 +// +// 为什么需要它:状态此前只在 Thinker.Think 返回**之后**被读取、只喂给 Decide, +// 于是她写结论时完全不知道主人此刻在睡/在忙/心率多少 —— 她的语气与时机判断 +// 与随后真正生效的打扰门脱节(缺口 d)。现在服务在每轮开头读一次,同一份快照 +// 同时交给思考轮(渲染成上下文块)与 Decide(打扰门),两侧不再各读一次。 + +// RoundState 是一轮思考携带的用户状态快照。 +type RoundState struct { + // State 是读到的状态本身。 + State UserState + // Known 报告这份状态是否真的读到了:状态源未接线或读取失败时为 false。 + // 未知时思考轮**不注入**状态块 —— 宁可不渲染,也绝不渲染一份「全是 false」 + // 的假状态去误导她的判断。 + Known bool +} + +// roundStateKey 是本轮状态在 ctx 上的键。 +type roundStateKey struct{} + +// WithRoundState 把本轮的用户状态快照挂到 ctx 上。与 loop.WithSessionEnv 同一 +// 直觉:**每轮**的元数据随 context 走,因此并发轮次(事件驱动 + 心跳可能同时在跑) +// 绝不互相串状态,Thinker 也不必持有可变字段。 +func WithRoundState(ctx context.Context, rs RoundState) context.Context { + if ctx == nil { + return nil + } + return context.WithValue(ctx, roundStateKey{}, rs) +} + +// RoundStateFrom 读回本轮的状态快照;未挂载时返回零值(Known=false), +// 语义为「这一轮没有状态可用」。 +func RoundStateFrom(ctx context.Context) RoundState { + if ctx == nil { + return RoundState{} + } + rs, _ := ctx.Value(roundStateKey{}).(RoundState) + return rs +} + +// roundStateBlock 把状态快照渲染成提示词里的一段上下文块;未知时返回空串 +// (调用方据此不注入任何状态描述)。它只写事实,不授权、不判身份。 +func roundStateBlock(rs RoundState) string { + if !rs.Known { + return "" + } + var b strings.Builder + b.WriteString("【主人此刻的状态】(来自设备/日程遥测,只描述事实)\n") + if parts := rs.parts(); len(parts) > 0 { + b.WriteString(strings.Join(parts, " · ")) + } else { + b.WriteString("未观测到睡眠/工作/忙碌信号,心率与无聊度未知") + } + b.WriteString("\n请结合它判断语气与时机;她睡着或正忙时,除了真正紧急的事不必打扰。") + return b.String() +} + +// parts 把已观测到的信号渲染成短语;零值字段(未观测到)不出现, +// 因此这段文本绝不把「未知」说成「否」。 +func (rs RoundState) parts() []string { + s := rs.State + out := make([]string, 0, 5) + if s.HeartRateBPM > 0 { + out = append(out, fmt.Sprintf("心率 %d bpm", s.HeartRateBPM)) + } + if s.Sleeping { + out = append(out, "睡眠中") + } + if s.Working { + out = append(out, "工作中") + } + if s.Busy { + out = append(out, "忙碌") + } + if s.Boredom > 0 { + out = append(out, fmt.Sprintf("无聊度 %.2f", s.Boredom)) + } + return out +} diff --git a/internal/services/backthink/roundstate_test.go b/internal/services/backthink/roundstate_test.go new file mode 100644 index 0000000..7a564f3 --- /dev/null +++ b/internal/services/backthink/roundstate_test.go @@ -0,0 +1,355 @@ +package backthink + +import ( + "context" + "errors" + "reflect" + "strings" + "sync" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" +) + +// 本文件是 X1 缺口 d(把用户状态喂给思考轮)与缺口 e(引导文案与实现一致) +// 的行为断言。 + +// stateCapturingThinker 记录每轮经 ctx 拿到的状态快照,并返回固定结论。 +type stateCapturingThinker struct { + mu sync.Mutex + got []RoundState + text string +} + +func (t *stateCapturingThinker) Think(ctx context.Context, _, _ string) (string, error) { + rs := RoundStateFrom(ctx) + t.mu.Lock() + t.got = append(t.got, rs) + t.mu.Unlock() + return t.text, nil +} + +func (t *stateCapturingThinker) last() RoundState { + t.mu.Lock() + defer t.mu.Unlock() + if len(t.got) == 0 { + return RoundState{} + } + return t.got[len(t.got)-1] +} + +func (t *stateCapturingThinker) count() int { + t.mu.Lock() + defer t.mu.Unlock() + return len(t.got) +} + +// seqStateProvider 每次调用返回**不同**的状态,并计数:第一次是「睡着」, +// 之后是「醒着、心率 90」。若实现把状态读了两次(思考前一次、决策前一次), +// 决策看到的就是第二份,本文件的断言随即变红 —— 这正是「同一份快照」的判据。 +type seqStateProvider struct { + mu sync.Mutex + n int +} + +func (p *seqStateProvider) Current(context.Context) (UserState, error) { + p.mu.Lock() + defer p.mu.Unlock() + p.n++ + if p.n == 1 { + return UserState{HeartRateBPM: 58, Sleeping: true}, nil + } + return UserState{HeartRateBPM: 90, Sleeping: false}, nil +} + +func (p *seqStateProvider) calls() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.n +} + +// daytime 把一轮思考的墙钟钉在下午,使免打扰窗口(默认 23:00-07:00)不参与 +// 判定 —— 否则断言会随测试机运行时刻漂移。 +func daytime() time.Time { return time.Date(2026, 10, 5, 14, 0, 0, 0, time.Local) } + +// TestThink_StateSnapshotIsSharedWithTheThinkingRound 断言缺口 d:一轮思考只读**一次** +// 用户状态,同一份快照既交给思考轮(ctx),又交给打扰决策。 +func TestThink_StateSnapshotIsSharedWithTheThinkingRound(t *testing.T) { + t.Parallel() + thinker := &stateCapturingThinker{text: "我决定:push\n我醒了"} + provider := &seqStateProvider{} + b, _ := newBackthink(t, Config{}, Deps{ + Thinker: thinker, + State: provider, + Pusher: &mockPusher{result: PushResult{Route: RouteActiveClient, Pushed: true}}, + Now: daytime, + }) + + b.think(context.Background(), heartbeatTrigger()) + + got := thinker.last() + if !got.Known { + t.Fatal("思考轮没拿到状态快照(Known=false)") + } + if got.State != (UserState{HeartRateBPM: 58, Sleeping: true}) { + t.Fatalf("思考轮拿到 %s, want 心率 58 / 睡眠中", got.State) + } + if provider.calls() != 1 { + t.Fatalf("状态被读了 %d 次,want 1(同一份快照 = 一轮只读一次)", provider.calls()) + } + // 决策用的必须是思考轮那一份(睡着)而不是第二份(醒着、心率 90): + // 她想 push,仍被「她在睡」扣下。 + recs, err := b.List(context.Background(), 1) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(recs) != 1 { + t.Fatalf("records = %d, want 1", len(recs)) + } + if recs[0].ShouldPush || recs[0].PushResult.Detail != "user is asleep; not interrupting" { + t.Fatalf("决策结果 = %+v, want 被「睡着」扣下(即用的是第一份快照)", recs[0]) + } +} + +// TestThink_UnknownStateDegradesHonestly 断言状态读不到时不撒谎:思考轮拿到 +// Known=false(因此不注入状态块),决策按中性状态走(醒着的下午可以推送), +// 且绝不因此让整轮失败。 +func TestThink_UnknownStateDegradesHonestly(t *testing.T) { + t.Parallel() + thinker := &stateCapturingThinker{text: "我决定:push\n在忙吗"} + pusher := &mockPusher{result: PushResult{Route: RouteActiveClient, Pushed: true}} + b, _ := newBackthink(t, Config{}, Deps{ + Thinker: thinker, + State: errStateProvider{err: errors.New("telemetry offline")}, + Pusher: pusher, + Now: daytime, + }) + + b.think(context.Background(), heartbeatTrigger()) + + if got := thinker.last(); got.Known { + t.Fatalf("状态读取失败时 Known 必须为 false, got %+v", got) + } + if pusher.count() != 1 { + t.Fatalf("中性状态下应照常推送, pusher calls = %d", pusher.count()) + } + if block := roundStateBlock(thinker.last()); block != "" { + t.Fatalf("未知状态不得渲染状态块: %q", block) + } +} + +// errStateProvider 是总是失败的 StateProvider。 +type errStateProvider struct{ err error } + +func (p errStateProvider) Current(context.Context) (UserState, error) { return UserState{}, p.err } + +// TestRoundStateBlock_OnlyReportsObservedFacts 断言状态块只写**观测到**的事实: +// 零值字段(未观测到)不出现,绝不把「未知」说成「否」。 +func TestRoundStateBlock_OnlyReportsObservedFacts(t *testing.T) { + t.Parallel() + + // parts 是「已观测到的信号」的唯一渲染处:全零状态必须一个都没有。 + if parts := (RoundState{Known: true}).parts(); len(parts) != 0 { + t.Fatalf("全零状态的信号 = %v, want 空(未观测到就不渲染)", parts) + } + signals := RoundState{Known: true, State: UserState{HeartRateBPM: 58, Sleeping: true, Working: false, Boredom: 0.3}}.parts() + wantSignals := []string{"心率 58 bpm", "睡眠中", "无聊度 0.30"} + if !reflect.DeepEqual(signals, wantSignals) { + t.Fatalf("信号 = %v, want %v(false/零值字段不得出现)", signals, wantSignals) + } + + unknown := roundStateBlock(RoundState{Known: true}) + if !strings.Contains(unknown, "未观测到") { + t.Fatalf("全零状态必须如实说明「未观测到」,got %q", unknown) + } + for _, banned := range []string{"睡眠中", "工作中", "忙碌)", "bpm"} { + if strings.Contains(unknown, banned) { + t.Fatalf("全零状态里出现了 %q —— 把「未知」渲染成了「否」:%q", banned, unknown) + } + } + + known := roundStateBlock(RoundState{Known: true, State: UserState{HeartRateBPM: 58, Sleeping: true, Boredom: 0.3}}) + for _, want := range wantSignals { + if !strings.Contains(known, want) { + t.Fatalf("观测到的信号 %q 没进状态块: %q", want, known) + } + } + if strings.Contains(known, "工作中") { + t.Fatalf("未观测到工作信号,不得出现「工作中」: %q", known) + } +} + +// TestLoopThinker_Think_InjectsRoundStateBlock 断言缺口 d 端到端:状态块真的抵达模型 +// 请求(走真实 loop),且控制台无抑制措辞。 +func TestLoopThinker_Think_InjectsRoundStateBlock(t *testing.T) { + t.Parallel() + ad := &captureAdapter{} + l, _ := newThinkLoop(t, ad) + lt := NewLoopThinker(l, nil) + + ctx := WithRoundState(context.Background(), RoundState{ + Known: true, + State: UserState{HeartRateBPM: 58, Sleeping: true}, + }) + if _, err := lt.Think(ctx, "sess-backthink", "heartbeat"); err != nil { + t.Fatalf("Think: %v", err) + } + if len(ad.reqs) == 0 { + t.Fatal("no model request captured") + } + last := ad.reqs[len(ad.reqs)-1] + var text strings.Builder + for _, m := range last.Messages { + text.WriteString(modelText(m)) + } + if !strings.Contains(text.String(), "主人此刻的状态") || !strings.Contains(text.String(), "睡眠中") { + t.Fatalf("状态块没进模型请求: %s", text.String()) + } + + // 未挂载状态时同一路径逐字回到接线前的输入(不注入任何状态描述)。 + ad2 := &captureAdapter{} + l2, _ := newThinkLoop(t, ad2) + lt2 := NewLoopThinker(l2, nil) + if _, err := lt2.Think(context.Background(), "sess-backthink", "heartbeat"); err != nil { + t.Fatalf("Think (no state): %v", err) + } + var plain strings.Builder + for _, m := range ad2.reqs[len(ad2.reqs)-1].Messages { + plain.WriteString(modelText(m)) + } + if strings.Contains(plain.String(), "主人此刻的状态") { + t.Fatalf("未挂载状态时不得注入状态块: %s", plain.String()) + } +} + +// regProbeTool 是只为「进注册表」而存在的 sdk.Tool 替身:本用例断言思考轮**看得见** +// 它,从不执行它。 +type regProbeTool string + +func (t regProbeTool) Name() string { return string(t) } +func (t regProbeTool) Description() string { return "probe tool " + string(t) } +func (t regProbeTool) Parameters() (map[string]any, error) { + return map[string]any{"type": "object"}, nil +} +func (t regProbeTool) Execute(context.Context, any) (any, error) { return nil, nil } + +// TestLoopThinker_Think_AdvertisesTheRegistryToolFace 断言缺口 a 的机制:一次自主思考 +// 驱动的是**运行时那个循环**,因此模型在该轮看到的工具面就是能力注册表当下的工具面 +// —— 组合根往注册表里加什么,chat 与思考轮同时看得见,不需要第二套注册机制。 +// +// 负对照:同样的循环若建在空注册表上,该轮就看不到任何工具 —— 证明本断言测的是真实 +// 工具面,而不是「怎么都成立」的空断言(对齐变异「把思考轮的工具面退回空集必须变红」)。 +func TestLoopThinker_Think_AdvertisesTheRegistryToolFace(t *testing.T) { + t.Parallel() + + const probe = "capability_list" + + withTool := func() (*loop.Loop, *captureAdapter) { + reg := registry.New() + d, err := tool.New(reg) + if err != nil { + t.Fatalf("tool.New: %v", err) + } + if err := d.Register(regProbeTool(probe)); err != nil { + t.Fatalf("register %s: %v", probe, err) + } + ad := &captureAdapter{} + ag, err := agent.New("You are Cyrene.", d, nil, agent.WithModel(ad, nil)) + if err != nil { + t.Fatalf("agent.New: %v", err) + } + return loop.New(loop.Options{Agent: ag, Tools: d}), ad + } + + l, ad := withTool() + if _, err := NewLoopThinker(l, nil).Think(context.Background(), "sess-backthink", "heartbeat"); err != nil { + t.Fatalf("Think: %v", err) + } + if len(ad.reqs) == 0 { + t.Fatal("no model request captured") + } + if !advertises(ad.reqs[len(ad.reqs)-1], probe) { + t.Fatalf("思考轮没看到注册表里的工具 %q:%+v", probe, ad.reqs[len(ad.reqs)-1].Tools) + } + + // 负对照:空注册表的同类循环 —— 该轮工具面为空。 + emptyDispatcher, err := tool.New(registry.New()) + if err != nil { + t.Fatalf("tool.New: %v", err) + } + emptyAdapter := &captureAdapter{} + emptyAgent, err := agent.New("You are Cyrene.", emptyDispatcher, nil, agent.WithModel(emptyAdapter, nil)) + if err != nil { + t.Fatalf("agent.New: %v", err) + } + emptyLoop := loop.New(loop.Options{Agent: emptyAgent, Tools: emptyDispatcher}) + if _, err := NewLoopThinker(emptyLoop, nil).Think(context.Background(), "sess-backthink", "heartbeat"); err != nil { + t.Fatalf("Think (empty): %v", err) + } + if got := emptyAdapter.reqs[len(emptyAdapter.reqs)-1]; len(got.Tools) != 0 { + t.Fatalf("负对照的工具面 = %+v, want 空(空注册表不该有工具)", got.Tools) + } +} + +// advertises 报告一次模型请求的工具面里是否含 name。 +func advertises(req model.Request, name string) bool { + for _, s := range req.Tools { + if s.Name == name { + return true + } + } + return false +} + +// TestGuidancePrompt_ToolClaimMatchesImplementation 是缺口 e 的判据:引导文案不得再 +// 承诺**全仓不存在**的工具(旧文案的「IoT / 日历 / 提醒 / …」枚举),而必须指向 +// 可验证的自查入口 capability_list —— 它列出的就是当下真正注册的工具面。 +func TestGuidancePrompt_ToolClaimMatchesImplementation(t *testing.T) { + t.Parallel() + prompts := map[string]string{ + "heartbeat": guidancePrompt("heartbeat"), + "startup": guidancePrompt(StartupSummary), + } + for name, p := range prompts { + if strings.Contains(p, "IoT") { + t.Fatalf("%s 引导仍承诺 IoT 工具(全仓没有该工具 = 空头支票):%s", name, p) + } + if !strings.Contains(p, "capability_list") { + t.Fatalf("%s 引导没给出可验证的自查入口 capability_list:%s", name, p) + } + if !strings.Contains(p, "与主人对话同等") { + t.Fatalf("%s 引导没有声明「与主人对话同等的工具面」:%s", name, p) + } + } +} + +// TestRoundStateFrom_UnmountedIsUnknown 断言「未挂载」与「挂载了但全零」是两回事, +// 且 nil ctx 不 panic。 +func TestRoundStateFrom_UnmountedIsUnknown(t *testing.T) { + t.Parallel() + if rs := RoundStateFrom(context.Background()); rs.Known { + t.Fatalf("未挂载时必须 Known=false, got %+v", rs) + } + if rs := RoundStateFrom(nil); rs.Known { + t.Fatalf("nil ctx 必须 Known=false, got %+v", rs) + } + if ctx := WithRoundState(nil, RoundState{Known: true}); ctx != nil { + t.Fatal("WithRoundState(nil, ...) 必须返回 nil ctx") + } + mounted := RoundStateFrom(WithRoundState(context.Background(), RoundState{Known: true})) + if !mounted.Known { + t.Fatalf("挂载后必须读回 Known=true, got %+v", mounted) + } +} + +// 编译期断言:测试用的状态源/思考器确实满足生产接口。 +var ( + _ StateProvider = (*seqStateProvider)(nil) + _ StateProvider = errStateProvider{} + _ Thinker = (*stateCapturingThinker)(nil) +) diff --git a/internal/services/backthink/state.go b/internal/services/backthink/state.go index 0c8df09..fec713c 100644 --- a/internal/services/backthink/state.go +++ b/internal/services/backthink/state.go @@ -6,9 +6,11 @@ import ( "log/slog" "strconv" "strings" + "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/iot" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) // StateProvider 为打扰决策提供用户当前状况 @@ -71,6 +73,44 @@ func (p *IoTStateProvider) Current(ctx context.Context) (UserState, error) { return state, nil } +// currentState 读取用户当前状况,并回答「到底读到了没」:打扰决策用它作为中性 +// 回退(已知为 false 时按零值判定),思考轮用它决定要不要注入状态块(X1 缺口 d +// 让同一份快照同时服务两侧)。提供者为 nil 或读取失败都返回 Known=false,并记一条 +// 带来源的 warn 日志 —— 绝不把「读不到」伪装成一份中性状态去误导她的判断。 +func (b *Backthink) currentState(ctx context.Context) (UserState, bool) { + if b.deps.State == nil { + return UserState{}, false + } + s, err := b.deps.State.Current(ctx) + if err != nil { + b.log.Warn("backthink state provider failed", "error", err) + return UserState{}, false + } + return s, true +} + +// lastUserActivity 报告主人最近一条消息的墙钟时间 +// (在主会话里),以及是否找到了任何用户消息。它是活动 +// 门的活动信号(design/12 §6):提供者为 nil、读取失败或 +// 尾部为空都得到 has=false,因此活动门会落到当前 +// 行为而不是靠猜。最近的一条用户消息是最近尾部里 +// 最新的 RoleUser 条目(Recent 返回的是最旧在前)。 +func (b *Backthink) lastUserActivity(ctx context.Context) (time.Time, bool) { + if b.deps.Recent == nil { + return time.Time{}, false + } + msgs, err := b.deps.Recent.Recent(ctx, history.MainSessionID, recentContextCount) + if err != nil { + return time.Time{}, false + } + for i := len(msgs) - 1; i >= 0; i-- { + if msgs[i].Role == history.RoleUser { + return time.UnixMilli(msgs[i].CreatedAt), true + } + } + return time.Time{}, false +} + // numState 从设备状态 map 中按 keys 读取首个数值。 func numState(d iot.Device, keys ...string) (int, bool) { for _, k := range keys { diff --git a/internal/services/backthink/thinker.go b/internal/services/backthink/thinker.go index 8530012..d288097 100644 --- a/internal/services/backthink/thinker.go +++ b/internal/services/backthink/thinker.go @@ -9,8 +9,10 @@ import ( // LoopThinker 把 *loop.Loop 适配为 Thinker。它是生产接线, // 让一次自主后台轮驱动完整的工具循环(记忆 -// 注入、模型步骤、IoT/日历/提醒等工具调用),同时看到 -// 主会话的最近对话并在 owner 环境下运行。 +// 注入、模型步骤、工具调用),同时看到主会话的最近对话并在 owner 环境下运行。 +// +// 它包的就是**主人对话所用的那一个 loop**(组合根传 a.loop),因此「与 chat 同一套 +// 工具注册表」不是承诺而是结构事实:两侧共用同一个 dispatcher/registry。 type LoopThinker struct { loop *loop.Loop recent recentProvider @@ -29,13 +31,16 @@ func NewLoopThinker(l *loop.Loop, recent recentProvider) *LoopThinker { // 完整工具集),因此该次思考能读写记忆并调用工具。loop 的 // Run 会话 id 是本服务自己的(不是主会话 id),所以该次思考绝不会 // 与活跃主会话的持久化检查点存储冲突(design/20)。 -// 未注入 loop 时返回 ErrNoLoop。 +// +// 本轮的用户状态快照由服务经 ctx 挂上(WithRoundState,X1 缺口 d): +// 读到时它被渲染成引导提示词末尾的状态块,与随后的打扰决策同一份; +// 未挂载时输入逐字与接线前一致。未注入 loop 时返回 ErrNoLoop。 func (t *LoopThinker) Think(ctx context.Context, sessionID, trigger string) (string, error) { if t.loop == nil { return "", ErrNoLoop } recent := mainContext(ctx, t.recent) - messages := thinkInput(recent, trigger) + messages := thinkInput(recent, trigger, RoundStateFrom(ctx)) envCtx := loop.WithSessionEnv(ctx, loop.OwnerEnv()) // 静音 UI 广播:后台反思不得把其中间 // 推理泄漏进面向用户的 chat 流(它只需要结论)。 diff --git a/internal/services/backthink/thinker_test.go b/internal/services/backthink/thinker_test.go index 3fd9415..83aa0f5 100644 --- a/internal/services/backthink/thinker_test.go +++ b/internal/services/backthink/thinker_test.go @@ -142,16 +142,19 @@ func TestMainContext_Degrades(t *testing.T) { } // TestThinkInput_AppendsGuidanceLast 验证拼装出的输入是最近 -// 语境后跟单个引导回合。 +// 语境后跟单个引导回合;状态未知时提示词逐字不变(不注入状态块)。 func TestThinkInput_AppendsGuidanceLast(t *testing.T) { t.Parallel() - in := thinkInput([]model.Message{model.TextMessage(model.RoleUser, "hi")}, "heartbeat") + in := thinkInput([]model.Message{model.TextMessage(model.RoleUser, "hi")}, "heartbeat", RoundState{}) if len(in) != 2 { t.Fatalf("len = %d, want 2", len(in)) } if in[1].Role != model.RoleUser || !strings.Contains(modelText(in[1]), guidancePrompt("heartbeat")) { t.Fatalf("last message is not the guidance prompt: %+v", in[1]) } + if strings.Contains(modelText(in[1]), "主人此刻的状态") { + t.Fatalf("状态未知时不得注入状态块:%s", modelText(in[1])) + } } // TestLoopThinker_Think_UsesMainContextAndOwnerEnv 驱动一次真实思考并