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

+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 驱动一次真实思考并