裁定(已发安卓):**能修**,走**写入侧规整** —— 不指望模型输出、不动客户端。 - 新增 internal/runtime/turnreply/paragraph.go 的 NormalizeParagraphs: 把未被 <p> 包裹的段落补上 <p>(按空行分段;<action>/<narration> 段也包进 <p>, 与正确形态 <p><action>…</action>…</p> 一致) - **幂等**守门断言:已完全包裹的文本逐字不变(防二次加工) - 落点覆盖 Web 与 IM 两条落库路径(gateway/session_turn_reply.go、channelbridge/turnreply.go、 proactive.go)⇒ 两边共用同一份规整文本 ⇒ **历史一次性变好、两个客户端零改动**、渲染口径不分叉 (测试与变异详见切片报告)
106 lines
6.3 KiB
Go
106 lines
6.3 KiB
Go
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 取消的回合:用户意图就是「停下」,此刻补一条「我出问题了」
|
||
// 反而违背那条指令;
|
||
// - 已经失效的路(被 interrupt 取消后又被清空、或会话从未登记过它):那次收尾
|
||
// 不属于当前会话状态,补消息会落到别人的回合里;
|
||
// - 静默内部流程(backthink、子代理):它们各自驱动 loop,从不经过本编排器,
|
||
// 因而天然不受这条保证影响(它们的产出只进内部状态,不进聊天历史)。
|
||
|
||
// 「空回复 / 轮次用尽 / 安全网终止 / 内部报错该说什么」由共享包
|
||
// internal/runtime/turnreply 统一给出(IM 渠道桥走另一条编排路径,两边必须是
|
||
// 同一份诚实说明;各写一套迟早漂移)。本文件只负责判定**这次收尾该不该落库**。
|
||
|
||
// turnIsCurrent 报告该路是否仍是该会话的**在途路**。已失效的路(被打断、被
|
||
// interrupt-all 清空、或会话从未登记过它)意味着这次收尾属于**一条已经不存在的
|
||
// 回合**,不该再往历史里写。
|
||
//
|
||
// 判据从旧的「世代号等于会话当前代次」改成「这条路的世代号还在 st.legs 里」:
|
||
// 真并行下启动第二路会给它分配一个新世代号,但**绝不会**让第一路失效
|
||
// (design/15 §2.5.9 —— 这正是旧的会话级单例代次做不到的那件事)。
|
||
func (o *Orchestrator) turnIsCurrent(sessionID string, gen uint64) bool {
|
||
o.mu.Lock()
|
||
defer o.mu.Unlock()
|
||
return o.legLocked(o.states[sessionID], gen) != nil
|
||
}
|
||
|
||
// persistTurnReply 是本回合助手回复的**唯一落库入口**(completeTurn 的收尾第一
|
||
// 步)。两条分支:
|
||
//
|
||
// - 有真实文本:逐字落库(**唯一例外**是幂等的段落包裹规整,见
|
||
// turnreply.NormalizeParagraphs:只给未被 `<p>` 包裹的段落补上包裹,已规范的
|
||
// 文本一字不改;且**只在 chat 模式**做,见正文注释),行为与加入本保证之前完全
|
||
// 一致(被取消/陈旧代次的回合也照旧落 —— 内容是真的,与「不静默」无关);
|
||
// - 没有可见文本(空回复 / 轮次用尽 / 内部报错 / 只剩安全网的内部文案):只有
|
||
// 「归本代次且不是被取消」才补一条诚实说明;其余情形返回原样、什么都不写。
|
||
//
|
||
// 补过文案时它就地改写 res.Text 并把它交还调用方,因此**续跑尾部**(st.tail)也
|
||
// 带着这条说明 —— 队列里的下一条消息不会看到一个「什么都没说」的上下文。res 为
|
||
// nil(回合报错)时它新建一个只带文案的 Result,调用方的 nil 语义不变(异步路径
|
||
// 丢弃返回值,同步路径在此之前已按错误返回)。
|
||
//
|
||
// turnID 是本路的权威回合标识(design/15 §2.5.9),由调用方**透传**下来而不是在这里
|
||
// 按世代号回查:InterruptScope(all) 会把 st.legs 整个清掉,而被打断那一路的助手文本
|
||
// 仍要照原样落库(内容是真的),那时回查只能拿到空串、把这一行的配对丢掉。调用方
|
||
// 手里就有 `*turnLeg`,它的 turnID 出生后不可变,因此透传是唯一不会漏标的取法。
|
||
// 空串等价于加入该列之前的行为(列里留空)。
|
||
func (o *Orchestrator) persistTurnReply(sessionID string, gen uint64, res *loop.Result, runErr error, deviceID, mode, personaID, turnID string) *loop.Result {
|
||
if o.history == nil {
|
||
return res
|
||
}
|
||
text := turnreply.Text(res)
|
||
if res != nil && res.Text == text {
|
||
// 真实文本:除段落包裹规整外逐字落库。规整是**幂等的写入侧加法**(只给
|
||
// 未被 <p> 包裹的段落补上包裹,已规范的文本一字不改),它就地改写
|
||
// res.Text,因此落库内容、WS `message_persisted`、REST reply、续跑尾部
|
||
// 与语音拿到的是**同一份**文本。客户端的解析规则是「只要存在完整 <p> 块
|
||
// 就不再按空行回退切分」,半包的回复恰好在那一侧整条塌成一段;同一条
|
||
// 判据在 IM 侧(splitBubbles)也成立,两边必须吃同一份文本。
|
||
//
|
||
// **只在 chat 模式规整**:拆气泡这条判据只属于 chat 气泡;其余 mode
|
||
// (code/home/work/docs…)的消息在客户端是**按原样整体渲染**的
|
||
// (design/07 §2),往里塞 <p> 只会在那些形态里露出野生标签。IM 永远是
|
||
// chat(channelbridge 的 modeChat),因此这条闸门不会让 Web 与 IM 分叉。
|
||
if modeOrDefault(mode) == modeChat {
|
||
res.Text = turnreply.NormalizeParagraphs(res.Text)
|
||
}
|
||
o.persistAssistantReply(sessionID, res, deviceID, mode, personaID, turnID)
|
||
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, turnID)
|
||
return res
|
||
}
|