Files
AskaEth 76263d42ed fix(upstream): M51 复审 p2 三条 —— light.go 同形出口 / MCP SDK 日志面 / ErrorToken 口径
① light.go 的 2 处 "light judge: " + d.Reason 与 engage.go 同形(model reason →
返回值 → coalesce_prepare 的 Debug)。把上一轮的 engageReason 提成包内共用的
judgeReason(judge_reason.go,RedactTextLimited + strings.Fields,60 rune),
engage.go 7 处 + light.go 2 处 + notice.go 的 "notice review failed: "+err.Error()
统一走它。控制流零改动:只换「进去的那份文本」,判定条件与返回值拼接结构未动。

② MCP 的 Logger 面先探针后修:go-sdk v1.8.0 的 keepalive 会把对端 JSON-RPC error
原样打进日志(实测泄漏,见 sdklog_probe_test.go 的输出),tools/call 路径不打日志。
新增 sdklog.go:redactingLogger 交给 SDK,wire 错误(链上有 *jsonrpc.Error)只留
code + message_bytes(复用 upstreamError),本地错误保留原因,消息/属性压平+打码+300 rune。

③ errortoken.go 的口径注释纠正:从「包外不得调用」改为按字段类别判定(协议枚举可守、
自由文本默认不带),并把 cdp.go 这条 M51 用法作为例子写进去。
2026-10-26 20:00:57 +08:00

