新下行帧 turn_begin:编排器每启动一路恰好一帧(单路 kind:"turn" / 并行路 "parallel"),
data{kind, messageIds, leg, activeTurns};角色门 fail-closed 仅 owner+admin,未进 frameReachesAll。
steer_ack 增帧壳 turnId(与该路 turn_begin 同源)—— 并行下没有它配不上正确的 turn.end。
上行 interrupt.turnId(仅 scope:"current" 生效):精确停某一路;目标不存在 ⇒ 定向
{type:"error",data:{code:"bad_request"}},不广播、不动任何会话;省略 turnId = v0.65 原口径(全部在途路)。
帧序按实现:session_busy / pending_drain → message_persisted(user) → turn_begin
(规范草案 §2.9 的时序图需按实现更正:messageIds 必须是已落库的权威 id,故只能在落库之后)。
测试:编排器侧 + 真 WS/JWT 端到端;变异 5 条(每路一帧 / 帧壳 turnId / 目标化打断 / 取消筛选)。
Docs 待升 v0.66(由监工登记)。
298 lines
15 KiB
Go
298 lines
15 KiB
Go
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")
|
||
}
|