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 已被取消,审计行仍会写入。