Files
Cyrene_v2/internal/services/gateway/ws_bridge.go
T
AskaEth 3d8822c922
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(loop,gateway): 13.4.5 目标态 —— 回合内 steer(不打断、新消息进同一上下文流)+ steer_ack 帧
规格=design/15 末尾「13.4.5 目标态规格」(不变量 I1/I2/I3)。调研依据:豆包无公开技术文档(不编造);
同类成熟 Agent 产品无一做同会话并行回合,共同范式= queue + 回合内 steer(Claude Code / Cline PR #13958 /
Codex Queue·Steer / open-webui / LangGraph)。

- 注入点=**回合内边界**(上一模型步 + 它那批工具调用都已结束、下一次模型调用还没开始):
  driveTurn 每 step 组装前调用一次 SteerSource(run.go)——完成 step 只能靠工具批次推进,
  故唯一可注入处就是「工具批次结束之后」,循环里没有任何「起第二个回合」的分支(**I1 结构性成立**)
- **I2 恰好一次**:注入只进**本轮 view、绝不写回 msgs**(checkpoint 与记忆提取面保持干净);
  被注入的消息从 pending 移除,不会由自己的回合再答一次
- **I3 转向不取消工具**:注入调用点在 runToolCalls **之后**(此刻无工具在飞),循环从不因注入 cancel 任何 ctx;
  边界序列有测试钉死(恰为 [1 2 …],无批次中途值)
- 判定口径= **pending ∩ 转录**:候选是本会话 pending 条目,每个边界重读 history.Recent 验证已落库,
  按**行 id** 去重、按 pending FIFO 注入。**不用时间戳**(Windows time.Now() 步长 ~0.44ms,连发常同毫秒并列,
  任何 ms 基线都会漏注入或重复注入);跨重启不成立也不需要(在飞回合与 pending 都在内存)
- 新下行帧 `steer_ack`:`{type, sessionId, data:{messageId, atStep}}`,语义=「已被本回合吃进、尚未被正面回答」
  (驱动前端灰态);**不进 frameReachesAll** ⇒ fail-closed 只发 owner+admin(测试双钉);经 hub 广播、不经 bus
- 组合根**零改动**:per-turn 状态用 ctx 承载(loop.WithSteerSource),编排器在回合启动/续跑时挂
- 守卫:session_orchestrator.go 加完接线后 505 行 ⇒ 按职责纯搬出 session_events.go(常量取值未改、行为零变化)
- 复核:build/vet 绿 + **全树 0 FAIL** + linux/darwin 交叉编译绿;loop 6 例(含 3 子测)+ gateway 2 例 E2E;
  **4 条变异反证**(删 consumePending → 模型步数 4≠3;注入写回 msgs → 快照被污染;批次中途注入 → 边界 [1 1 2];
  把帧加进白名单 → viewer 收到)均已还原
- 未做(登记):IM 自有 Chatter 落库的消息不参与 steer(有意,避免重复作答);pending 条目无自落库 id
  ⇒ 配对用 (内容,图片) 逐行认领;同回合超 20 条用户消息时超出者留 pending 由各自回合作答(不丢)
2026-10-04 14:53:40 +08:00

