fix(channelbridge): IM 渠道也走「回合必落可见回复」(与网关同口径)
根因(agent-194,文件:行): - inbound.go 的 deliverReply 对空回复直接 return(静默)⇒ 只剩 user 消息; - runInboundTurn 报错直接 return ⇒ 失败回合连说明都没有。 修法:抽共享**纯函数**包 internal/runtime/turnreply(从网关逐字搬运、零语义改动)+ channelbridge/turnreply.go 的 turnVisibleReply(与网关 persistTurnReply 同构); 取消边界在 IM 用 context.Canceled / ctx 取消表达(渠道桥无代次概念,如实登记差异)。 测试 6 条:空回复/纯空白/轮次用尽/安全网/回合报错/取消不落库/正常逐字不变。 变异:还原旧静默行为 ⇒ 4 条红(含「出站 0 条,want 1」与英文内部诊断外泄)。
This commit is contained in:
1 parent
be38651ba4
commit
fbbbb4ea64
6 files changed
+380
-73
No files matched your search
@@ -0,0 +1,52 @@
|
||||
// Package turnreply 统一「一个回合应当交付给用户看的可见回复文本」的口径。
|
||||
//
|
||||
// 两条编排路径都受同一条不静默铁律约束 —— **归本代次、且不是被用户打断的
|
||||
// 回合,用户一定要看到一条回复**:网关(Web / REST / WS 回合,见
|
||||
// internal/services/gateway/session_turn_reply.go)与渠道桥(IM 回合,见
|
||||
// internal/services/channelbridge/turnreply.go)。没有真实文本时补的是同一份
|
||||
// 中文诚实说明;把它放在共享包里,是为了让文案只有一处定义 —— 两条路径各写
|
||||
// 一套「抱歉我出问题了」迟早会漂移成两种说法,而它们是同一张脸在说话。
|
||||
//
|
||||
// 本包只做纯映射:输入 *loop.Result,输出该交付的文本。**是否该落库**(被取消、
|
||||
// 陈旧代次、静默内部流程……)由调用方按各自的编排状态判定,不在本包内 ——
|
||||
// 网关有会话代次,IM 没有,这个差异必须留在各自的编排器里。
|
||||
package turnreply
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
)
|
||||
|
||||
// 静默收尾的诚实说明文案(全部中文,按结束原因区分)。它们是**用户可见**的聊天
|
||||
// 内容,因此只说明「没答上来 + 下一步能怎么办」,不写内部术语、不写堆栈。
|
||||
const (
|
||||
// FallbackEmptyReply 用于模型给出空回复的回合(没有正文、也没有工具调用)。
|
||||
FallbackEmptyReply = "抱歉,我这次没能组织出回复内容(模型返回了空回复)。你可以再说一次,或者换个说法。"
|
||||
// FallbackMaxRounds 用于工具循环耗尽轮次预算仍没得出结论的回合。
|
||||
FallbackMaxRounds = "抱歉,我这次的工具调用反复失败、把思考轮次用尽了,还没能得出结论。你可以再说一次,或者把要求拆小一点。"
|
||||
// FallbackToolLoop 用于关键工具循环安全网终止的回合(同一个工具调用跨轮重复)。
|
||||
FallbackToolLoop = "抱歉,我在同一个工具调用上反复碰壁,已经把这次回合停下了。你可以换个说法再说一次。"
|
||||
// FallbackError 用于回合因内部错误(模型 / 工具 / 编排)中断的回合。
|
||||
FallbackError = "抱歉,我这边出了点问题,这个回合没能给出回复。你可以稍后再试一次。"
|
||||
)
|
||||
|
||||
// Text 返回本回合**应当交付**的可见回复文本:真实文本逐字原样返回,
|
||||
// 没有可见文本、或只有关键工具循环安全网塞进来的内部英文文案时,换成按结束
|
||||
// 原因区分的诚实说明。res 为 nil(回合报错、没带回结果)按内部错误处理。
|
||||
func Text(res *loop.Result) string {
|
||||
if res == nil {
|
||||
return FallbackError
|
||||
}
|
||||
if strings.TrimSpace(res.Text) != "" && res.StopReason != loop.StopReasonToolLoopTerminated {
|
||||
return res.Text
|
||||
}
|
||||
switch res.StopReason {
|
||||
case loop.StopReasonToolLoopTerminated:
|
||||
return FallbackToolLoop
|
||||
case loop.StopReasonMaxRounds:
|
||||
return FallbackMaxRounds
|
||||
default:
|
||||
return FallbackEmptyReply
|
||||
}
|
||||
}
|
||||
@@ -183,23 +183,6 @@ func TestProcessSkipsEmptyText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessLoopErrorDoesNotSend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b, chat, _, ad := newTestBridge(t)
|
||||
chat.res = nil
|
||||
chat.err = context.DeadlineExceeded
|
||||
|
||||
b.process(context.Background(), directMessage())
|
||||
|
||||
if chat.sessionID != history.MainSessionID {
|
||||
t.Errorf("loop should still run for valid text on the main session, session = %q", chat.sessionID)
|
||||
}
|
||||
if len(ad.sends) != 0 {
|
||||
t.Errorf("send count = %d, want 0 on loop error", len(ad.sends))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessGroupMessageKeepsGroupTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -231,19 +214,6 @@ func TestProcessGroupMessageKeepsGroupTarget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessEmptyReplySendsNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b, chat, _, ad := newTestBridge(t)
|
||||
chat.res = &loop.Result{Text: " "}
|
||||
|
||||
b.process(context.Background(), directMessage())
|
||||
|
||||
if len(ad.sends) != 0 {
|
||||
t.Errorf("send count = %d, want 0 for empty reply", len(ad.sends))
|
||||
}
|
||||
}
|
||||
|
||||
// captureHistory 记录每次 Append,用于断言桥接器把主人私聊
|
||||
// 消息写入 Web 主会话(design/16 §4.4)。
|
||||
type captureHistory struct {
|
||||
|
||||
@@ -266,27 +266,38 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
|
||||
if err != nil {
|
||||
b.log.Warn("agent run failed", "platform", msg.Platform,
|
||||
"session", target.sessionID, "error", err)
|
||||
return
|
||||
}
|
||||
b.deliverReply(ctx, msg, a, sess, target, res)
|
||||
// 出错也交给 deliverReply:回合不能因为「模型那边出了问题」就对用户静默
|
||||
// (空回复 / 轮次用尽 / 内部报错都落一条诚实的可见回复;被取消的回合除外,
|
||||
// 见 turnreply.go)。
|
||||
b.deliverReply(ctx, msg, a, sess, target, res, err)
|
||||
}
|
||||
|
||||
// deliverReply 持久化助手回复,并经接收该消息的适配器
|
||||
// 送回去。不支持出站文本的适配器
|
||||
// deliverReply 解析本回合的可见回复、持久化并经接收该消息的适配器
|
||||
// 送回去。可见回复由 turnVisibleReply 给出(turnreply.go):真实文本逐字,
|
||||
// 空回复 / 轮次用尽 / 安全网终止 / 内部报错则是按原因区分的中文诚实说明 ——
|
||||
// IM 回合因此绝不会「没声了」;被取消的回合刻意不交付。不支持出站文本的适配器
|
||||
// (adapter.ErrUnsupported)只按 debug 级跳过,不算失败:回复
|
||||
// 已在历史存储中落地。
|
||||
func (b *Bridge) deliverReply(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter,
|
||||
sess session.Session, target inboundTarget, res *loop.Result) {
|
||||
reply := strings.TrimSpace(res.Text)
|
||||
if reply == "" {
|
||||
b.log.Debug("no reply produced", "platform", msg.Platform, "session", target.sessionID)
|
||||
sess session.Session, target inboundTarget, res *loop.Result, runErr error) {
|
||||
reply, ok := turnVisibleReply(ctx, res, runErr)
|
||||
if !ok {
|
||||
b.log.Debug("turn reply canceled, nothing delivered",
|
||||
"platform", msg.Platform, "session", target.sessionID)
|
||||
return
|
||||
}
|
||||
// mood 只挂在助手回复上(design/16 §3.3),且只有真实结果才带得回来
|
||||
// (回合报错时 res 为 nil)。
|
||||
mood := ""
|
||||
if res != nil {
|
||||
mood = res.Mood
|
||||
}
|
||||
// 持久化助手回复(主人私聊 → 主会话 + message_persisted
|
||||
// 广播;受限 → 隔离日志 + channel_persisted 广播),随后
|
||||
// 送回渠道。
|
||||
if target.private {
|
||||
b.persistToMain(ctx, history.RoleAssistant, reply, res.Mood, target.platform, deviceIDFor(msg), adapterName(a))
|
||||
b.persistToMain(ctx, history.RoleAssistant, reply, mood, target.platform, deviceIDFor(msg), adapterName(a))
|
||||
} else {
|
||||
b.persistIsolated(ctx, history.RoleAssistant, reply, "", sess, msg.Platform, "", adapterName(a))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/turnreply"
|
||||
)
|
||||
|
||||
// 本文件承载 IM 回合的「可见回复」判定(切片 KB1 的渠道侧一半)。
|
||||
//
|
||||
// 缺口形状:网关侧已保证「归本代次、且不是被用户打断的回合,历史里一定有一条
|
||||
// 可见的 assistant 消息」(gateway/session_turn_reply.go),但 IM 走的是**另一条
|
||||
// 路** —— 渠道桥自己驱动 loop 并自行落库,过去的 deliverReply 遇到空文本直接
|
||||
// return,于是模型给空回复(只有思维链、没有正文)时,IM 侧就是「她没声了」:
|
||||
// 既没有出站消息,也没有任何说明。轮次用尽、安全网终止、回合内部报错这三条出口
|
||||
// 同样静默。
|
||||
//
|
||||
// 文案与判定口径与网关**逐字一致**(同一份文案来自共享包
|
||||
// internal/runtime/turnreply),但「该不该交付」的边界由渠道桥自己的编排状态决定:
|
||||
// IM 没有网关那套会话代次(interrupt / 续跑队列是 Web 会话的概念),这里的取消
|
||||
// 判据是**处理上下文被取消** —— 桥接器 Stop 会取消 b.ctx,在途回合随之取消,
|
||||
// 用户意图就是「停下」,此时补一句「我出问题了」反而违背那条指令。
|
||||
|
||||
// turnVisibleReply 返回本回合应当交付给渠道的可见回复:reply 为要交付(并落库)
|
||||
// 的文本,ok=false 表示这个回合**刻意什么都不交付**(被取消)。
|
||||
//
|
||||
// 与网关 persistTurnReply 同构:有真实文本就交付真实文本(连被取消的回合也照旧
|
||||
// 交付 —— 内容是真的,与「不静默」无关);没有可见文本时,只有「未被取消」才补
|
||||
// 一条按结束原因区分的诚实说明。
|
||||
//
|
||||
// 真实文本按渠道既有行为去掉首尾空白后交付(内容一字不改),与加入本保证之前
|
||||
// 的行为完全一致。
|
||||
func turnVisibleReply(ctx context.Context, res *loop.Result, runErr error) (string, bool) {
|
||||
text := turnreply.Text(res)
|
||||
if res != nil && res.Text == text {
|
||||
return strings.TrimSpace(text), true
|
||||
}
|
||||
if errors.Is(runErr, context.Canceled) || ctx.Err() != nil {
|
||||
return "", false
|
||||
}
|
||||
return text, true
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/turnreply"
|
||||
)
|
||||
|
||||
// 本文件锁定切片 KB1 的渠道侧一半:**IM 回合也绝不能对用户静默**。
|
||||
//
|
||||
// 缺口形状(与网关侧实测同一根因):渠道桥自己驱动 loop 并自行落库,过去的
|
||||
// deliverReply 见到空文本直接 return,于是模型给空回复(只有思维链、没有正文)
|
||||
// 时,IM 侧就是「她没声了」:没有出站消息、历史里也只有那条用户消息。轮次用尽、
|
||||
// 安全网终止、回合内部报错三条出口同样静默。
|
||||
//
|
||||
// 判定口径与网关逐字一致(同一份文案来自共享包 internal/runtime/turnreply),
|
||||
// 边界差异只有取消:IM 没有网关的会话代次(interrupt / 续跑队列是 Web 会话的
|
||||
// 概念),这里的取消判据是**处理上下文被取消**(桥接器 Stop 会取消 b.ctx)。
|
||||
// 因此本文件用「被取消的回合不交付」覆盖网关侧「被 interrupt 取消 / 陈旧代次
|
||||
// 不落库」那两条边界在 IM 上的等价物。
|
||||
//
|
||||
// 断言走真路径:真入站流水线(process → 会话解析 → actor → 落库 → 出站)+ 桩
|
||||
// Chatter,只有模型那一侧是脚本化的。
|
||||
|
||||
// replyTurnBridge 装配一个主人私聊 IM 回合的桥接器:桩 Chatter + 记录型历史 +
|
||||
// 已注册的 telegram 适配器。主人私聊 ⇒ 回复落 Web 主会话,因此可以直接读
|
||||
// records 断言落库内容。
|
||||
func replyTurnBridge(t *testing.T, chat *stubChatter) (*Bridge, *fakeAdapter, *captureHistory) {
|
||||
t.Helper()
|
||||
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
|
||||
hist := &captureHistory{}
|
||||
ad := &fakeAdapter{name: "telegram"}
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: hist,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
b.Add(ad)
|
||||
return b, ad, hist
|
||||
}
|
||||
|
||||
// assertAssistantReply 断言历史里恰好一条 user + 恰好一条 assistant,且 assistant
|
||||
// 内容逐字等于 want(用户消息必须原样留着 —— 回合跑过就该留下痕迹)。
|
||||
func assertAssistantReply(t *testing.T, hist *captureHistory, want string) history.Message {
|
||||
t.Helper()
|
||||
recs := hist.records()
|
||||
if len(recs) != 2 {
|
||||
t.Fatalf("落库记录 = %d 条 (%+v), want 2 (user + assistant)", len(recs), recs)
|
||||
}
|
||||
if recs[0].Role != history.RoleUser || recs[0].Content != "hello" {
|
||||
t.Fatalf("user 记录 = %+v, want 原样的 inbound 文本", recs[0])
|
||||
}
|
||||
if recs[1].Role != history.RoleAssistant {
|
||||
t.Fatalf("第二条记录 role = %q, want assistant", recs[1].Role)
|
||||
}
|
||||
if strings.TrimSpace(recs[1].Content) == "" {
|
||||
t.Fatal("落库的 assistant 内容为空 —— IM 侧就是「她没声了」")
|
||||
}
|
||||
if recs[1].Content != want {
|
||||
t.Fatalf("assistant 内容 = %q, want %q", recs[1].Content, want)
|
||||
}
|
||||
return recs[1]
|
||||
}
|
||||
|
||||
// TestTurnReply_EmptyReplyDeliversHonestChineseText 是必测①:IM 回合拿到空回复
|
||||
// (空串与纯空白两种写法)时,必须落一条**非空的中文** assistant 消息,并经同一
|
||||
// 适配器发出去 —— 而不是过去那条空消息/静默。
|
||||
func TestTurnReply_EmptyReplyDeliversHonestChineseText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
}{
|
||||
{"完全空串", ""},
|
||||
{"纯空白", " "},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
chat := &stubChatter{res: &loop.Result{Text: c.text}}
|
||||
b, ad, hist := replyTurnBridge(t, chat)
|
||||
|
||||
b.process(context.Background(), directMessage())
|
||||
|
||||
if len(ad.sends) != 1 {
|
||||
t.Fatalf("出站消息 = %d 条, want 1(空回复也必须有可见回复)", len(ad.sends))
|
||||
}
|
||||
got := ad.sends[0].Content
|
||||
if got != turnreply.FallbackEmptyReply {
|
||||
t.Fatalf("出站文案 = %q, want 共享包的诚实说明 %q", got, turnreply.FallbackEmptyReply)
|
||||
}
|
||||
if !strings.Contains(got, "抱歉") {
|
||||
t.Fatalf("文案不是中文诚实说明: %q", got)
|
||||
}
|
||||
assertAssistantReply(t, hist, turnreply.FallbackEmptyReply)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnReply_TerminalStopsUseHonestChineseText 覆盖另外两条静默出口:轮次用尽
|
||||
// 与关键工具循环安全网终止。安全网那一条尤其重要 —— loop 塞进 Result.Text 的是
|
||||
// **内部英文诊断**,绝不能原样发给 IM 用户(与网关侧同一条断言)。
|
||||
func TestTurnReply_TerminalStopsUseHonestChineseText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
res *loop.Result
|
||||
want string
|
||||
}{
|
||||
{"轮次用尽", &loop.Result{Text: "", StopReason: loop.StopReasonMaxRounds}, turnreply.FallbackMaxRounds},
|
||||
{"安全网终止",
|
||||
&loop.Result{Text: "critical tool-loop repetition detected", StopReason: loop.StopReasonToolLoopTerminated},
|
||||
turnreply.FallbackToolLoop},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
chat := &stubChatter{res: c.res}
|
||||
b, ad, hist := replyTurnBridge(t, chat)
|
||||
|
||||
b.process(context.Background(), directMessage())
|
||||
|
||||
if len(ad.sends) != 1 {
|
||||
t.Fatalf("出站消息 = %d 条, want 1", len(ad.sends))
|
||||
}
|
||||
if got := ad.sends[0].Content; got != c.want {
|
||||
t.Fatalf("出站文案 = %q, want %q", got, c.want)
|
||||
}
|
||||
if strings.Contains(ad.sends[0].Content, "critical tool-loop") {
|
||||
t.Fatalf("内部英文诊断文案漏给了 IM 用户: %q", ad.sends[0].Content)
|
||||
}
|
||||
assertAssistantReply(t, hist, c.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnReply_RunErrorDeliversHonestChineseText 覆盖「失败回合」:loop 返回错误
|
||||
// (模型 / 工具 / 编排报错)时,IM 用户仍要看到一条说明,而不是一片安静。
|
||||
func TestTurnReply_RunErrorDeliversHonestChineseText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chat := &stubChatter{res: nil, err: errors.New("model: upstream 502")}
|
||||
b, ad, hist := replyTurnBridge(t, chat)
|
||||
|
||||
b.process(context.Background(), directMessage())
|
||||
|
||||
if len(ad.sends) != 1 {
|
||||
t.Fatalf("出站消息 = %d 条, want 1(回合报错也要留下说明)", len(ad.sends))
|
||||
}
|
||||
if got := ad.sends[0].Content; got != turnreply.FallbackError {
|
||||
t.Fatalf("出站文案 = %q, want %q", got, turnreply.FallbackError)
|
||||
}
|
||||
assertAssistantReply(t, hist, turnreply.FallbackError)
|
||||
}
|
||||
|
||||
// TestTurnReply_CanceledTurnDeliversNothing 是必测②(IM 侧的边界):被取消的回合
|
||||
// **刻意什么都不交付** —— 用户意图就是「停下」,此刻补一句「我出问题了」反而违背
|
||||
// 那条指令。两个取消信号都要认:回合以 context.Canceled 结束,以及处理上下文本身
|
||||
// 已被取消(桥接器 Stop 取消 b.ctx 的形状)。
|
||||
func TestTurnReply_CanceledTurnDeliversNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("回合以取消结束", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
chat := &stubChatter{res: nil, err: context.Canceled}
|
||||
b, ad, hist := replyTurnBridge(t, chat)
|
||||
|
||||
b.process(context.Background(), directMessage())
|
||||
|
||||
assertNothingDelivered(t, ad, hist)
|
||||
})
|
||||
|
||||
t.Run("处理上下文已取消", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// 空回复 + 已取消的上下文:正是桥接器 Stop 打断在途回合的形状。
|
||||
chat := &stubChatter{res: &loop.Result{Text: ""}}
|
||||
b, ad, hist := replyTurnBridge(t, chat)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
b.process(ctx, directMessage())
|
||||
|
||||
assertNothingDelivered(t, ad, hist)
|
||||
})
|
||||
}
|
||||
|
||||
// assertNothingDelivered 断言这个回合既没有出站消息,也没有落库 assistant 消息
|
||||
// (只留下那条用户消息 —— 它是回合开始前就写好的)。
|
||||
func assertNothingDelivered(t *testing.T, ad *fakeAdapter, hist *captureHistory) {
|
||||
t.Helper()
|
||||
if len(ad.sends) != 0 {
|
||||
t.Fatalf("被取消的回合发了 %d 条消息, want 0: %+v", len(ad.sends), ad.sends)
|
||||
}
|
||||
recs := hist.records()
|
||||
if len(recs) != 1 || recs[0].Role != history.RoleUser {
|
||||
t.Fatalf("落库记录 = %+v, want 只有那条 user 消息", recs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnReply_NormalReplyPassedThroughVerbatim 是必测③:正常回合(有正文)
|
||||
// 的出站与落库内容与模型正文**逐字相同**,绝不掺进兜底文案。
|
||||
func TestTurnReply_NormalReplyPassedThroughVerbatim(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const want = "你好,我在。今天想聊点什么?"
|
||||
chat := &stubChatter{res: &loop.Result{Text: want, Mood: "平静"}}
|
||||
b, ad, hist := replyTurnBridge(t, chat)
|
||||
|
||||
b.process(context.Background(), directMessage())
|
||||
|
||||
if len(ad.sends) != 1 {
|
||||
t.Fatalf("出站消息 = %d 条, want 1", len(ad.sends))
|
||||
}
|
||||
if got := ad.sends[0].Content; got != want {
|
||||
t.Fatalf("出站文案 = %q, want 逐字等于模型正文", got)
|
||||
}
|
||||
rec := assertAssistantReply(t, hist, want)
|
||||
if rec.Mood != "平静" {
|
||||
t.Fatalf("assistant mood = %q, want 平静(真实结果的心情照旧带到消息上)", rec.Mood)
|
||||
}
|
||||
for _, fallback := range []string{turnreply.FallbackEmptyReply, turnreply.FallbackError} {
|
||||
if strings.Contains(rec.Content, fallback) {
|
||||
t.Fatalf("正常回复里混进了兜底文案: %q", rec.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnReply_EmptyInboundContentStillDropped 守住边界:无文本(且无附件)的
|
||||
// 入站消息仍按既有规则丢弃 —— 本改动只补**回合的回复**,绝不放垃圾消息进库。
|
||||
func TestTurnReply_EmptyInboundContentStillDropped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chat := &stubChatter{res: &loop.Result{Text: "不该出现"}}
|
||||
b, ad, hist := replyTurnBridge(t, chat)
|
||||
msg := directMessage()
|
||||
msg.Content = " "
|
||||
|
||||
b.process(context.Background(), msg)
|
||||
|
||||
if chat.sessionID != "" {
|
||||
t.Fatalf("空内容消息驱动了循环, session = %q", chat.sessionID)
|
||||
}
|
||||
if len(ad.sends) != 0 {
|
||||
t.Fatalf("空内容消息发了 %d 条消息, want 0", len(ad.sends))
|
||||
}
|
||||
if recs := hist.records(); len(recs) != 0 {
|
||||
t.Fatalf("空内容消息落库 = %+v, want 0 条", recs)
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,9 @@ package gateway
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/turnreply"
|
||||
)
|
||||
|
||||
// 本文件归属「回合收尾必须留下可见回复」这条不静默铁律(切片 KB1)。
|
||||
@@ -33,38 +33,9 @@ import (
|
||||
// - 静默内部流程(backthink、子代理):它们各自驱动 loop,从不经过本编排器,
|
||||
// 因而天然不受这条保证影响(它们的产出只进内部状态,不进聊天历史)。
|
||||
|
||||
// 静默收尾的诚实说明文案(全部中文,按结束原因区分)。它们是**用户可见**的聊天
|
||||
// 内容,因此只说明「没答上来 + 下一步能怎么办」,不写内部术语、不写堆栈。
|
||||
const (
|
||||
// turnFallbackEmptyReply 用于模型给出空回复的回合(没有正文、也没有工具调用)。
|
||||
turnFallbackEmptyReply = "抱歉,我这次没能组织出回复内容(模型返回了空回复)。你可以再说一次,或者换个说法。"
|
||||
// turnFallbackMaxRounds 用于工具循环耗尽轮次预算仍没得出结论的回合。
|
||||
turnFallbackMaxRounds = "抱歉,我这次的工具调用反复失败、把思考轮次用尽了,还没能得出结论。你可以再说一次,或者把要求拆小一点。"
|
||||
// turnFallbackToolLoop 用于关键工具循环安全网终止的回合(同一个工具调用跨轮重复)。
|
||||
turnFallbackToolLoop = "抱歉,我在同一个工具调用上反复碰壁,已经把这次回合停下了。你可以换个说法再说一次。"
|
||||
// turnFallbackError 用于回合因内部错误(模型 / 工具 / 编排)中断的回合。
|
||||
turnFallbackError = "抱歉,我这边出了点问题,这个回合没能给出回复。你可以稍后再试一次。"
|
||||
)
|
||||
|
||||
// turnReplyText 返回本回合**应当落库**的可见回复文本:真实文本逐字原样返回,
|
||||
// 没有可见文本、或只有关键工具循环安全网塞进来的内部英文文案时,换成按结束
|
||||
// 原因区分的诚实说明。
|
||||
func turnReplyText(res *loop.Result) string {
|
||||
if res == nil {
|
||||
return turnFallbackError
|
||||
}
|
||||
if strings.TrimSpace(res.Text) != "" && res.StopReason != loop.StopReasonToolLoopTerminated {
|
||||
return res.Text
|
||||
}
|
||||
switch res.StopReason {
|
||||
case loop.StopReasonToolLoopTerminated:
|
||||
return turnFallbackToolLoop
|
||||
case loop.StopReasonMaxRounds:
|
||||
return turnFallbackMaxRounds
|
||||
default:
|
||||
return turnFallbackEmptyReply
|
||||
}
|
||||
}
|
||||
// 「空回复 / 轮次用尽 / 安全网终止 / 内部报错该说什么」由共享包
|
||||
// internal/runtime/turnreply 统一给出(IM 渠道桥走另一条编排路径,两边必须是
|
||||
// 同一份诚实说明;各写一套迟早漂移)。本文件只负责判定**这次收尾该不该落库**。
|
||||
|
||||
// turnIsCurrent 报告该代次是否仍是该会话的当前代次。陈旧代次(已被 interrupt
|
||||
// 或更新的顶层回合取代)意味着这次收尾属于**上一个**回合,不该再往历史里写。
|
||||
@@ -91,7 +62,7 @@ func (o *Orchestrator) persistTurnReply(sessionID string, gen uint64, res *loop.
|
||||
if o.history == nil {
|
||||
return res
|
||||
}
|
||||
text := turnReplyText(res)
|
||||
text := turnreply.Text(res)
|
||||
if res != nil && res.Text == text {
|
||||
// 真实文本:逐字落库,行为与加入本保证之前完全一致。
|
||||
o.persistAssistantReply(sessionID, res, deviceID, mode, personaID)
|
||||
|
||||
Reference in new issue
Block a user