feat(backthink): 思考轮工具面补齐(裁决 15)—— 能力面接入 + 只读 capability_list + 本轮状态快照
- 缺口 d:状态在**思考前**读一次,同一份快照既喂思考轮又喂 Decide(读不到 = Known=false,绝不伪装成中性状态) - 缺口 c:新增只读工具 capability_list(owner-only、三重输出边界、读写隔离),她可自查当下真实可用面 - 缺口 b:内置允许集补 transcode / capability_list(此前 CAPACITY_ENABLED=true 时新工具会从模型可见面消失) - 缺口 e:guidancePrompt 旧文案承诺的「IoT/日历/提醒」在全仓根本没有对应工具(chat 侧也没有)⇒ 按诚实化改为「与主人对话同等工具面 + 指名自查入口」,不再有空头支票 - (a) 未做且**不自创**:那三把工具无任何设计规格(名字/schema/读写边界/输出上限)⇒ 已报回并登记进 design/20(裁决:走能力/插件/MCP 暴露路线) - 变异实测:允许集去掉新工具 ⇒ TestDecide_AllowedSets + 组合根跨层测试红;丢掉状态快照 ⇒ TestLoopThinker_Think_InjectsRoundStateBlock 红 - 已知弱项(如实登记):组合根那行 wireCapabilitySurface() 调用本身无测试覆盖(与既有 wireTranscode 同惯例)
This commit is contained in:
1 parent
34cef95e17
commit
321cc3b763
14 files changed
+1664
-73
No files matched your search
@@ -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 已被取消,审计行仍会写入。
|
||||
|
||||
@@ -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" +
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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,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 流(它只需要结论)。
|
||||
|
||||
@@ -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 驱动一次真实思考并
|
||||
|
||||
Reference in new issue
Block a user