217 lines
9.8 KiB
Go
Raw 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 (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/comm"
)
// wsSubagentResult 是后台子代理批次结算后的下行帧类型(design/05 §9「结果回填
// 主会话并提示用户」)。编排层在 orchestration.SubagentResultSubject 上发布批次
// 结果,本文件的中继把它成形为这一帧并按载荷里的父会话归属。它属**内容帧**,
// 因此走 design/11 §7.1 的 fail-closed 默认档:只发 owner + admin(不登记进那 7 个
// 状态机信号白名单)。
const wsSubagentResult = "subagent_result"
// wsSteerAck 是「忙时新消息已被本回合吃进」的下行帧类型(design/15 §5,裁决
// 13.4.5 目标态)。语义:该消息已在在飞回合的**下一个回合内边界**被注入同一回合、
// 并已从待处理队列移除,但**尚未被正面回答** —— 前端据此把它的插入气泡渲染成灰态
// (「已读、未答」,对齐 Claude Code 的 gray-until-responding);「正面回答」是随后
// 抵达的 message.* / turn.end 帧,不需要第二个确认帧。
//
// 帧壳:{type:"steer_ack", sessionId}。载荷 data:{messageId, atStep} —— messageId
// 是那条用户消息的权威 id(客户端据此把本地乐观气泡对齐到它,而不是靠文本相等这类
// 脆弱匹配),atStep 是吃进它的模型步序号(从 1 开始)。
//
// 它携带具体消息身份(客户端能定位到某一条气泡,而不只是会话忙闲),因此**不登记**
// 进 frameReachesAll 的「状态机信号」白名单,落 design/11 §7.1 的 fail-closed 默认
// 档:只发 owner + admin。
const wsSteerAck = "steer_ack"
// eventBridge 把生产者 bus 主题映射为其下行 WS 事件类型。hub
// 订阅这些(并重新归一化为 ws.* 类型),使前端既有的
// 消费方亮起来,而生产者无需改变其主题名。
var eventBridge = map[string]string{
"iot.device_update": "device_update",
"task.output": "task_output",
"task.done": "task_done",
"automation.result": "automation_result",
"reminder.trigger": "reminder",
orchestration.SubagentResultSubject: wsSubagentResult,
// 可观测面(裁决 05.4.3 + 16.4.3-WS):日志/指标/trace 的推送帧。载荷形状
// 与既有 REST 端点同形(见 observability_push.go),因此面板换成消费帧后
// 不需要第二套解码。
obsLogSubject: wsObservabilityLog,
obsTraceSubject: wsObservabilityTrace,
obsMetricsSubject: wsObservabilityMetrics,
}
// bridgePatterns 是 hub 在 ws.> 之外额外订阅的 bus 模式,用于
// 捕获生产者事件并把它们归一化以供 WS 投递。
var bridgePatterns = []string{
"iot.>",
"task.>",
"automation.>",
"reminder.>",
"cyrene.agent.*.event",
// 可观测推送(observability.log/trace/metrics):日志/指标/trace 的增量
// 帧都走这个主题族,见 observability_push.go。
"observability.>",
// 后台子代理批次结果:它既不匹配 ws.>,也不是 cyrene.agent 的 4 段广播形态,
// 所以必须在这里登记订阅,否则后台结果永远上不了前台(只能看到进度帧)。
orchestration.SubagentResultSubject,
}
// normalizeEvent 把 bus 事件映射为下行 wsEvent。ws.* 主题保持
// 其设计类型(ws. 前缀被剥掉);已知的生产者主题被
// 桥接为其 ws.* 类型;子代理进度事件被塑形为
// ws.subagent_event。对没有 WS 可见表示的事件返回 ok=false
// (例如内部的 cyrene.agent ask/reply 流量)。
func normalizeEvent(e bus.Event) (wsEvent, bool) {
// 后台批次结果先走专用成形:它要按载荷里的父会话把结果归属到该会话的实时流
// (帧壳 sessionId),这是 eventBridge 的原样透传做不到的。载荷形状不符预期时
// 退回下面的通用映射(两路都只发 owner + admin)。
if e.Subject == orchestration.SubagentResultSubject {
if ev, ok := subagentResultEvent(e.Payload); ok {
return ev, true
}
}
if typ, ok := eventBridge[e.Subject]; ok {
return wsEvent{Type: typ, Data: e.Payload}, true
}
if strings.HasPrefix(e.Subject, wsPrefix) {
return wsEvent{Type: eventType(e.Subject), Data: e.Payload}, true
}
if ev, ok := subagentProgressEvent(e); ok {
return ev, true
}
return wsEvent{}, false
}
// subagentResultEvent 把后台批次的结果成形为下行帧:帧壳 sessionId 取自载荷的
// 父会话,使结果落回**父会话**的实时消息流(design/05 §9);data 即载荷本身
// (批次状态/汇总 + 逐代理结果,与派发工具返回给模型的结果同形)。载荷不是
// *orchestration.SubagentResultEvent 时返回 ok=false,由调用方退回通用映射。
func subagentResultEvent(payload any) (wsEvent, bool) {
ev, ok := payload.(*orchestration.SubagentResultEvent)
if !ok || ev == nil {
return wsEvent{}, false
}
return wsEvent{Type: wsSubagentResult, SessionID: ev.SessionID, Data: ev}, true
}
// subagentProgressEvent 把编排进度事件 —— 经 comm.PublishEvent 广播在
// cyrene.agent.<from>.event 上 —— 映射为 ws.subagent_event
// 帧。只有五个子代理进度名会被浮现;同一主题上广播的
// 其他任何事件(ask/reply/addressed 事件,或外来事件
// 名)都被忽略,使内部 agent 间流量保持内部。
func subagentProgressEvent(e bus.Event) (wsEvent, bool) {
if !isBroadcastAgentEvent(e.Subject) {
return wsEvent{}, false
}
ev, ok := e.Payload.(comm.Event)
if !ok {
return wsEvent{}, false
}
switch ev.Name {
case orchestration.EventDispatched, orchestration.EventStarted,
orchestration.EventDone, orchestration.EventFailed, orchestration.EventAborted:
default:
return wsEvent{}, false
}
p, ok := ev.Data.(orchestration.Progress)
if !ok {
return wsEvent{}, false
}
return wsEvent{Type: "subagent_event", Data: map[string]any{
"id": p.AgentID,
"name": p.AgentID,
"status": string(p.Status),
"role": p.RoleID,
"mode": p.Mode,
}}, true
}
// streamEventData 把流式的 model.StreamKind 映射为其下游 WS 事件
// 类型并构建带类型的 data 载荷。它正是把 loop 的结构化
// 事件变成前端所渲染的 message.*/thinking.*/tool_call.*/tool_execution.*
// 与 turn.start/end 帧的东西。模型流帧(message.*/thinking.*/tool_call.*)
// 额外携带 streamId(同一模型流的多帧关联键,design/04 §1.1);
// tool_execution.* 与 turn.* 不属于某一次模型流,不带 streamId。
// 未知类型回退到旧的 stream_chunk 形状,使
// 未识别的事件仍能抵达客户端而不是被丢弃。
func streamEventData(ev model.StreamEvent) (string, map[string]any) {
switch ev.Kind {
case model.StreamKindTextStart:
return "message.start", withStreamID(map[string]any{"index": ev.Index}, ev)
case model.StreamKindTextDelta:
return "message.delta", withStreamID(map[string]any{"delta": ev.Delta, "index": ev.Index}, ev)
case model.StreamKindTextEnd:
return "message.end", withStreamID(map[string]any{"index": ev.Index}, ev)
case model.StreamKindThinkingStart:
return "thinking.start", withStreamID(map[string]any{"index": ev.Index}, ev)
case model.StreamKindThinkingDelta:
return "thinking.delta", withStreamID(map[string]any{"delta": ev.Delta, "index": ev.Index}, ev)
case model.StreamKindThinkingEnd:
return "thinking.end", withStreamID(map[string]any{"index": ev.Index}, ev)
case model.StreamKindToolCallStart:
return "tool_call.start", withStreamID(map[string]any{"id": ev.ID, "index": ev.Index}, ev)
case model.StreamKindToolCallDelta:
return "tool_call.delta", withStreamID(map[string]any{"id": ev.ID, "index": ev.Index, "delta": ev.Delta}, ev)
case model.StreamKindToolCallEnd:
return "tool_call.end", withStreamID(map[string]any{"id": ev.ID, "index": ev.Index}, ev)
case model.StreamKindToolExecutionStart:
return "tool_execution.start", toolExecutionData(ev)
case model.StreamKindToolExecutionUpdate:
return "tool_execution.update", toolExecutionData(ev)
case model.StreamKindToolExecutionEnd:
return "tool_execution.end", toolExecutionData(ev)
case model.StreamKindTurnStart:
return "turn.start", nil
case model.StreamKindTurnEnd:
return "turn.end", nil
default:
return "stream_chunk", map[string]any{"delta": ev.Delta}
}
}
// withStreamID 把事件携带的 streamId 并入模型流帧的 data(空值省略,
// 使未带流身份的事件保持旧形状)。它原地修改并返回 data,
// 仅供 streamEventData 的字面量使用。
func withStreamID(data map[string]any, ev model.StreamEvent) map[string]any {
if ev.StreamID != "" {
data["streamId"] = ev.StreamID
}
return data
}
// toolExecutionData 构建 tool_execution.* 帧的载荷:工具名与细粒度
// phase 为结构化字段(phase 绝不塞进 delta,design/04 §1.1 裁决
// 01.4.1);id 复用 provider 的 tool call id(可关联 tool_call.* 帧),
// update 帧可再携带 delta 局部输出。键在空值时省略。
func toolExecutionData(ev model.StreamEvent) map[string]any {
data := map[string]any{"tool": ev.Tool, "phase": ev.Phase}
if ev.ID != "" {
data["id"] = ev.ID
}
if ev.Kind == model.StreamKindToolExecutionUpdate && ev.Delta != "" {
data["delta"] = ev.Delta
}
return data
}
// isBroadcastAgentEvent 报告 subject 是否恰为 4 段广播形态
// cyrene.agent.<from>.event,调度器正是这样发布进度的。
// 它刻意不匹配寻址事件(cyrene.agent.<from>.<to>.event)
// 或 ask/reply 主题。
func isBroadcastAgentEvent(subject string) bool {
parts := strings.Split(subject, ".")
if len(parts) != 4 {
return false
}
return parts[0] == "cyrene" && parts[1] == "agent" && parts[3] == "event"
}