- REST(同步 + stream:true)与 WS 两条入口同形解析 documents:只收服务端存储名(形态 + 真实存在 + 嗅探 MIME + 本地 URL + 大小),客户端谎报 mime/url/size 一律被服务端覆盖 ⇒ 不重开 SSRF 面(有「真实可命中 URL,命中次数 0」的用例钉住)。 - 拒绝口径对齐 S1:超过 5 条 → 400 too_many_documents(且先于任何 FS 访问);形态非法/穿越/不存在 → 404 document_not_found。 - 挂载:一回合一条 RoleUser 消息,文档部件追加其后(纯附件回合不塞空文本块,空文本会被 provider 拒)。 - 修 S2 移交的两处静默吞掉:session_transcript 用 history.DecodeDocument 把 h.Document 还原为部件(坏列带来源 WARN 不静默、也不卡会话);session_tail 的 messagesEmpty 计入 ContentTypeDocument(纯附件不再被判空回合)。 - 拆文件/拆函数(AGENTS:文件≤500 行、函数≤80 行):new chat_documents.go(rest.go 原 499/500 行塞不下)、handleChat 尾段 54 行原样搬成 submitChatTurn(handleChat 90→40 行),ws_hub 增 documents 依赖(与既有 envResolver 同接法)。 - 主会话独立验收(隔离 worktree = 当前 HEAD + 仅 B 的文件 ⇒ 正是 A+B 合流态):build/vet 绿、全树 59 包 0 FAIL、13 条新用例 PASS、linux/darwin 交叉编译绿;变异独立复现(摘掉条数上限判定 ⇒ 红)。 - 主会话补的测试钉子:TestMaxChatDocumentsMatchesDesign —— 原条数用例是用常量自身算输入条数的,改常量它照样绿(实测 5→1000 全包仍绿);补钉后改 10 时新钉子红、旧用例仍绿。
250 lines
12 KiB
Go
250 lines
12 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})
|
||
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 := newTurnID()
|
||
// 把轮次身份串进运行上下文,使 loop 把它盖在它发出的每个流式
|
||
// 事件上(message.*/thinking.*/tool_call.*);hub 随后把它放到
|
||
// 下行帧上。每一轮都取全新的 id —— 一个会话最多一个在途轮次
|
||
// —— 因此客户端能把一串增量关联到同一个助手轮次
|
||
// (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"`
|
||
}
|
||
|
||
// 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 时是空操作(既有行为,本次不改)。
|
||
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
|
||
}
|
||
c.h.orchestrator.InterruptScope(req.SessionID, scope)
|
||
}
|
||
|
||
// 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"`
|
||
}
|
||
|
||
// 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")
|
||
}
|