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 已被取消,审计行仍会写入。
|
||||
|
||||
Reference in new issue
Block a user