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 用法作为例子写进去。
This commit is contained in:
1 parent
7fbb8d6232
commit
76263d42ed
11 files changed
+560
-47
No files matched your search
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// 本文件承载公开平台群聊/陌生人会话的参与度引擎
|
||||
@@ -59,29 +58,6 @@ const (
|
||||
// 因此它失败关闭为静默。
|
||||
var errNoEngageJudge = errors.New("channelbridge: no light judge wired for engage")
|
||||
|
||||
// engageReasonRunes 是判定器 reason 允许出镜的 rune 上限。
|
||||
//
|
||||
// 取 60 的理由:reason 由「只输出 JSON 对象」的轻量判定器给出,实际值是一句短语
|
||||
// (「群里在聊周末安排,与角色无关」≈ 16 字),60 rune 容得下三四句这样的短语;
|
||||
// 一旦超过 60 rune,说明它在**复述群聊原文** —— 那部分对排障没有价值(要的是
|
||||
// 「判定方向 + 方向词」),却正是最不该进日志的那段话。
|
||||
const engageReasonRunes = 60
|
||||
|
||||
// engageReason 把判定器回的 reason 整理成可以出镜的短句(Debug 日志与返回值共用
|
||||
// 这同一份,口径不会分叉):
|
||||
//
|
||||
// - 压平空白:reason 是模型自由文本,一个换行就能在按行聚合的日志里伪造出
|
||||
// 「另一条记录」;返回值也会流进日志,所以同样要压;
|
||||
// - 凭据清洗走 trace.RedactTextLimited(与 trace.RedactText 同一实现,不是第二套
|
||||
// 脱敏路径):模型可能把 Authorization / sk-… 之类原样回显进 reason;
|
||||
// - 截到 engageReasonRunes(见上)。
|
||||
//
|
||||
// 🔴 本函数只决定「进去的那份文本」,不参与任何判定:reason 的分支条件与返回值拼接
|
||||
// 结构一个字都不动(2026-10-26 塔口径)。
|
||||
func engageReason(raw string) string {
|
||||
return trace.RedactTextLimited(strings.Join(strings.Fields(raw), " "), engageReasonRunes)
|
||||
}
|
||||
|
||||
// DefaultEngageConfig 返回 design/18 的参数默认值(P2a 基准;P2c 会让它们
|
||||
// 可按群配置/在面板编辑)。
|
||||
func DefaultEngageConfig() EngageConfig {
|
||||
@@ -243,16 +219,16 @@ func (b *Bridge) engageProbe(ctx context.Context, msg adapter.UnifiedMessage, ac
|
||||
}
|
||||
if d.AvoidTopic {
|
||||
b.toIdle(st)
|
||||
b.log.Debug("engage probe avoid topic, staying idle", "group", key, "reason", engageReason(d.Reason))
|
||||
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", engageReason(d.Reason))
|
||||
return true, "engage: " + engageReason(d.Reason)
|
||||
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", engageReason(d.Reason))
|
||||
b.log.Debug("engage probing → idle, no interest", "group", key, "reason", judgeReason(d.Reason))
|
||||
return false, "engage: no interest"
|
||||
}
|
||||
|
||||
@@ -271,14 +247,14 @@ func (b *Bridge) engageFollowUp(ctx context.Context, msg adapter.UnifiedMessage,
|
||||
}
|
||||
if d.AvoidTopic || (!d.WorthReply && !d.Interesting) {
|
||||
b.countNoInterest(st)
|
||||
b.log.Debug("engage follow-up no interest / avoid", "group", key, "reason", engageReason(d.Reason))
|
||||
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", engageReason(d.Reason))
|
||||
return true, "engage follow-up: " + engageReason(d.Reason)
|
||||
b.log.Debug("engage follow-up respond", "group", key, "reason", judgeReason(d.Reason))
|
||||
return true, "engage follow-up: " + judgeReason(d.Reason)
|
||||
}
|
||||
|
||||
// engageAfterFollowUp 判定一次无兴趣/回避跟进探测的结果。
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件钉住「参与度判定器的 reason 是模型自由文本,不得原样出镜」(engage.go 的
|
||||
// engageReason):Debug 日志与返回值必须同时满足「凭据打码」与「压平成一行的短句」。
|
||||
// 本文件钉住「参与度判定器的 reason 是模型自由文本,不得原样出镜」(engage.go 的 7 处
|
||||
// 出口,统一走 judge_reason.go 的 judgeReason):Debug 日志与返回值必须同时满足
|
||||
// 「凭据打码」与「压平成一行的短句」。
|
||||
//
|
||||
// 变异判据(每条都对应一个会红的实现):
|
||||
// - 把 engageReason 换成恒等返回(或摘掉 trace.RedactTextLimited)⇒ 日志与返回值里
|
||||
// - 把 judgeReason 换成恒等返回(或摘掉 trace.RedactTextLimited)⇒ 日志与返回值里
|
||||
// 都能搜到 sk-live…,红;
|
||||
// - 只做 RedactText 不做 strings.Fields 压平 ⇒ 换行仍在,红(见下面两条换行断言);
|
||||
// - 只改日志、不改返回值(`"engage: " + d.Reason` 保持原样)⇒ 返回值那条断言红。
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件是「判定模型/上游回来的自由文本怎么写进返回值与日志」的唯一收敛点。
|
||||
//
|
||||
// 它的三个使用者都是同一类出口:模型(或上游)给一句 reason,我们把它拼进返回值、
|
||||
// 再被上层落进日志 ——
|
||||
// - engage.go 的参与度判定(5 处 Debug + 2 处返回值);
|
||||
// - light.go 的旧轻量判定(2 处返回值,落到 coalesce_prepare.go 的 Debug);
|
||||
// - notice.go 的系统通知剔噪失败原因(拼进 noticeOutcome.Reason,落到事件 Debug)。
|
||||
//
|
||||
// 这类文本的两种病害都真实存在:模型可以把 Authorization / sk-… 回显进 reason(凭据),
|
||||
// 也可以塞一个换行(在按行聚合的日志里伪造出「另一条记录」)。因此出镜前必须
|
||||
// 「压平 + 打码 + 截断」,而且**只有这一份实现** —— 三处各写一份必然漂移,漂移的方向
|
||||
// 总是「其中一处为了多带点诊断把原文留下来」。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// judgeReasonRunes 是这类 reason 允许出镜的 rune 上限。
|
||||
//
|
||||
// 取 60 的理由:这些 reason 由「只输出 JSON 对象」的轻量判定器给出,实际值是一句短语
|
||||
// (「群里在聊周末安排,与角色无关」≈ 16 字),60 rune 容得下三四句这样的短语;一旦
|
||||
// 超过 60 rune,说明它在**复述群聊原文** —— 那部分对排障没有价值(要的是「判定方向 +
|
||||
// 方向词」),却正是最不该进日志的那段话。
|
||||
const judgeReasonRunes = 60
|
||||
|
||||
// judgeReason 把一段判定/上游 reason 整理成可以出镜的短句(返回值与日志共用这同一份,
|
||||
// 口径不会分叉):
|
||||
//
|
||||
// - 压平空白:一个换行就能在按行聚合的日志里伪造出「另一条记录」;返回值也会流进
|
||||
// 日志(coalesce_prepare 的 Debug),所以同样要压;
|
||||
// - 凭据清洗走 trace.RedactTextLimited(与 trace.RedactText 同一实现,不是第二套
|
||||
// 脱敏路径):模型可能把 Authorization / sk-… 之类原样回显进 reason;
|
||||
// - 截到 judgeReasonRunes(见上)。
|
||||
//
|
||||
// 🔴 本函数只决定「进去的那份文本」,不参与任何判定:调用点的分支条件、状态迁移与
|
||||
// 返回值拼接结构一个字都不动(2026-10-26 塔口径)。
|
||||
func judgeReason(raw string) string {
|
||||
return trace.RedactTextLimited(strings.Join(strings.Fields(raw), " "), judgeReasonRunes)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件钉住 judgeReason 的三个使用者(engage.go / light.go / notice.go)都真的走它。
|
||||
// engage.go 那 7 处由 engage_reason_test.go 覆盖;这里覆盖后两处与 helper 本身。
|
||||
//
|
||||
// 变异判据(每条都对应一个会红的实现):
|
||||
// - 把 judgeReason 换成恒等返回(摘掉 trace.RedactTextLimited)⇒ 三条用例都红;
|
||||
// - 只做 RedactText 不做 strings.Fields 压平 ⇒ helper 那条与 light 那条红(换行仍在);
|
||||
// - 把 light.go / notice.go 的调用点改回原文(`d.Reason` / `err.Error()`)⇒ 对应用例红。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
// judgeReasonSecret 是模型/上游「回显」进 reason 的凭据形状。
|
||||
const judgeReasonSecret = "sk-live-0123456789abcdef"
|
||||
|
||||
// judgeReasonRaw 是原始 reason:短语 + 回显的凭据 + 换行(想在按行聚合的日志里伪造
|
||||
// 一条新记录)。
|
||||
const judgeReasonRaw = "第一行:群里有 " + judgeReasonSecret + "\n第二行:伪造一条日志"
|
||||
|
||||
// judgeReasonWant 是 judgeReason 的期望产物。
|
||||
const judgeReasonWant = "第一行:群里有 [redacted] 第二行:伪造一条日志"
|
||||
|
||||
// assertSanitizedReason 断言一段出镜文本既不含凭据原文,也不含换行。
|
||||
func assertSanitizedReason(t *testing.T, what, got string) {
|
||||
t.Helper()
|
||||
if strings.Contains(got, "sk-live") {
|
||||
t.Fatalf("%s 里出现了回显的凭据:%q", what, got)
|
||||
}
|
||||
if !strings.Contains(got, "[redacted]") {
|
||||
t.Errorf("%s 里没有打码标记:%q", what, got)
|
||||
}
|
||||
if strings.Contains(got, "\n") {
|
||||
t.Fatalf("%s 里出现了换行(应被压平):%q", what, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJudgeReason_RedactsFlattensAndBounds 钉住 helper 本身的三件事。
|
||||
func TestJudgeReason_RedactsFlattensAndBounds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := judgeReason(judgeReasonRaw); got != judgeReasonWant {
|
||||
t.Fatalf("judgeReason = %q,期望 %q", got, judgeReasonWant)
|
||||
}
|
||||
// 上限按 rune 计:60 个汉字必须原样保留(不能被截),61 个才截断。
|
||||
if got := judgeReason(strings.Repeat("中", judgeReasonRunes)); got != strings.Repeat("中", judgeReasonRunes) {
|
||||
t.Fatalf("恰好 %d rune 不该被截断:%q", judgeReasonRunes, got)
|
||||
}
|
||||
if got := judgeReason(strings.Repeat("中", judgeReasonRunes+1)); len([]rune(got)) != judgeReasonRunes+1 {
|
||||
t.Fatalf("超限时应保留 %d rune + 省略号,实际 %q", judgeReasonRunes, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyLightJudge_ReasonSanitized 覆盖 light.go 的两处返回值(旧轻量判定车道):
|
||||
// reason 出镜前必须打码压平(它随后被 coalesce_prepare 的 Debug 落日志)。
|
||||
func TestLegacyLightJudge_ReasonSanitized(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, respond := range []bool{true, false} {
|
||||
judge := &stubLightJudge{resp: LightDecision{Respond: respond, Reason: judgeReasonRaw}}
|
||||
b, err := New(Options{
|
||||
Log: logger.New(logger.Options{Level: "debug", Out: &strings.Builder{}}),
|
||||
Loop: &stubChatter{res: &loop.Result{Text: "x"}},
|
||||
Sessions: &stubSessions{sess: nonOwnerSession()},
|
||||
Owner: &user.OwnerProfile{},
|
||||
Light: judge,
|
||||
TriggerWords: []string{"昔涟"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
msg := nonOwnerGroupMsg("提一句昔涟")
|
||||
got, reason := b.legacyLightJudge(context.Background(), msg, msg.Sender, false)
|
||||
b.Stop(context.Background())
|
||||
if got != respond {
|
||||
t.Fatalf("legacyLightJudge respond = %v, 期望 %v", got, respond)
|
||||
}
|
||||
assertSanitizedReason(t, "light judge 返回值", reason)
|
||||
if !strings.Contains(reason, "light judge: "+judgeReasonWant) {
|
||||
t.Fatalf("返回值 = %q,期望含 %q", reason, "light judge: "+judgeReasonWant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoticeReviewFailureReasonSanitized 覆盖 notice.go 的失败原因:
|
||||
// 审查模型(或它背后的上游)的 error 文本进 noticeOutcome.Reason 前同样要打码压平。
|
||||
func TestNoticeReviewFailureReasonSanitized(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
review := func(context.Context, []adapter.UnifiedMessage) ([]int, error) {
|
||||
return nil, errors.New(judgeReasonRaw)
|
||||
}
|
||||
ad := ¬iceAdapter{notice: adapter.SystemNotice{Kind: adapter.NoticeOther, SafeText: "群/客户端系统事件"}, ok: true}
|
||||
b, _, _, _ := noticeFixture(t, ad, review)
|
||||
|
||||
msg := groupNoticeMessage()
|
||||
out := b.preEntryReview(context.Background(), &msg, ad)
|
||||
if out.Proceed {
|
||||
t.Fatalf("审查失败必须走 fail-safe 静默,实际放行了:reason=%q", out.Reason)
|
||||
}
|
||||
if !strings.HasPrefix(out.Reason, "notice review failed: ") {
|
||||
t.Fatalf("原因前缀被改动了:%q", out.Reason)
|
||||
}
|
||||
assertSanitizedReason(t, "notice 审查失败原因", out.Reason)
|
||||
}
|
||||
@@ -279,9 +279,9 @@ func (b *Bridge) legacyLightJudge(ctx context.Context, msg adapter.UnifiedMessag
|
||||
})
|
||||
if err == nil {
|
||||
if d.Respond {
|
||||
return true, "light judge: " + d.Reason
|
||||
return true, "light judge: " + judgeReason(d.Reason)
|
||||
}
|
||||
return false, "light judge: " + d.Reason
|
||||
return false, "light judge: " + judgeReason(d.Reason)
|
||||
}
|
||||
b.log.Warn("light judge failed; silencing", "error", err)
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ func (b *Bridge) preEntryReview(ctx context.Context, msg *adapter.UnifiedMessage
|
||||
default:
|
||||
keep, err := b.reviewNoticeNoise(ctx, *msg, notice)
|
||||
if err != nil {
|
||||
out.Reason = "notice review failed: " + err.Error()
|
||||
out.Reason = "notice review failed: " + judgeReason(err.Error())
|
||||
out.Proceed = noticeFailSafeProceeds
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user