Files
Cyrene_v2/internal/services/gateway/ws_bridge.go
T
AskaEth 6e7ec56f9a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(loop,gateway): 事件批 — tool_execution 三事件 + streamId + turn.start/end(裁决 01.4.1/01.4.2/01.4.3/01.4.5/03.4.4/1.12)
执行方案阶段 2 · P2-3(`DevDocs/audit/_plan.md`)。契约 `Docs/api-client.md` → **v0.50**。

- **01.4.1 `tool_execution` 拆三事件**:`model/stream.go` 删旧 `StreamKindToolExecution`,新增
  `tool_execution.start/update/end` + `ToolPhaseRunning/Done/Error`(与 design/19 status 同值);
  `StreamEvent` 增 `Tool`/`Phase`;`loop/tools.go` 的 `emitToolExecution` 重写为结构化字段、
  **Delta 恒空**(断言锁死),`ID` 复用 provider tool call id(可与 `tool_call.*` 关联)。
  ⚠️ **诚实边界**:start/end 有真实生产者;`update` 的 wire 映射已实现并单测锁定,但**工具分发器
  无中途进度上报源 ⇒ loop 暂不产出 update 帧**(已在 api-client §3.1 如实登记)。
- **03.4.4 phase 驱动图节点状态**:`invokeOne` 用**单一 phase 决策**同时决定 end 帧 phase、
  span 的 `phase` prop 与终态;`trace/graph.go` 新增 `FlowStatusForPhase`,`graph_build.go` 的
  `AddSpan` 对 tool 节点**优先消费 phase prop**(缺省回落 span 终态 ⇒ 两路不可能矛盾)。
- **01.4.2 `streamId`**:`StreamEvent` 增 `StreamID`;`loop/stream.go` 每次 ChatStream 生成
  `stream-<hex>`(crypto/rand,降级时间戳),该流所有帧共享、多步工具轮取新 id;`ws_bridge`
  把它并进**模型流帧** `data`(`tool_execution.*`/`turn.*` 不带)。语义区分:`turnId` = 哪轮回答,
  `streamId` = 这条流式消息。
- **01.4.3 `turn.start`/`turn.end`**:`loop/run.go` 入口发 start、defer 发 end,**覆盖全部出口**
  (最终答案/安全网终止/预算耗尽/步骤错误);帧**无 data 载荷**;静音的内部 run(backthink/子代理)
  不发。顺带把 91 行的 `Run` 拆成 `driveTurn` + `settleAndReturn`(清掉一处既有 >80 行违规)。
- **01.4.5**:仅 `Docs/api-client.md` §3.5 补 `graph_update` 三范围(`web`/`web:<uuid>`/`sess-backthink`),
  与 `wire_runtime.go` 的 allow 谓词一致,**零代码变更**。

验证:`go build ./...` 绿;本批四包(loop/model/trace/gateway)vet + 测试绿(含 `-count=1`);
linux/amd64 + darwin/arm64 交叉编译绿;本批 16 文件 ≤500 行、函数 ≤80 行。
**真 WS 帧断言 8 条**:帧序 `turn.start → message.delta → tool_execution.start →
tool_execution.end → message.delta → turn.end`(+`stream_end`)、turn 边界无载荷且整轮 turnId 一致、
两条模型流 `streamId` 互异、tool 帧 `{tool,phase,id}` 且无 `delta`/`streamId` 键、
`turn.end` 在步骤出错时也发出、内部静音 run 不发边界帧。
**变异验证**:把 `ev.StreamID = streamID` 改为赋空 → 两条断言 FAIL(`streamId` 为空/两流同空),
还原后复绿(直接删行会先被编译期 `declared and not used` 拦截)。

未验证:前端未消费这些帧(本轮未动 `frontend/**`,「前端能渲染」待前端批);无真实模型端到端。
2026-10-04 11:26:35 +08:00

162 lines
6.3 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/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/comm"
)
// 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",
}
// bridgePatterns 是 hub 在 ws.> 之外额外订阅的 bus 模式,用于
// 捕获生产者事件并把它们归一化以供 WS 投递。
var bridgePatterns = []string{
"iot.>",
"task.>",
"automation.>",
"reminder.>",
"cyrene.agent.*.event",
}
// normalizeEvent 把 bus 事件映射为下行 wsEvent。ws.* 主题保持
// 其设计类型(ws. 前缀被剥掉);已知的生产者主题被
// 桥接为其 ws.* 类型;子代理进度事件被塑形为
// ws.subagent_event。对没有 WS 可见表示的事件返回 ok=false
// (例如内部的 cyrene.agent ask/reply 流量)。
func normalizeEvent(e bus.Event) (wsEvent, bool) {
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
}
// 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 局部输出。键在空值时省略。
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.<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"
}