fix(channelbridge): IM 渠道也走「回合必落可见回复」(与网关同口径)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

根因(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:
AskaEth committed 2026-10-04 22:29:54 +08:00
1 parent be38651ba4
commit fbbbb4ea64
6 files changed
+380 -73

No files matched your search

@@ -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)