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 maps a producer bus subject to its outbound WS event type. The hub // subscribes to these (and re-normalizes into ws.* types) so the frontend's // existing consumers light up without the producers changing their subjects. 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 are the additional bus patterns the hub subscribes to on top of // ws.> to capture producer events and normalize them for WS delivery. var bridgePatterns = []string{ "iot.>", "task.>", "automation.>", "reminder.>", "cyrene.agent.*.event", } // normalizeEvent maps a bus event to an outbound wsEvent. A ws.* subject keeps // its design type (the ws. prefix is stripped); a known producer subject is // bridged to its ws.* type; a sub-agent progress event is shaped into // ws.subagent_event. It returns ok=false for events that have no WS-visible // representation (e.g. internal cyrene.agent ask/reply traffic). 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 maps an orchestration progress event — broadcast on // cyrene.agent..event via comm.PublishEvent — into a ws.subagent_event // frame. Only the five sub-agent progress names are surfaced; any other event // broadcast on the same subject (ask/reply/addressed events, or foreign event // names) is ignored so internal agent-to-agent traffic stays internal. 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 maps a streamed model.StreamKind to its downstream WS event // type and builds a typed data payload. It is what turns the loop's structured // events into the message.*/thinking.*/tool_call.*/tool_execution.* frames the // frontend renders. Unknown kinds fall back to the legacy stream_chunk shape so // an unrecognized event still reaches the client instead of being dropped. func streamEventData(ev model.StreamEvent) (string, map[string]any) { switch ev.Kind { case model.StreamKindTextStart: return "message.start", map[string]any{"index": ev.Index} case model.StreamKindTextDelta: return "message.delta", map[string]any{"delta": ev.Delta, "index": ev.Index} case model.StreamKindTextEnd: return "message.end", map[string]any{"index": ev.Index} case model.StreamKindThinkingStart: return "thinking.start", map[string]any{"index": ev.Index} case model.StreamKindThinkingDelta: return "thinking.delta", map[string]any{"delta": ev.Delta, "index": ev.Index} case model.StreamKindThinkingEnd: return "thinking.end", map[string]any{"index": ev.Index} case model.StreamKindToolCallStart: return "tool_call.start", map[string]any{"id": ev.ID, "index": ev.Index} case model.StreamKindToolCallDelta: return "tool_call.delta", map[string]any{"id": ev.ID, "index": ev.Index, "delta": ev.Delta} case model.StreamKindToolCallEnd: return "tool_call.end", map[string]any{"id": ev.ID, "index": ev.Index} case model.StreamKindToolExecution: return "tool_execution.update", map[string]any{"id": ev.ID, "index": ev.Index, "delta": ev.Delta} default: return "stream_chunk", map[string]any{"delta": ev.Delta} } } // isBroadcastAgentEvent reports whether subject has the exact 4-token broadcast // shape cyrene.agent..event, which is how the scheduler publishes progress. // It deliberately does not match addressed events (cyrene.agent...event) // or the ask/reply subjects. 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" }