Files
Cyrene_v2/internal/services/gateway/ws_bridge.go
T
AskaEth 215eedf8e9 feat(ws): tool_execution.end 带可选 resultSummary(Y1,答应安卓端的那条)
- 形状:data.resultSummary(可选)。与回看面 toolCalls[].resultSummary 代码级同源:同一个常量
  (500 rune)与同一个截断函数,摘要由 **loop 侧**生成(trace.RedactText + renderToolResultSummary)
  再随 StreamEvent 下发,gateway 只搬运不加工(与回看面同口径)
- 缺席语义:结果真拿到才带;失败/取消/超时/未落图 ⇒ 整键缺席(绝不用空串顶位)
- 线上帧证据:真 WS + 真 JWT + 真 loop ⇒ 客户端收到 resultSummary;同源钉用真图快照反读逐字比对
- 3 个变异(空摘要也写键/去掉脱敏/失败当结果)
⚠️ 待裁:失败路径现按派工书「整键缺席」,但回看面对失败给的是 error 摘要 ⇒ 实时无结果区、事后有;
   若要实时也显示失败原因,一行可改(toolEndFrameSummary 的 return "")
2026-10-06 21:48:48 +08:00

258 lines
12 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/events"
"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,
// 渠道事件实时帧(design/26 §2.6 / §4.5;切片 E7):账号状态迁移与(经
// PublishingStore 装饰的)其余渠道事件都发在同一个领域主题上,在这里统一落成
// 帧类型。载荷即 `events.ChannelEventFrame`(`{"event":{…}}`),与 REST 列表的
// `events[]` 同形 —— 面板不需要第二套解码。
events.SubjectChannelEvent: wsChannelEvent,
}
// 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,
// 渠道事件实时面(切片 E7):精确主题,不用 channel.> —— 不替将来可能的
// 其他 channel.* 生产者顺手开口子(多订阅一个主题就多一次解码尝试)。
events.SubjectChannelEvent,
}
// 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 局部输出。end 帧在**结果真的拿到**时带
// resultSummary(同一份已脱敏文本、同一份上限预算,见 frameResultSummary)。
// 键在空值时省略。
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
}
if s := frameResultSummary(ev); s != "" {
data["resultSummary"] = s
}
return data
}
// frameResultSummary 取 tool_execution.end 帧上的结果摘要(契约 v0.82)。它只认
// end 帧,且只在 loop 真的填了摘要时给值 —— loop 侧
// (`internal/runtime/loop/tool_frame_summary.go`)已走**图侧同一条脱敏路径**
// (放行表 + 凭据形状清洗,即 `trace.RedactText`),因此这里与回看面同一口径:
// **只截断、不做二次脱敏**(二次脱敏会掩盖「写侧漏了」这个真问题),也绝不回上游
// 原文 —— 帧上根本没有原文可回,摘要就是 loop 给的这一份。
//
// 上限与回看面 `toolCalls[].resultSummary` **共用同一个常量与同一个截断函数**
// (`maxToolCallSummaryRunes` / `truncateToolSummary`,见 history_toolcalls.go):
// 「同一份预算」是代码级复用而不是口头约定。它在这里是**独立兜底** —— 正常路径上
// 图侧 RedactText 已把长度收到 ≤80 rune,只有写入侧放宽或出现绕过它的生产者时
// 这道上限才生效(同 history_toolcalls.go 对 argsSummary 的理由)。
//
// 🔴 缺席语义:空串 ⇒ 该键**整键缺席**,绝不用空串顶位 —— 安卓端按「键在场 ⇒ 渲染
// 结果区,缺席 ⇒ 只显示工具名与状态」编程,一个空的 resultSummary 会让它渲染出
// 一个空的结果区。
func frameResultSummary(ev model.StreamEvent) string {
if ev.Kind != model.StreamKindToolExecutionEnd {
return ""
}
return truncateToolSummary(ev.ResultSummary)
}
// 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"
}