Files
Cyrene_v2/internal/services/gateway/session_turn_reply.go
T
AskaEth 18381f65fd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,loop,tools): 失败回合必落可见回复 + knowledge 角色可信
根因(agent-191,均给出文件:行):
1) knowledge 的 persona 在 schema 里是 optional,模型省略时工具把空串透给
   memory.Retriever → 硬校验 ErrMissingPersona → 模型只能重复调用同一堵墙。
   修:新增 tool/turncontext.go(loop 播种本回合角色),knowledge 依次取
   「参数 → 本回合角色 → 可消化拒绝」,拒绝文案明确「重试不会成功」。
2) persistAssistantReply 对空文本直接 return,而三条正常收尾出口(空回复 /
   轮次用尽 / 回合内部报错)都会给空文本 ⇒ 历史只剩 user 消息、客户端静默。
   修:新增 session_turn_reply.go 的 persistTurnReply(唯一落库入口 + 按原因
   的中文诚实说明);中断 / 陈旧代次 / 静默内部流程(backthink、子代理调度)
   仍不落库,真实文本行为逐字未改。

测试:knowledge_test 改断言 + turn_persona_test(新)+ session_turn_reply_test(新,7 条)
变异:去掉 loop 角色发布 ⇒ loop×2 + gateway「工具执行次数 = 0, want 2」红
2026-10-04 22:14:42 +08:00

110 lines
5.8 KiB
Go

package gateway
import (
"context"
"errors"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// 本文件归属「回合收尾必须留下可见回复」这条不静默铁律(切片 KB1)。
//
// 缺陷形状(安卓侧实测 E2E-RETRY,纯文本回合):模型连续两次调用一个失败的
// `knowledge` 工具,随后给出一段**空回复**(只有思维链、没有正文)—— 循环按
// 「正常收尾」落定,Result.Text 为空,而助手回复的落库对空文本是静默空操作,
// 于是历史里只剩那条用户消息:客户端两张「工具调用失败」卡片之后直接回空闲,
// 既没有回复、也没有错误横幅。
//
// 同类静默出口还有两个,全都在这里收敛:
// - 工具循环耗尽轮次预算(loop 返回 StopReasonMaxRounds 且 Text 为空);
// - 回合内部报错(Run 返回 nil Result + error:模型 / 工具 / 编排报错)。
//
// 保证是:**归本代次、且不是被用户打断的回合,历史里一定有一条可见的 assistant
// 消息** —— 没有真实文本时补一条按结束原因区分的诚实说明。落库仍走
// persistAssistantReply(同一条写入 + message_persisted 广播路径),因此历史接口、
// 跨端同步与客户端渲染都与真实回复完全一致,不需要任何新契约。
//
// 三类**不补**的情形(边界,与既有语义不冲突):
// - 被 interrupt 取消的回合:用户意图就是「停下」,此刻补一条「我出问题了」
// 反而违背那条指令;
// - 被更新的回合取代的陈旧代次(st.gen 已前进):那次收尾不属于当前会话状态,
// 补消息会落到别人的回合里;
// - 静默内部流程(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
}
}
// turnIsCurrent 报告该代次是否仍是该会话的当前代次。陈旧代次(已被 interrupt
// 或更新的顶层回合取代)意味着这次收尾属于**上一个**回合,不该再往历史里写。
func (o *Orchestrator) turnIsCurrent(sessionID string, gen uint64) bool {
o.mu.Lock()
defer o.mu.Unlock()
st := o.states[sessionID]
return st != nil && st.gen == gen
}
// persistTurnReply 是本回合助手回复的**唯一落库入口**(completeTurn 的收尾第一
// 步)。两条分支:
//
// - 有真实文本:逐字落库,行为与加入本保证之前完全一致(被取消/陈旧代次的
// 回合也照旧落 —— 内容是真的,与「不静默」无关);
// - 没有可见文本(空回复 / 轮次用尽 / 内部报错 / 只剩安全网的内部文案):只有
// 「归本代次且不是被取消」才补一条诚实说明;其余情形返回原样、什么都不写。
//
// 补过文案时它就地改写 res.Text 并把它交还调用方,因此**续跑尾部**(st.tail)也
// 带着这条说明 —— 队列里的下一条消息不会看到一个「什么都没说」的上下文。res 为
// nil(回合报错)时它新建一个只带文案的 Result,调用方的 nil 语义不变(异步路径
// 丢弃返回值,同步路径在此之前已按错误返回)。
func (o *Orchestrator) persistTurnReply(sessionID string, gen uint64, res *loop.Result, runErr error, deviceID, mode, personaID string) *loop.Result {
if o.history == nil {
return res
}
text := turnReplyText(res)
if res != nil && res.Text == text {
// 真实文本:逐字落库,行为与加入本保证之前完全一致。
o.persistAssistantReply(sessionID, res, deviceID, mode, personaID)
return res
}
if errors.Is(runErr, context.Canceled) || !o.turnIsCurrent(sessionID, gen) {
return res
}
if res == nil {
res = &loop.Result{}
}
res.Text = text
o.persistAssistantReply(sessionID, res, deviceID, mode, personaID)
return res
}