package gateway import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "strings" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // relay 是 ws.> 模式与各生产者桥接模式的 bus 处理器。ws.chat 主题是 // 流式触发器(跑 loop 并发 chunk);ws.* 主题成为其设计类型对应的 // 下行事件;被桥接的生产者主题(device_update、任务 output/done、 // automation result、reminder、subagent progress)被规范化为匹配的 // ws.* 事件类型。没有 WS 可见映射的事件会被丢弃,使内部 agent 间 // 流量永不泄露给客户端。 func (h *hub) relay(ctx context.Context, e bus.Event) error { if e.Subject == wsChatSubject { go h.streamChat(ctx, e.Payload) return nil } if e.Subject == wsStreamSubject { // OnStream→bus→relay 路径:loop 已经发布了结构化的 // model.StreamEvent;把它映射为带类型的下行事件。 h.broadcastStream(e.Payload) return nil } ev, ok := normalizeEvent(e) if !ok { return nil } h.broadcast(ev) return nil } // streamChat 是 ws.chat 触发器的入口。它解码请求,并在接了 // orchestrator 时把该轮路由经会话 orchestrator(design/15 的非阻塞 // busy/pending/resume 路径);否则回退到旧的直接运行方式,使未接 // orchestrator 的 hub 保持完全相同的 stream_chunk/stream_end 契约。 // 它跑在各自的 goroutine 上,因此慢的模型调用不会阻塞 bus 订阅 // worker。 // // 它把编排器对本次提交的判定**交回调用方**(协同 G2):REST 的 stream:true // 路径与 WS 帧共用这一段,前者据此回明确的 2xx/非 2xx/202/409,而不是在问 // 编排器之前就先回 200 —— 那样客户端可能拿到 200 却什么都没发生。异步调用点 // (`go h.streamChat(...)`,WS 帧与 bus 中继)照常丢弃返回值。 func (h *hub) streamChat(ctx context.Context, payload any) SubmitResult { var req chatStreamRequest if b, err := json.Marshal(payload); err == nil { _ = json.Unmarshal(b, &req) } emptyTurn := strings.TrimSpace(req.Text) == "" && strings.TrimSpace(req.Image) == "" && len(req.Documents) == 0 if h.runner == nil || emptyTurn { h.broadcast(wsEvent{Type: "stream_end", TurnID: newTurnID(), Seq: int(h.streamSeq.Add(1)), SessionID: req.SessionID, Data: map[string]any{"stopReason": "idle"}}) return SubmitResult{} } if req.SessionID == "" { req.SessionID = "web" } // 文档引用在本进程内解析(只认服务端存储名 + 确实存在),失败**可见**: // 回一条机器可读的 error 帧并中止本回合,绝不静默丢掉附件(design/24 §4.4)。 docs, derr := h.resolveDocuments(req.Documents) if derr != nil { logger.WithSource(h.log, documentsLogSource).Warn("chat documents rejected", "session", req.SessionID, "count", len(req.Documents), "err", derr) h.broadcast(wsEvent{Type: "error", SessionID: req.SessionID, Data: map[string]any{"code": chatDocumentErrorCode(derr)}}) return SubmitResult{} } // Web 主会话是 owner 的私有对话(JWT = owner),因此它绑定全权限 // env:记忆读写,行为不变。角色来自会话的绑定(design/22 §4.5): // 切到角色 Y 的会话在下一轮以 Y 应答,而未绑定的/既有的会话继续 // 解析为全局默认角色。 msgs := chatTurnMessages(req.Text, req.Image, docs) // 通过 ctx 把客户端的 deviceID、行为模式与可选幂等键串进 // orchestrator,使持久化的用户轮次与助手回复都带上标记,并让重复到达的 // clientMsgId 被判重(design/16 §4.5、§5.1;协同 G4)。空 clientMsgId 是 // 空操作,因此老客户端的行为逐字不变。 ctx = withClientMsgHint(withMode(withDeviceID(ctx, req.DeviceID), req.Mode), clientMsgHint{ClientMsgID: req.ClientMsgID}) // 可选的并行声明走同一条 ctx 通道(design/15 §2.5):false 是空操作, // 省略 `parallel` 的老客户端与加入该字段之前逐字相同。 ctx = withParallelTurn(ctx, req.Parallel) env := h.envResolver.owner(ctx, req.SessionID) if h.orchestrator != nil { return h.orchestrator.Submit(ctx, req.SessionID, msgs, env) } _, _ = h.runTurn(ctx, req.SessionID, msgs, env) return SubmitResult{Accepted: true, Immediate: true} } // runTurn 执行一个 agent 轮次并广播其输出流。它是 orchestrator(经其 // 注入的 TurnRunner)与旧回退路径共用的单元,因此无论由谁驱动,一轮 // 总是发出相同的 stream_chunk/stream_end 事件。当 loop 处于流式 // (接了 OnStream)时,结构化增量已经通过 OnStream→bus→relay 路径以 // message.*/thinking.*/tool_call.* 事件扇出,因此只有非流式路径才发出 // 一整段文本 chunk 以维持流契约。 func (h *hub) runTurn(ctx context.Context, sessionID string, msgs []model.Message, env loop.SessionEnv) (*loop.Result, error) { // 该路的 turnId 优先取 ctx 上已有的那个(design/15 §2.5.9 的 ⚠️ 必改项): // 编排器在**启动该路之前**就生成了它,并把同一份 id 交给 runTurn —— 因为它 // 发出 `turn_begin` 帧时必须报出这个 id,而客户端按 turnId // 两级分桶。若这里再自己生成一个,两处 id 不同,分桶会立即失配。 // // 未挂载(子代理 / 后台思考 / 未接编排器的旧回退路径)时才新铸一个,因此 // 单路路径的帧上仍是一个**每回合全新的随机 id** ⇒ 对客户端无可见差异。 turnID := loop.TurnIDFrom(ctx) if turnID == "" { turnID = newTurnID() } // 把轮次身份串进运行上下文,使 loop 把它盖在它发出的每个流式 // 事件上(message.*/thinking.*/tool_call.*);hub 随后把它放到 // 下行帧上,客户端因此能把一串增量关联到同一个助手轮次 // (design/16 §4.2)。 ctx = loop.WithTurnID(ctx, turnID) if h.runner == nil || len(msgs) == 0 { h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"stopReason": "idle"}}) return &loop.Result{StopReason: "idle"}, nil } res, err := h.runner.Run(loop.WithSessionEnv(ctx, env), sessionID, msgs) if err != nil { h.log.Error("chat run", "err", err, "session", sessionID) h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"stopReason": "error", "error": err.Error()}}) return nil, err } if !h.streaming { h.broadcast(wsEvent{Type: "stream_chunk", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"delta": res.Text}}) } h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"stopReason": res.StopReason, "steps": res.Steps}}) return res, nil } // wsBadRequestCode 是上行帧载荷非法时定向回发送者的机器可读错误码。它与 // ws_route.go 的 wsForbiddenCode 同形({type:"error", data:{code}}), // 因此客户端只看 data.code 就能把「你没有这个权限」与「这个帧写错了」 // 区分开。 const wsBadRequestCode = "bad_request" // interruptRequest 是解码后的 "interrupt" 上行载荷(design/15 §3)。 type interruptRequest struct { SessionID string `json:"sessionId"` // Scope 选择这次打断清到哪一层(协同项 A2,取值见 InterruptScope): // 省略 / 空串 / "all" = 现状语义(取消在途回合并清空待处理队列), // "current" = 只取消在途回合、保留待处理队列让它们照常续跑。 // 其余取值一律显式拒绝 —— 客户端以为队列保住了、实际被清空, // 就是静默丢消息,正是 A2 要修掉的那类缺陷。 Scope string `json:"scope,omitempty"` // TurnID 是「只停这一路」的目标(忙时并行规范草案 §2.3):**只在 // scope:"current" 时生效**;省略 scope 或 scope:"all" 时按契约**忽略**它 // ——那两个取值的语义本就是「全部在途路 + 清 pending」。 // // 指向不存在的路(已被收尾 / 已被取消 / id 写错)⇒ 定向回 // `{type:"error",data:{code:"bad_request"}}`,**绝不**静默降级成「停全部」: // 那会把用户「只停这一路」的意图执行成「停掉所有路」。 TurnID string `json:"turnId,omitempty"` } // parseInterruptScope 把上行 scope 解析为取值域内的口径。省略、空串与 // "all" 都落到现状语义(省略 = 什么都不改,因此老客户端只发 // {sessionId} 的行为逐字不变);"current" 是新增的保留队列口径;其余 // 取值返回错误,由调用方定向回 bad_request,绝不静默当默认执行。 // // 取值大小写敏感(与契约里的枚举 id 同口径),只容忍首尾空白。 func parseInterruptScope(raw string) (InterruptScope, error) { switch strings.TrimSpace(raw) { case "", string(InterruptScopeAll): return InterruptScopeAll, nil case string(InterruptScopeCurrent): return InterruptScopeCurrent, nil default: return "", fmt.Errorf("interrupt: 未知 scope %q(合法值:%q / %q)", raw, InterruptScopeAll, InterruptScopeCurrent) } } // handleInterrupt 处理客户端的 "interrupt" 帧(design/15 §3 + 协同项 A2): // 按 scope 让 orchestrator 取消该会话的在途回合,必要时保留待处理队列。 // 未接 orchestrator 时是空操作。 // // 它是**连接级**的,因为非法 scope 必须定向回发送者一条 // {type:"error", data:{code:"bad_request"}}:只到发送者、不广播、也不打断 // 任何会话(显式报错,绝不静默按默认执行)。该帧只由 owner/admin 连接 // 分发到此(design/11 §7.2 B)。 // // 缺 sessionId 时是空操作(既有行为,本次不改)。 // // 目标化打断(忙时并行规范草案 §2.3):`scope:"current"` + `turnId` ⇒ 只停那 // 一路(InterruptTurn)。其它 scope 下 turnId 按契约**忽略**(它们的语义本就是 // 「全部在途路 + 清队列」),这里只留一条 Debug 让「发了但没生效」可被追溯。 func (c *wsConn) handleInterrupt(payload json.RawMessage) { if c.h == nil { return } var req interruptRequest _ = json.Unmarshal(payload, &req) scope, err := parseInterruptScope(req.Scope) if err != nil { c.h.log.Debug("ws interrupt rejected", "err", err, "scope", req.Scope) c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"code": wsBadRequestCode}}) return } if c.h.orchestrator == nil || req.SessionID == "" { return } if req.TurnID != "" && scope != InterruptScopeCurrent { c.h.log.Debug("ws interrupt turnId ignored", "session", req.SessionID, "scope", scope) } if req.TurnID != "" && scope == InterruptScopeCurrent { c.interruptOneTurn(req.SessionID, req.TurnID) return } c.h.orchestrator.InterruptScope(req.SessionID, scope) } // interruptOneTurn 执行一次「只停这一路」并如实回报结果:目标不存在时定向回 // 发送者一条 {type:"error", data:{code:"bad_request"}}(只到发送者、不广播、也 // 不动任何会话),**绝不**静默降级成「停掉全部在途路」。 func (c *wsConn) interruptOneTurn(sessionID, turnID string) { if c.h.orchestrator.InterruptTurn(sessionID, turnID) { return } c.h.log.Debug("ws interrupt turn not found", "session", sessionID, "turn", turnID) c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"code": wsBadRequestCode}}) } // broadcastStream 把流式的 model.StreamEvent 映射为其下行 WS 事件类型, // 并经 hub 的帧门扇出(design/11 §7.1):每一个结构化流帧都携带模型 // 输出,因此只到达 owner + admin。它跑在 bus 订阅 worker 上;seq 原子 // 分配,因此即便 loop 从多个 goroutine 发出事件,一串帧仍保持有序。 // SessionID/TurnID 随事件本身携带(loop 在轮次边界从运行上下文盖上 // 它们),因此帧带有客户端把它路由到活跃会话以及关联到某个助手 // 轮次所需的范围。 func (h *hub) broadcastStream(payload any) { ev, ok := payload.(model.StreamEvent) if !ok { return } typ, data := streamEventData(ev) h.broadcast(wsEvent{Type: typ, SessionID: ev.SessionID, TurnID: ev.TurnID, Seq: int(h.streamSeq.Add(1)), Data: data}) } // chatStreamRequest 是解码后的 ws.chat 载荷。 type chatStreamRequest struct { SessionID string `json:"sessionId"` Text string `json:"text"` Image string `json:"image,omitempty"` // DeviceID 是客户端生成的发送浏览器/设备标识(design/16 §4.5), // 例如 "web:chrome:a1b2c3";它可选,并随消息一同持久化,使 agent // 知道主人是从哪一端发消息。 DeviceID string `json:"deviceId,omitempty"` // Mode 是客户端发送该轮时所用的行为模式(design/01 §3、 // design/16 §5.1);它可选,按消息持久化,缺省时回退为 "chat"。 Mode string `json:"mode,omitempty"` // ClientMsgID 是客户端自带的幂等键(协同 G4):同一会话内同一个非空 // clientMsgId 只承认第一条,重复到达不再落库、不再起回合,而是回一条 // `chat_duplicate`(data 带既有 messageId)。它可选,省略时行为与加入该 // 字段之前逐字相同。 ClientMsgID string `json:"clientMsgId,omitempty"` // Documents 是本回合附带的文档引用(design/24 §6.1),与 REST 的 // `chatRequest.documents` **同形**:只给服务端存储名,绝不接受 URL。 // 上行帧仍有 4096 字节硬上限 ⇒ 文档实际上走 REST;这里保持同形解析, // 使两条入口不会分叉(解析见 resolveDocuments)。 Documents []chatDocumentRef `json:"documents,omitempty"` // Parallel 是「把本条作为**独立一路**处理」的显式声明(design/15 §2.5, // 主人 2026-10-05 裁决 B):它可选,只有会话正忙且并发额度未满时才被照准 // (额度满 ⇒ 落回排队并回 `pending_ack{reason:"parallel_limit"}`)。 // 省略它时行为与加入该字段之前逐字相同 —— 服务端**不做**启发式自动升级, // 触发权在客户端。 Parallel bool `json:"parallel,omitempty"` } // resolveDocuments 解析本回合携带的文档引用(design/24 §4.4/§6.1)。一条都不带 // 是空操作;未接解析器(未接线的构建 / 单元测试构造的裸 hub)时**明确报错**, // 绝不把附件静默丢掉。 func (h *hub) resolveDocuments(refs []chatDocumentRef) ([]model.Document, error) { if len(refs) == 0 { return nil, nil } if h.documents == nil { return nil, documentsUnavailableError() } return h.documents(refs) } // newTurnID 返回新的随机十六进制标识,用于把一串流式 WS 事件关联到 // 同一个助手轮次。它消耗 crypto/rand 源,源不可用时回退到纳秒 // 时间戳,因此总能产出 id(空的 turnId 会静默破坏客户端关联)。 func newTurnID() string { var b [8]byte if _, err := rand.Read(b[:]); err == nil { return "turn-" + hex.EncodeToString(b[:]) } return "turn-" + time.Now().UTC().Format("20060102150405.000000000") }