377 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package channelbridge
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
)
// 本文件承载公开平台群聊/陌生人会话的参与度引擎
// (DevDocs/design/18):按会话的状态机
// (IDLE→PROBING→ACTIVE→COOLDOWN)、受成本控制的触发闸门(角色名提及
// / 概率)、带跟进复审的活跃窗口,以及群内出现负面
// 态度时的冷避。它按进程存于内存(重启即重置),
// 且只在真正触发时才咨询轻量 LLM 判定器,因此不会仅为「要不要
// 打扰某人」就把聊天消息发给 LLM(design/18 红线)。
//
// M18(主人 2026-10-14 裁决「给」):冷避**优先于**「@她必回」——
// 冷避闸(coldShoulderGate)由 shouldRespond 在廉价规则之前调用,
// 使群成员的一句「少说点」在 30 分钟时限内不被随后的 @ 作废。
// 冷却期内的唯一放行口是**高价值关键词穿透**(塔 2026-10-15 定稿,不解除冷却),
// 口径见 coldShoulderLocked。
// EngageConfig 承载 design/18 的按会话参与度调参。P2c 会让它
// 可按群配置 / 在面板编辑;目前由 DefaultEngageConfig 提供
// 常量,组合根中的 EngageFor 接缝保持 nil。
type EngageConfig struct {
// Enabled 是按会话开关(design/18 §6 engageEnabled)。为 false 时
// 只有角色名提醒词触发仍会探测;概率路径关闭。
Enabled bool
Probability float64 // 触发③ 概率,默认 0.10
ActivityWindow time.Duration // ACTIVE 窗口,默认 15m
ActivityMessages int // ACTIVE 窗口按消息条数计,默认 20
Cooldown time.Duration // COOLDOWN 时长,默认 30m
ContextMessages int // 探测时喂给判定器的近期消息条数
}
// 参数默认值(design/18 §6)。编译期常量,运行路径上就不会出现
// 裸魔法数字(项目规范 §10)。
const (
defaultEngageProbability = 0.10
defaultActivityWindow = 15 * time.Minute
defaultActivityMessages = 20
defaultCooldownDuration = 30 * time.Minute
defaultEngageContext = 12
// consecutiveNoInterestThreshold 是连续多少次跟进探测都返回「无
// 兴趣」/回避后,她从 ACTIVE 退回 IDLE(design/18 §4)。负面
// 态度则由下面的规则词表把她按进 COOLDOWN。
consecutiveNoInterestThreshold = 2
)
// errNoEngageJudge 表示参与度引擎已配置但未装配 LightJudge,
// 因此它失败关闭为静默。
var errNoEngageJudge = errors.New("channelbridge: no light judge wired for engage")
// DefaultEngageConfig 返回 design/18 的参数默认值(P2a 基准;P2c 会让它们
// 可按群配置/在面板编辑)。
func DefaultEngageConfig() EngageConfig {
return EngageConfig{
Enabled: true,
Probability: defaultEngageProbability,
ActivityWindow: defaultActivityWindow,
ActivityMessages: defaultActivityMessages,
Cooldown: defaultCooldownDuration,
ContextMessages: defaultEngageContext,
}
}
// engageState 是按会话状态机的一个阶段(design/18 §1)。
type engageState int
const (
engageIDLE engageState = iota
engagePROBING // 瞬态:某次触发刚把该消息送去判定
engageACTIVE // 她正在参与;跟进复审已开启
engageCOOLDOWN // 她被要求安静;计时结束后自动恢复
)
// engageGroupState 是一个参与度引擎实例,以群聊/陌生人
// 会话为键(design/18 §1)。它仅存于内存(进程重启即重置)。
type engageGroupState struct {
mu sync.Mutex
state engageState
lastTopic string
lastInteract time.Time
activeUntil time.Time
msgsInWindow int
cooldownUntil time.Time
consecutiveNoInterest int
}
// engageConfigured 报告该桥接器是否装配了 design/18 参与度引擎。
// 角色带触发词,或参与度参数启用引擎时为 true;
// 否则桥接器沿用引入参与度引擎之前的旧轻量判定
// (design/12 §6),不做改动。
func (b *Bridge) engageConfigured() bool {
return len(b.triggerWords) > 0 || b.engage.Enabled || b.engage.Probability > 0
}
// engageKey 返回该会话在参与度引擎里的状态键(design/18 §1/§6,裁决 06.4.3)。键口径与
// 入站节流**完全一致**:adapter.ConversationKey(platform+channelID+groupID/peerID),
// 所以同一会话在「滑窗计数」和「参与度状态机」里落在同一个桶——两个群并发互不挤占。
// 群聊缺 group id 时用 actor 上报的群 id 补齐(适配器可能只填了 Sender.GroupID)。
func engageKey(msg adapter.UnifiedMessage, actor adapter.Actor) string {
if msg.GroupID == "" && (msg.IsGroup() || actor.IsGroupChat() || actor.GroupID != "") {
msg.GroupID = actor.GroupID
}
return adapter.ConversationKey(msg)
}
// engageState 返回会话键对应的状态,首次使用时创建 IDLE
// 实例(内存态,可安全并发使用)。
func (b *Bridge) engageState(key string) *engageGroupState {
b.engageMu.Lock()
defer b.engageMu.Unlock()
st := b.engageGroups[key]
if st == nil {
st = &engageGroupState{state: engageIDLE}
b.engageGroups[key] = st
}
return st
}
// engageConfigFor 解析一条消息实际生效的参与度配置:装配了按群
// 覆盖(EngageFor,P2a 中为 nil)时用它,否则用基准配置,
// 并把所有零值字段归一化为默认值。
func (b *Bridge) engageConfigFor(msg adapter.UnifiedMessage, actor adapter.Actor) EngageConfig {
cfg := b.engage
if b.engageFor != nil {
g := msg.GroupID
if g == "" {
g = actor.GroupID
}
if ov := b.engageFor(msg.Platform, g); ov != nil {
cfg = *ov
}
}
// 把未设置的字段归一化为保守默认值,使只配了触发的配置仍能
// 限定活跃窗口与冷却,而不是以零值运行。
if cfg.ActivityWindow <= 0 {
cfg.ActivityWindow = defaultActivityWindow
}
if cfg.ActivityMessages <= 0 {
cfg.ActivityMessages = defaultActivityMessages
}
if cfg.Cooldown <= 0 {
cfg.Cooldown = defaultCooldownDuration
}
if cfg.ContextMessages <= 0 {
cfg.ContextMessages = defaultEngageContext
}
if cfg.Probability <= 0 {
cfg.Probability = defaultEngageProbability
}
return cfg
}
// engageDecision 是对(廉价规则无法判定)非主人消息的 design/18
// 参与度决策。仅在 engageConfigured() 为 true 时才会走到。
// 它返回该消息是否应驱动完整 agent 循环并回复,以及
// 供日志用的简短原因。调用方(shouldRespond)不持锁;
// 这里获取按键的状态互斥锁,使同一会话的并发消息串行化,
// 不同会话之间保持独立。
func (b *Bridge) engageDecision(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, inQuiet bool) (bool, string) {
cfg := b.engageConfigFor(msg, actor)
key := engageKey(msg, actor)
st := b.engageState(key)
st.mu.Lock()
defer st.mu.Unlock()
now := b.now()
// design/18 §5 冷避优先:群成员表达负面态度是明确的「安静」信号 →
// COOLDOWN,即使正处在参与中。判据本体与 shouldRespond 的冷避闸
// (coldShoulderGate)**只有这一份**;那里在廉价规则之前先跑它,因此
// @她 / 高价值关键词都拦不住冷避(M18,主人 2026-10-14 裁决「给」)。
// 这里保留调用是**纵深防御**:engageDecision 单看仍然自带冷避优先语义,
// 将来若多出别的调用点也不会漏掉它。
if quiet, reason := b.coldShoulderLocked(msg, actor, key, st, cfg, now); quiet {
return false, reason
}
// ACTIVE 跟进:活跃窗口未过期间,她持续轻量判断
// 群里延续的讨论,并倾向于保持参与(design/18 §4)。
if st.state == engageACTIVE {
if st.activityExpired(now, cfg) {
b.toIdle(st)
b.log.Debug("engage activity window expired, back to idle", "group", key)
} else {
return b.engageFollowUp(ctx, msg, actor, sess, key, st, cfg, inQuiet)
}
}
// IDLE:只有角色名提及或概率命中才会把该消息送入受成本控制的
// PROBING 判定(design/18 §2 触发②/③);否则它只被
// 登记并静默,不调用 LLM(一条红线)。
if !b.mentionsTriggerWord(msg.Content) && !b.engageProbabilityHit(cfg) {
b.log.Debug("engage no trigger, registered only", "group", key)
return false, "engage: no trigger"
}
return b.engageProbe(ctx, msg, actor, sess, key, st, cfg, inQuiet)
}
// engageProbe 是一次受成本控制的 PROBING 判定(design/18 §3):把
// 群上下文 + 角色提示词喂给轻量判定器,并决定 ACTIVE(回复)还是
// IDLE(仅登记、静默)。解析/LLM 失败时失败关闭为静默。
func (b *Bridge) engageProbe(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, key string, st *engageGroupState, cfg EngageConfig, inQuiet bool) (bool, string) {
now := b.now()
st.state = engagePROBING
d, err := b.judgeEngage(ctx, msg, actor, sess, cfg, inQuiet)
if err != nil {
b.toIdle(st)
b.log.Warn("engage probe failed; silenced", "group", key, "error", err)
return false, "engage: probe failed; silenced"
}
if d.AvoidTopic {
b.toIdle(st)
b.log.Debug("engage probe avoid topic, staying idle", "group", key, "reason", judgeReason(d.Reason))
return false, "engage: avoid topic"
}
if d.WorthReply || d.Interesting {
b.enterActive(st, cfg, now, msg)
b.log.Debug("engage probing → active (respond)", "group", key, "reason", judgeReason(d.Reason))
return true, "engage: " + judgeReason(d.Reason)
}
b.toIdle(st)
b.log.Debug("engage probing → idle, no interest", "group", key, "reason", judgeReason(d.Reason))
return false, "engage: no interest"
}
// engageFollowUp 是 ACTIVE 窗口的跟进复审(design/18 §4):她仍
// 轻量判断讨论,但倾向于继续参与。连续无兴趣或
// 窗口结束会使她退出 ACTIVE;负面态度早已
// 被前面的冷避规则捕获。
func (b *Bridge) engageFollowUp(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, key string, st *engageGroupState, cfg EngageConfig, inQuiet bool) (bool, string) {
st.msgsInWindow++
now := b.now()
d, err := b.judgeEngage(ctx, msg, actor, sess, cfg, inQuiet)
if err != nil {
b.countNoInterest(st)
b.log.Warn("engage follow-up judge failed; counted as no interest", "group", key, "error", err)
return b.engageAfterFollowUp(st, cfg, now, key)
}
if d.AvoidTopic || (!d.WorthReply && !d.Interesting) {
b.countNoInterest(st)
b.log.Debug("engage follow-up no interest / avoid", "group", key, "reason", judgeReason(d.Reason))
return b.engageAfterFollowUp(st, cfg, now, key)
}
st.lastInteract = now
st.lastTopic = contentSnippet(msg.Content)
st.consecutiveNoInterest = 0
b.log.Debug("engage follow-up respond", "group", key, "reason", judgeReason(d.Reason))
return true, "engage follow-up: " + judgeReason(d.Reason)
}
// engageAfterFollowUp 判定一次无兴趣/回避跟进探测的结果。
func (b *Bridge) engageAfterFollowUp(st *engageGroupState, cfg EngageConfig, now time.Time, key string) (bool, string) {
if st.consecutiveNoInterest >= consecutiveNoInterestThreshold || st.activityExpired(now, cfg) {
b.toIdle(st)
b.log.Debug("engage exited active (no interest / window ended)", "group", key)
return false, "engage: left active"
}
return false, "engage: not responding"
}
// countNoInterest 为一次跟进探测递增连续无兴趣计数。
func (b *Bridge) countNoInterest(st *engageGroupState) {
st.consecutiveNoInterest++
}
// judgeEngage 为一次受成本控制的参与度判定渲染 LightInput,并
// 咨询注入的 LightJudge。只有在这里才喂入群的近期上下文与
// 角色提示词——绝不在未触发的消息上喂。
func (b *Bridge) judgeEngage(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, cfg EngageConfig, inQuiet bool) (LightDecision, error) {
if b.light == nil {
return LightDecision{}, errNoEngageJudge
}
return b.light.Judge(ctx, LightInput{
Msg: msg, Actor: actor, SenderName: senderDisplayName(msg, actor),
InQuietHours: inQuiet, QuietStart: b.quietStartHour, QuietEnd: b.quietEndHour,
Engage: true,
PersonaPrompt: b.personaPrompt,
TriggerWords: b.triggerWords,
RecentContext: b.buildEngageContext(ctx, sess, cfg),
})
}
// buildEngageContext 为参与度判定器渲染该会话最近的 N 条消息(带发送者
// 名,最旧在前)。未装配历史存储或存储尚无数据时为空操作
// (返回空字符串),判定器因此退化为只读
// 那一条触发消息。
func (b *Bridge) buildEngageContext(ctx context.Context, sess session.Session, cfg EngageConfig) string {
if b.history == nil || cfg.ContextMessages <= 0 {
return ""
}
msgs, err := b.history.Recent(ctx, sess.SessionID, cfg.ContextMessages)
if err != nil || len(msgs) == 0 {
return ""
}
var sb strings.Builder
for _, m := range msgs {
name := m.SenderName
if name == "" {
name = "(未标注)"
}
role := m.Role
if role == "" {
role = "msg"
}
sb.WriteString(fmt.Sprintf("%s %s: %s\n", role, name, truncateText(m.Content, 80)))
}
if strings.TrimSpace(sb.String()) == "" {
return ""
}
return sb.String()
}
// engageProbabilityHit 报告该消息是否独立命中与角色名提醒词无关的
// 概率触发(design/18 §2 触发③)。它读取可注入的
// EngagRand 源,使测试具备确定性;随机源为 nil 时概率路径
// 关闭(失败关闭)。
func (b *Bridge) engageProbabilityHit(cfg EngageConfig) bool {
if !cfg.Enabled || cfg.Probability <= 0 || b.engageRand == nil {
return false
}
return b.engageRand() < cfg.Probability
}
// contentSnippet 把消息截短为状态机所用的有界话题标记。
func contentSnippet(text string) string {
runes := []rune(strings.TrimSpace(text))
if len(runes) <= 40 {
return string(runes)
}
return string(runes[:40]) + "…"
}
func (st *engageGroupState) inCooldown(now time.Time) bool {
return st.state == engageCOOLDOWN && now.Before(st.cooldownUntil)
}
func (st *engageGroupState) activityExpired(now time.Time, cfg EngageConfig) bool {
return now.After(st.activeUntil) || st.msgsInWindow >= cfg.ActivityMessages
}
// toIdle 把会话重置回 IDLE(design/18:退出 ACTIVE → IDLE)。
func (b *Bridge) toIdle(st *engageGroupState) {
st.state = engageIDLE
st.msgsInWindow = 0
st.consecutiveNoInterest = 0
st.lastTopic = ""
}
// enterActive 打开活跃窗口(design/18 §4)。调用方须持有 st.mu。
func (b *Bridge) enterActive(st *engageGroupState, cfg EngageConfig, now time.Time, msg adapter.UnifiedMessage) {
st.state = engageACTIVE
st.activeUntil = now.Add(cfg.ActivityWindow)
st.msgsInWindow = 1
st.consecutiveNoInterest = 0
st.lastInteract = now
st.lastTopic = contentSnippet(msg.Content)
}
// enterCooldown 把会话按进安静期(design/18 §5)。调用方
// 须持有 st.mu。
func (b *Bridge) enterCooldown(st *engageGroupState, cfg EngageConfig, now time.Time) {
st.state = engageCOOLDOWN
st.cooldownUntil = now.Add(cfg.Cooldown)
st.msgsInWindow = 0
st.consecutiveNoInterest = 0
st.lastTopic = ""
}