package gateway import ( "context" "errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/turnreply" ) // 本文件归属「回合收尾必须留下可见回复」这条不静默铁律(切片 KB1)。 // // 缺陷形状(安卓侧实测 E2E-RETRY,纯文本回合):模型连续两次调用一个失败的 // `knowledge` 工具,随后给出一段**空回复**(只有思维链、没有正文)—— 循环按 // 「正常收尾」落定,Result.Text 为空,而助手回复的落库对空文本是静默空操作, // 于是历史里只剩那条用户消息:客户端两张「工具调用失败」卡片之后直接回空闲, // 既没有回复、也没有错误横幅。 // // 同类静默出口还有两个,全都在这里收敛: // - 工具循环耗尽轮次预算(loop 返回 StopReasonMaxRounds 且 Text 为空); // - 回合内部报错(Run 返回 nil Result + error:模型 / 工具 / 编排报错)。 // // 保证是:**归本代次、且不是被用户打断的回合,历史里一定有一条可见的 assistant // 消息** —— 没有真实文本时补一条按结束原因区分的诚实说明。落库仍走 // persistAssistantReply(同一条写入 + message_persisted 广播路径),因此历史接口、 // 跨端同步与客户端渲染都与真实回复完全一致,不需要任何新契约。 // // 三类**不补**的情形(边界,与既有语义不冲突): // - 被 interrupt 取消的回合:用户意图就是「停下」,此刻补一条「我出问题了」 // 反而违背那条指令; // - 被更新的回合取代的陈旧代次(st.gen 已前进):那次收尾不属于当前会话状态, // 补消息会落到别人的回合里; // - 静默内部流程(backthink、子代理):它们各自驱动 loop,从不经过本编排器, // 因而天然不受这条保证影响(它们的产出只进内部状态,不进聊天历史)。 // 「空回复 / 轮次用尽 / 安全网终止 / 内部报错该说什么」由共享包 // internal/runtime/turnreply 统一给出(IM 渠道桥走另一条编排路径,两边必须是 // 同一份诚实说明;各写一套迟早漂移)。本文件只负责判定**这次收尾该不该落库**。 // 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 := turnreply.Text(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 }