Files
AskaEth 5de708a560
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,channelbridge,turnreply): p 标签半包写入侧规整(修「整条塌成一段」)
裁定(已发安卓):**能修**,走**写入侧规整** —— 不指望模型输出、不动客户端。
- 新增 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)⇒ 两边共用同一份规整文本 ⇒ **历史一次性变好、两个客户端零改动**、渲染口径不分叉
(测试与变异详见切片报告)
2026-10-05 10:56:39 +08:00

106 lines
6.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}