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]) + "…"
}
+192
View File
@@ -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
}
+4
View File
@@ -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
}
+374
View File
@@ -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)
}
}
+11 -4
View File
@@ -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)
}
+23 -2
View File
@@ -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
+41 -56
View File
@@ -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 已被取消,审计行仍会写入。
+27 -5
View File
@@ -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" +
+88
View File
@@ -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
}
@@ -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)
)
+40
View File
@@ -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 {
+9 -4
View File
@@ -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 流(它只需要结论)。
+5 -2
View File
@@ -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 驱动一次真实思考并