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, turnId}。载荷 data:{messageId, atStep} —— // messageId 是那条用户消息的权威 id(客户端据此把本地乐观气泡对齐到它,而不是靠 // 文本相等这类脆弱匹配),atStep 是吃进它的模型步序号(从 1 开始)。 // // 帧壳的 turnId 是**吃进它的那一路**的权威回合标识(design/15 §2.5.9 / // 忙时并行规范草案 §2.5):灰态要靠它与该路的 `turn.end` 配对才能收口,而真并行 // 下同一会话有两条交错的路 —— 少了它,客户端只能猜这条消息属于哪一路。 // // 它携带具体消息身份(客户端能定位到某一条气泡,而不只是会话忙闲),因此**不登记** // 进 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..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..event,调度器正是这样发布进度的。 // 它刻意不匹配寻址事件(cyrene.agent...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" }