feat(gateway): 地基③ 打通实时信号 producer→前端WS + loop 真流式 + 上行路由补全
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

打通前端 ws.ts 里那批死消费者事件(thinking/mood/tool_used/subagent_event/
task_output/device_update/reminder/backthink),并给 loop 接真 token 流式。

- wire.go:loop 建时传 OnStream(streamChunk 闭包把 delta 发 bus ws.stream_chunk);
  gateway.Deps 注入 Scheduler/Tasks/Streaming;iot 接共享总线 iot.WithBus(a.bus)。
- ws_bridge.go:hub 额外订阅 iot.>/task.>/automation.>/reminder.>/cyrene.agent.*.event,
  normalizeEvent 归一为 ws.*(device_update/task_output/task_done/automation_result/
  reminder/subagent_event);不订阅 cyrene.agent.> 以免 ask/reply 内部事件进 relay。
- ws_route.go:voice_input→ws.voice_input;subagent_ask→Scheduler.AuditContext(真实
  上下文审计,替换 pending stub);task_output_query→Manager.TaskOutput(真实输出,
  替换空 {})。
- server.go:新增 SubagentAuditor/TaskOutputStore 接口 + Deps.Scheduler/Tasks/Streaming。
- orchestration/auditcontext.go:Scheduler.AuditContext(full/tail/keyword)。
- task/manager.go:Manager.TaskOutput(id)。
- backthink:record() 补发 ws.backthink 事件。
- json tag 修正(device_update/reminder/task output 结构体加小写 tag + DeviceUpdate
  补 message),使前端分支能真正渲染。

实机验证:WS 握手 101、task manager ready、iot manager initialized(WithBus 生效)。
go build/vet/test 全绿;TestHubBridgeDeviceUpdate/TestHubBridgeSubagentProgress 覆盖
映射。dev 推至后续版本。
This commit is contained in:
Aska committed 2026-09-06 15:53:48 +08:00
1 parent 04bfa2c7a7
commit 9b207a31e8
14 files changed
+673 -109

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
package main
import "context"
// streamChunkSubject is the bus subject the loop's OnStream closure publishes
// each token delta to. The gateway hub subscribes to ws.> and re-broadcasts it
// as a ws.stream_chunk frame to every client.
const streamChunkSubject = "ws.stream_chunk"
// streamChunk is the loop's OnStream callback. It publishes one token delta to
// the gateway's streaming subject so the hub fans it out to every client as a
// ws.stream_chunk frame. It is a no-op when no bus is wired and never blocks the
// loop: bus.Publish is asynchronous and a full per-subscription buffer drops the
// event rather than stalling the model stream.
func (a *app) streamChunk(delta string) {
if a.bus == nil {
return
}
if err := a.bus.Publish(context.Background(), streamChunkSubject, map[string]any{"delta": delta}); err != nil {
a.log.Warn("stream chunk publish", "err", err)
}
}
+16 -12
View File
@@ -44,8 +44,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/basic"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
)
// defaultOwnerPath and defaultPersonasDir point at the on-disk persona/owner
@@ -302,6 +302,7 @@ func (a *app) wireRuntime() error {
PersonaID: personaID,
MaxRounds: defaultMaxRounds,
WorkingMemory: memory.NewWorkingMemory(0),
OnStream: a.streamChunk,
Log: logger.WithSource(a.log, "runtime/loop"),
})
@@ -331,7 +332,7 @@ func (a *app) wireAuxiliary() error {
}
a.tasks = tmgr
iotMgr, err := iot.New(a.cfg.IoT, a.log)
iotMgr, err := iot.New(a.cfg.IoT, a.log, iot.WithBus(a.bus))
if err != nil {
return fmt.Errorf("iot: %w", err)
}
@@ -404,16 +405,19 @@ func (a *app) wireChannels() error {
func (a *app) wireFrontend() error {
jwt := auth.New(a.cfg.Auth)
gw, err := gateway.New(a.cfg, gateway.Deps{
Log: logger.WithSource(a.log, "gateway"),
Auth: jwt,
Bus: a.bus,
Registry: a.caps,
Loop: a.loop,
Session: a.sessions,
Personas: a.personas,
Memories: a.memStore,
IoT: a.iot,
Frontend: resolveFrontendFS(a.log),
Log: logger.WithSource(a.log, "gateway"),
Auth: jwt,
Bus: a.bus,
Registry: a.caps,
Loop: a.loop,
Session: a.sessions,
Personas: a.personas,
Memories: a.memStore,
IoT: a.iot,
Scheduler: a.scheduler,
Tasks: a.tasks,
Streaming: true,
Frontend: resolveFrontendFS(a.log),
})
if err != nil {
return fmt.Errorf("gateway: %w", err)
+7 -4
View File
@@ -82,11 +82,14 @@ func (d Device) Clone() Device {
// state transitions, typically as the result of a Control call.
type DeviceUpdate struct {
// Provider is the name of the provider that owns the device.
Provider string
Provider string `json:"provider"`
// Device is the post-transition device snapshot.
Device Device
Device Device `json:"device"`
// Action is the control action that produced the transition.
Action string
Action string `json:"action"`
// Params are the optional control parameters.
Params map[string]any
Params map[string]any `json:"params,omitempty"`
// Message is a human-readable summary of the transition, surfaced to the
// frontend device_update feed.
Message string `json:"message,omitempty"`
}
+17 -1
View File
@@ -22,6 +22,17 @@ type Manager struct {
log *slog.Logger
}
// Option customizes a Manager built by New.
type Option func(*Manager)
// WithBus injects an existing bus instead of letting the Manager create one. It
// mirrors the other subsystems so iot.device_update flows on the shared bus and
// reaches the gateway hub / background-trigger subscribers rather than staying
// on an isolated manager-owned bus.
func WithBus(b bus.Bus) Option {
return func(m *Manager) { m.bus = b }
}
// New builds a Manager from cfg, selecting the device Provider by cfg.Provider.
//
// Supported providers: "" or "mock" registers the built-in mock provider with
@@ -31,11 +42,14 @@ type Manager struct {
//
// log may be nil; it is replaced with a discard logger. The Manager owns a
// fresh, isolated bus exposed via Bus for subscribers to attach to.
func New(cfg config.IoTConfig, log *slog.Logger) (*Manager, error) {
func New(cfg config.IoTConfig, log *slog.Logger, opts ...Option) (*Manager, error) {
m := &Manager{
bus: bus.New(),
log: logger.WithSource(discardLogger(log), "iot/manager"),
}
for _, opt := range opts {
opt(m)
}
switch strings.TrimSpace(cfg.Provider) {
case "", "mock":
mp, err := newMockProvider(log)
@@ -142,11 +156,13 @@ func (m *Manager) Status(ctx context.Context, deviceID string) (*Device, error)
// changed, and control should not be reported as failed because an observer did
// not receive the event.
func (m *Manager) publishUpdate(ctx context.Context, provider string, d Device, action string, params map[string]any) {
msg := fmt.Sprintf("设备 %s · %s(%s)", d.Name, action, provider)
if err := m.bus.Publish(ctx, DeviceUpdateSubject, DeviceUpdate{
Provider: provider,
Device: d,
Action: action,
Params: params,
Message: msg,
}); err != nil {
m.log.Error("iot publish device update", "device", d.ID, "action", action, "error", err)
return
@@ -0,0 +1,94 @@
package orchestration
import (
"context"
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
)
// AuditMode selects how a sub-agent context is shaped for the subagent_ask
// upstream WS route. It mirrors the audit.ContextAuditor surface (Full/Tail/
// Keyword) so the gateway can request exactly the window the client asks for.
type AuditMode string
const (
// AuditFull returns the entire conversation context.
AuditFull AuditMode = "full"
// AuditTail returns the last n messages of the context.
AuditTail AuditMode = "tail"
// AuditKeyword returns the window around the first keyword match.
AuditKeyword AuditMode = "keyword"
)
// AuditResult is the read-only result of auditing a sub-agent's conversation
// context. Messages carries the selected window; Line and Match are populated for
// a keyword query. It is the wire payload for a ws.subagent_event reply.
type AuditResult struct {
AgentID string `json:"agentId"`
Status Status `json:"status"`
Goal string `json:"goal,omitempty"`
Mode AuditMode `json:"mode"`
Messages []model.Message `json:"messages,omitempty"`
Line int `json:"line,omitempty"`
Match string `json:"match,omitempty"`
}
// AuditContext returns a read-only view of sub-agent id's conversation context
// for the subagent_ask WS route. mode selects the window: AuditFull returns the
// whole context, AuditTail returns the last n messages, AuditKeyword returns the
// window around the first query hit. It wraps ErrAgentNotFound for an unknown id
// and audit.ErrQueryEmpty for a blank keyword query, both with %w so callers can
// classify with errors.Is.
func (s *Scheduler) AuditContext(ctx context.Context, id string, mode AuditMode, n int, query string) (*AuditResult, error) {
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("orchestration: audit %q: %w", id, err)
}
s.mu.Lock()
sub := s.agents[id]
s.mu.Unlock()
if sub == nil {
return nil, fmt.Errorf("orchestration: audit %q: %w", id, ErrAgentNotFound)
}
a := audit.NewContextAuditor(sub.Context)
res := &AuditResult{AgentID: sub.ID, Status: sub.Status(), Goal: sub.Goal, Mode: mode}
switch mode {
case AuditFull:
res.Messages = a.Full()
case AuditTail:
res.Messages = a.Tail(n)
case AuditKeyword:
loc, err := a.Keyword(ctx, query)
if err != nil {
return nil, fmt.Errorf("orchestration: audit %q: %w", id, err)
}
if loc == nil {
res.Messages = []model.Message{}
break
}
res.Line = loc.Line
res.Messages = loc.Surrounding
res.Match = messageText(loc.Surrounding[len(loc.Surrounding)-1])
default:
return nil, fmt.Errorf("orchestration: audit %q: unknown mode %q", id, mode)
}
return res, nil
}
// messageText concatenates the text parts of one message. It mirrors the audit
// subpackage's own helper so the result payload can surface a human-readable
// match without exporting a cross-package text extractor.
func messageText(m model.Message) string {
var text string
for _, c := range m.Content {
if c.Type == model.ContentTypeText && c.Text != "" {
if text != "" {
text += " "
}
text += c.Text
}
}
return text
}
@@ -0,0 +1,57 @@
package orchestration
import (
"context"
"errors"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
)
// TestSchedulerAuditContext exercises the subagent_ask audit path across the full,
// tail and keyword windows, plus the not-found and blank-query errors.
func TestSchedulerAuditContext(t *testing.T) {
t.Parallel()
s := New(Options{MaxParallel: 2, AgentID: "orchestrator"})
ctx := context.Background()
msgs := []model.Message{
model.TextMessage(model.RoleUser, "hello"),
model.TextMessage(model.RoleAssistant, "hi there"),
}
s.mu.Lock()
s.agents["sub-1"] = &SubAgent{ID: "sub-1", Goal: "do it", Context: msgs, status: StatusRunning}
s.mu.Unlock()
full, err := s.AuditContext(ctx, "sub-1", AuditFull, 0, "")
if err != nil {
t.Fatalf("audit full: %v", err)
}
if full.AgentID != "sub-1" || full.Status != StatusRunning || len(full.Messages) != 2 {
t.Fatalf("full = %+v, want sub-1/running/2 msgs", full)
}
tail, err := s.AuditContext(ctx, "sub-1", AuditTail, 1, "")
if err != nil {
t.Fatalf("audit tail: %v", err)
}
if len(tail.Messages) != 1 || tail.Messages[0].Content[0].Text != "hi there" {
t.Fatalf("tail = %+v, want last message 'hi there'", tail)
}
kw, err := s.AuditContext(ctx, "sub-1", AuditKeyword, 0, "hello")
if err != nil {
t.Fatalf("audit keyword: %v", err)
}
if kw.Line != 1 || len(kw.Messages) != 2 {
t.Fatalf("keyword = %+v, want line 1 with 2 surrounding msgs", kw)
}
if _, err := s.AuditContext(ctx, "nope", AuditFull, 0, ""); !errors.Is(err, ErrAgentNotFound) {
t.Fatalf("want ErrAgentNotFound, got %v", err)
}
if _, err := s.AuditContext(ctx, "sub-1", AuditKeyword, 0, " "); !errors.Is(err, audit.ErrQueryEmpty) {
t.Fatalf("want ErrQueryEmpty, got %v", err)
}
}
+35 -14
View File
@@ -43,25 +43,25 @@ var (
// reaches a terminal state. It carries everything the agent needs to consume the
// result without a second lookup.
type DoneEvent struct {
ID string
Kind Kind
Status Status
Output Output
ExitCode int
Truncated bool
Error string
StartedAt time.Time
FinishedAt time.Time
ID string `json:"id"`
Kind Kind `json:"kind"`
Status Status `json:"status"`
Output Output `json:"output"`
ExitCode int `json:"exitCode"`
Truncated bool `json:"truncated"`
Error string `json:"error,omitempty"`
StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt"`
}
// OutputEvent is the payload published to the "task.output" subject as output
// becomes available.
type OutputEvent struct {
ID string
Kind Kind
Status Status
Output Output
Truncated bool
ID string `json:"id"`
Kind Kind `json:"kind"`
Status Status `json:"status"`
Output Output `json:"output"`
Truncated bool `json:"truncated"`
}
// Manager owns the task registry and the execution lifecycle. It owns an
@@ -230,6 +230,27 @@ func (m *Manager) Get(ctx context.Context, id string) (*Task, error) {
return t, nil
}
// TaskOutput returns the current output snapshot of task id for external
// consumers (the gateway's task_output_query WS route). It never waits for the
// task to finish: for a running background task it returns the output captured so
// far. It wraps ErrNotFound with %w so callers can classify with errors.Is.
func (m *Manager) TaskOutput(ctx context.Context, id string) (OutputEvent, error) {
if err := ctx.Err(); err != nil {
return OutputEvent{}, fmt.Errorf("task: output %q: %w", id, err)
}
t := m.get(id)
if t == nil {
return OutputEvent{}, fmt.Errorf("task: output %q: %w", id, ErrNotFound)
}
return OutputEvent{
ID: t.ID,
Kind: t.Kind,
Status: t.Status(),
Output: t.Output(),
Truncated: t.Truncated(),
}, nil
}
// Abort cancels the task for id. It returns ErrNotFound if there is no such task
// and ErrAlreadyDone if the task has already finished.
func (m *Manager) Abort(ctx context.Context, id string) error {
+3 -3
View File
@@ -22,11 +22,11 @@ const (
// Reminder is a single delayed notification.
type Reminder struct {
// ID uniquely identifies the reminder.
ID string
ID string `json:"id"`
// At is the time the reminder should fire.
At time.Time
At time.Time `json:"at"`
// Message is the payload surfaced to the user when it fires.
Message string
Message string `json:"message"`
}
// ReminderStore schedules delayed notifications and publishes each due reminder
+7
View File
@@ -397,6 +397,13 @@ func (b *Backthink) record(r Record) {
if _, err := b.store.Append(context.Background(), r); err != nil {
b.log.Error("backthink record failed", "error", err)
}
// Surface the persisted pass on the WS stream so the frontend's backthink
// consumer (or its default fallback) observes it without polling the store.
if b.deps.Bus != nil {
if err := b.deps.Bus.Publish(context.Background(), "ws.backthink", r); err != nil {
b.log.Warn("backthink bus publish failed", "error", err)
}
}
}
// normalize fills a Config's zero fields with safe defaults.
+109 -11
View File
@@ -25,6 +25,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"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"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/task"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
@@ -192,11 +195,31 @@ type stubIoT struct {
func (s *stubIoT) List(ctx context.Context) ([]iot.Device, error) { return s.list, s.err }
// stubAuditor mimics SubagentAuditor for the subagent_ask route.
type stubAuditor struct {
res *orchestration.AuditResult
err error
}
func (s *stubAuditor) AuditContext(ctx context.Context, id string, mode orchestration.AuditMode, n int, query string) (*orchestration.AuditResult, error) {
return s.res, s.err
}
// stubTaskOutputs mimics TaskOutputStore for the task_output_query route.
type stubTaskOutputs struct {
ev task.OutputEvent
err error
}
func (s *stubTaskOutputs) TaskOutput(ctx context.Context, id string) (task.OutputEvent, error) {
return s.ev, s.err
}
// stubChannel implements sdk.Channel.
type stubChannel struct{}
func (stubChannel) Name() string { return "qq" }
func (stubChannel) Connect(ctx context.Context) error { return nil }
func (stubChannel) Connect(ctx context.Context) error { return nil }
func (stubChannel) Disconnect(ctx context.Context) error { return nil }
func (stubChannel) IsConnected() bool { return true }
func (stubChannel) HealthCheck(ctx context.Context) error { return nil }
@@ -205,9 +228,9 @@ var _ sdk.Channel = stubChannel{}
// captureRunner records the sessionID each Run call sees.
type captureRunner struct {
got []string
res *loop.Result
err error
got []string
res *loop.Result
err error
}
func (c *captureRunner) Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error) {
@@ -664,8 +687,8 @@ func TestStatusRecorderFlush(t *testing.T) {
func TestSPA(t *testing.T) {
t.Parallel()
front := fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte("<html>root</html>")},
"assets/app.js": &fstest.MapFile{Data: []byte("var x = 1;")},
"index.html": &fstest.MapFile{Data: []byte("<html>root</html>")},
"assets/app.js": &fstest.MapFile{Data: []byte("var x = 1;")},
}
srv, ts, _ := buildEnv(t, cfgDefault(), Deps{Frontend: front})
_ = srv
@@ -825,20 +848,48 @@ func TestHubRelayChatBranch(t *testing.T) {
func TestHubRoute(t *testing.T) {
t.Parallel()
hb := newTestHub(nil, bus.New())
hb.auditor = &stubAuditor{res: &orchestration.AuditResult{
AgentID: "sub-1", Status: orchestration.StatusRunning, Goal: "do it", Mode: orchestration.AuditFull,
Messages: []model.Message{model.TextMessage(model.RoleUser, "hi")},
}}
hb.taskOutputs = &stubTaskOutputs{ev: task.OutputEvent{
ID: "task-1", Status: task.StatusDone, Output: task.Output{Stdout: "out", Stderr: "err"},
}}
c := newFakeConn(hb, 32)
hb.register(c)
// subagent_ask → subagent_event (enqueueJSON).
c.route(wsInbound{Type: "subagent_ask"})
// subagent_ask → subagent_event (real audit data, not a hardcoded pending).
c.route(wsInbound{Type: "subagent_ask", Data: json.RawMessage(`{"id":"sub-1"}`)})
if ev := readFakeFrame(t, c); ev.Type != "subagent_event" {
t.Fatalf("subagent frame type = %q, want subagent_event", ev.Type)
}
// task_output_query → task_output (enqueueJSON).
c.route(wsInbound{Type: "task_output_query"})
// subagent_ask with no auditor → clear error, never a fabricated pending.
hb.auditor = nil
c.route(wsInbound{Type: "subagent_ask", Data: json.RawMessage(`{"id":"sub-1"}`)})
if ev := readFakeFrame(t, c); ev.Type != "error" {
t.Fatalf("no-auditor subagent frame type = %q, want error", ev.Type)
}
hb.auditor = &stubAuditor{res: &orchestration.AuditResult{}}
// task_output_query → task_output with real output.
c.route(wsInbound{Type: "task_output_query", Data: json.RawMessage(`{"taskId":"task-1"}`)})
if ev := readFakeFrame(t, c); ev.Type != "task_output" {
t.Fatalf("task frame type = %q, want task_output", ev.Type)
}
// unknown → error (enqueueJSON), with non-nil rawData via mode_switch path already.
// task_output_query with no task store → clear error, never an empty {}.
hb.taskOutputs = nil
c.route(wsInbound{Type: "task_output_query", Data: json.RawMessage(`{"taskId":"task-1"}`)})
if ev := readFakeFrame(t, c); ev.Type != "error" {
t.Fatalf("no-task store frame type = %q, want error", ev.Type)
}
hb.taskOutputs = &stubTaskOutputs{ev: task.OutputEvent{ID: "task-1", Status: task.StatusDone, Output: task.Output{Stdout: "out"}}}
// voice_input → publishes to bus (no frame enqueued, not an unknown error).
c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"audio":"x"}`)})
if err := connHasNoFrame(c); err != nil {
t.Fatalf("voice_input should not enqueue a frame directly: %v", err)
}
// unknown → error (enqueueJSON).
c.route(wsInbound{Type: "nope"})
if ev := readFakeFrame(t, c); ev.Type != "error" {
t.Fatalf("unknown frame type = %q, want error", ev.Type)
@@ -850,6 +901,53 @@ func TestHubRoute(t *testing.T) {
}
}
func TestHubBridgeDeviceUpdate(t *testing.T) {
t.Parallel()
b := bus.New()
hb := newTestHub(nil, b)
c := newFakeConn(hb, 32)
hb.register(c)
hb.start()
t.Cleanup(hb.close)
if err := b.Publish(context.Background(), "iot.device_update", map[string]any{"message": "灯开了"}); err != nil {
t.Fatalf("publish: %v", err)
}
if ev := readFakeFrame(t, c); ev.Type != "device_update" {
t.Fatalf("type = %q, want device_update (frame=%+v)", ev.Type, ev)
}
}
func TestHubBridgeSubagentProgress(t *testing.T) {
t.Parallel()
hb := newTestHub(nil, nil)
c := newFakeConn(hb, 32)
hb.register(c)
if err := hb.relay(context.Background(), bus.Event{
Subject: "cyrene.agent.orchestrator.event",
Payload: comm.Event{Name: orchestration.EventDone, Data: orchestration.Progress{
AgentID: "sub-1", Status: orchestration.StatusDone, RoleID: "engineer", Mode: "chat",
}},
}); err != nil {
t.Fatalf("relay: %v", err)
}
if ev := readFakeFrame(t, c); ev.Type != "subagent_event" {
t.Fatalf("type = %q, want subagent_event (frame=%+v)", ev.Type, ev)
}
// An internal ask subject must never surface to clients.
if err := hb.relay(context.Background(), bus.Event{
Subject: "cyrene.agent.a.b.ask",
Payload: comm.Event{Name: orchestration.EventDone, Data: orchestration.Progress{AgentID: "sub-1"}},
}); err != nil {
t.Fatalf("relay ask: %v", err)
}
if err := connHasNoFrame(c); err != nil {
t.Fatalf("internal ask must not be broadcast: %v", err)
}
}
func TestPublishPublicNilBus(t *testing.T) {
t.Parallel()
hb := newTestHub(nil, nil) // nil bus
+53 -23
View File
@@ -38,6 +38,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"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/task"
)
// source is the log provenance tag for every record this package emits.
@@ -96,6 +98,18 @@ type IoTStore interface {
List(ctx context.Context) ([]iot.Device, error)
}
// SubagentAuditor audits a sub-agent's conversation context for the subagent_ask
// WS route. *orchestration.Scheduler is the production implementation.
type SubagentAuditor interface {
AuditContext(ctx context.Context, id string, mode orchestration.AuditMode, n int, query string) (*orchestration.AuditResult, error)
}
// TaskOutputStore queries a task's output for the task_output_query WS route.
// *task.Manager is the production implementation.
type TaskOutputStore interface {
TaskOutput(ctx context.Context, id string) (task.OutputEvent, error)
}
// Deps carries the gateway's injected dependencies. Every field is nil-tolerant
// except Auth, which is required (authentication backs login and every RBAC
// decision). Registry/Loop are satisfied by the concrete *capability.Registry and
@@ -121,6 +135,16 @@ type Deps struct {
Memories MemoryStore
// IoT lists IoT devices. Optional.
IoT IoTStore
// Scheduler audits sub-agent context for the subagent_ask WS route.
// Optional (subagent_ask returns a clear error when unset).
Scheduler SubagentAuditor
// Tasks queries task output for the task_output_query WS route.
// Optional (task_output_query returns a clear error when unset).
Tasks TaskOutputStore
// Streaming marks the injected Loop as streaming-capable (its OnStream is
// wired to publish ws.stream_chunk). When set the hub skips its own single
// final stream_chunk emission, since deltas already stream through OnStream.
Streaming bool
// Frontend is the SPA filesystem served for all non-API, non-WS routes.
// When nil the gateway serves no static assets (API and WS only). The
// embedded dist (internal/webui) or an external dist directory is used.
@@ -130,18 +154,21 @@ type Deps struct {
// Server is the gateway. It owns a ServeMux aggregating REST + WS + admin and a
// WebSocket hub. It is safe for concurrent use once constructed.
type Server struct {
cfg *config.Config
auth Authenticator
bus bus.Bus
caps CapabilityStore
runner Chatter
sessions SessionStore
personas PersonaStore
memories MemoryStore
iot IoTStore
frontend fs.FS
log *slog.Logger
hub *hub
cfg *config.Config
auth Authenticator
bus bus.Bus
caps CapabilityStore
runner Chatter
sessions SessionStore
personas PersonaStore
memories MemoryStore
iot IoTStore
auditor SubagentAuditor
taskOutputs TaskOutputStore
streaming bool
frontend fs.FS
log *slog.Logger
hub *hub
}
// New validates cfg and deps and returns a ready Server. Auth is required; the
@@ -155,17 +182,20 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
return nil, fmt.Errorf("gateway: auth dependency required")
}
s := &Server{
cfg: cfg,
auth: deps.Auth,
bus: deps.Bus,
caps: deps.Registry,
runner: deps.Loop,
sessions: deps.Session,
personas: deps.Personas,
memories: deps.Memories,
iot: deps.IoT,
frontend: deps.Frontend,
log: sourcedLog(deps.Log),
cfg: cfg,
auth: deps.Auth,
bus: deps.Bus,
caps: deps.Registry,
runner: deps.Loop,
sessions: deps.Session,
personas: deps.Personas,
memories: deps.Memories,
iot: deps.IoT,
auditor: deps.Scheduler,
taskOutputs: deps.Tasks,
streaming: deps.Streaming,
frontend: deps.Frontend,
log: sourcedLog(deps.Log),
}
s.hub = newHub(s)
s.hub.start()
+92
View File
@@ -0,0 +1,92 @@
package gateway
import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"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.<from>.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
}
// isBroadcastAgentEvent reports whether subject has the exact 4-token broadcast
// shape cyrene.agent.<from>.event, which is how the scheduler publishes progress.
// It deliberately does not match addressed events (cyrene.agent.<from>.<to>.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"
}
+54 -41
View File
@@ -23,6 +23,7 @@ const (
wsEventsPattern = "ws.>"
wsPrefix = "ws."
wsChatSubject = "ws.chat"
wsVoiceInput = "ws.voice_input"
wsReadLimit = 4096
wsWriteWait = 10 * time.Second
wsPongWait = 60 * time.Second
@@ -57,27 +58,33 @@ type wsConn struct {
// hub owns the connection set and relays bus events to every client.
type hub struct {
mu sync.RWMutex
conns map[*wsConn]struct{}
bus bus.Bus
runner Chatter
log *slog.Logger
upgrader websocket.Upgrader
origins map[string]bool
allowAll bool
cancel context.CancelFunc
mu sync.RWMutex
conns map[*wsConn]struct{}
bus bus.Bus
runner Chatter
auditor SubagentAuditor
taskOutputs TaskOutputStore
streaming bool
log *slog.Logger
upgrader websocket.Upgrader
origins map[string]bool
allowAll bool
cancel context.CancelFunc
}
// newHub builds a hub bound to the server's dependencies.
func newHub(s *Server) *hub {
origins, allowAll := s.allowedOrigins()
return &hub{
conns: make(map[*wsConn]struct{}),
bus: s.bus,
runner: s.runner,
log: s.log,
origins: origins,
allowAll: allowAll,
conns: make(map[*wsConn]struct{}),
bus: s.bus,
runner: s.runner,
auditor: s.auditor,
taskOutputs: s.taskOutputs,
streaming: s.streaming,
log: s.log,
origins: origins,
allowAll: allowAll,
upgrader: websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
@@ -87,15 +94,20 @@ func newHub(s *Server) *hub {
}
// start subscribes the hub to the bus event stream. Publishing to ws.> arrives
// here and is fanned out to clients; nothing happens when no bus is wired.
// here and is fanned out to clients; the producer bridge patterns (iot/task/
// automation/reminder/subagent-progress) are also subscribed and normalized into
// ws.* event types. Nothing happens when no bus is wired.
func (h *hub) start() {
if h.bus == nil {
return
}
ctx, cancel := context.WithCancel(context.Background())
h.cancel = cancel
if _, err := h.bus.Subscribe(ctx, wsEventsPattern, h.relay); err != nil {
h.log.Warn("bus subscribe", "pattern", wsEventsPattern, "err", err)
patterns := append([]string{wsEventsPattern}, bridgePatterns...)
for _, p := range patterns {
if _, err := h.bus.Subscribe(ctx, p, h.relay); err != nil {
h.log.Warn("bus subscribe", "pattern", p, "err", err)
}
}
}
@@ -154,15 +166,23 @@ func (h *hub) broadcast(ev wsEvent) {
}
}
// relay is the bus handler for the ws.> pattern. The ws.chat subject is a
// streaming trigger (run the loop and emit chunks); every other subject becomes
// an outbound event of its design type.
// relay is the bus handler for the ws.> pattern and the producer bridge
// patterns. The ws.chat subject is a streaming trigger (run the loop and emit
// chunks); a ws.* subject becomes an outbound event of its design type; a
// bridged producer subject (device_update, task output/done, automation result,
// reminder, subagent progress) is normalized into the matching ws.* event type.
// Events with no WS-visible mapping are dropped so internal agent-to-agent
// traffic never leaks to clients.
func (h *hub) relay(ctx context.Context, e bus.Event) error {
if e.Subject == wsChatSubject {
go h.streamChat(ctx, e.Payload)
return nil
}
h.broadcast(wsEvent{Type: eventType(e.Subject), Data: e.Payload})
ev, ok := normalizeEvent(e)
if !ok {
return nil
}
h.broadcast(ev)
return nil
}
@@ -187,7 +207,13 @@ func (h *hub) streamChat(ctx context.Context, payload any) {
h.broadcast(wsEvent{Type: "stream_end", Data: map[string]any{"stopReason": "error", "error": err.Error()}})
return
}
h.broadcast(wsEvent{Type: "stream_chunk", Data: map[string]any{"delta": res.Text}})
// When the loop is streaming (OnStream wired), the deltas already fan out as
// ws.stream_chunk through the OnStream→bus→relay path; a second whole-text
// chunk would duplicate the tail. The non-streaming path (or a stub runner)
// still emits one chunk so the stream contract holds in tests.
if !h.streaming {
h.broadcast(wsEvent{Type: "stream_chunk", Data: map[string]any{"delta": res.Text}})
}
h.broadcast(wsEvent{Type: "stream_end", Data: map[string]any{"stopReason": res.StopReason, "steps": res.Steps}})
}
@@ -282,22 +308,6 @@ func (c *wsConn) writePump() {
}
}
// route dispatches one inbound frame by its type.
func (c *wsConn) route(in wsInbound) {
switch in.Type {
case "mode_switch":
c.publishPublic("ws.mode_switch", rawData(in.Data))
case "chat":
c.publishPublic(wsChatSubject, rawData(in.Data))
case "subagent_ask":
c.enqueueJSON(wsEvent{Type: "subagent_event", Data: map[string]any{"state": "pending"}})
case "task_output_query":
c.enqueueJSON(wsEvent{Type: "task_output", Data: map[string]any{}})
default:
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "unknown event type"}})
}
}
// publishPublic publishes a bus event without any per-connection reply.
func (c *wsConn) publishPublic(subject string, payload any) {
if c.h.bus == nil {
@@ -306,11 +316,14 @@ func (c *wsConn) publishPublic(subject string, payload any) {
_ = c.h.bus.Publish(context.Background(), subject, payload)
}
// shutdown closes the connection exactly once.
// shutdown closes the connection exactly once. It tolerates a nil *websocket.Conn
// so tests that build a fake connection can rely on a safe teardown.
func (c *wsConn) shutdown() {
c.once.Do(func() {
close(c.done)
_ = c.conn.Close()
if c.conn != nil {
_ = c.conn.Close()
}
})
}
+107
View File
@@ -0,0 +1,107 @@
package gateway
import (
"context"
"encoding/json"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
)
// route dispatches one inbound frame by its type.
func (c *wsConn) route(in wsInbound) {
switch in.Type {
case "mode_switch":
c.publishPublic("ws.mode_switch", rawData(in.Data))
case "chat":
c.publishPublic(wsChatSubject, rawData(in.Data))
case "voice_input":
// Voice is not wired to a live pipeline yet; surface the payload on the
// bus so an upper layer (or a later milestone) can consume it, rather
// than dropping it as unknown.
c.publishPublic(wsVoiceInput, rawData(in.Data))
case "subagent_ask":
c.handleSubagentAsk(in)
case "task_output_query":
c.handleTaskOutputQuery(in)
default:
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "unknown event type"}})
}
}
// subagentAskRequest is the decoded payload of a subagent_ask frame. Mode selects
// the audit window (""/full/tail/keyword); n bounds a tail query; query is the
// keyword for a keyword query.
type subagentAskRequest struct {
ID string `json:"id"`
Mode string `json:"mode"`
N int `json:"n"`
Query string `json:"query"`
}
// taskOutputRequest is the decoded payload of a task_output_query frame.
type taskOutputRequest struct {
TaskID string `json:"taskId"`
}
// handleSubagentAsk audits a sub-agent's context and replies with a
// ws.subagent_event frame carrying the real context window. When no auditor is
// injected (gateway not wired with a scheduler) it replies with a clear error
// rather than a fabricated "pending" snapshot.
func (c *wsConn) handleSubagentAsk(in wsInbound) {
if c.h.auditor == nil {
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "subagent audit unavailable"}})
return
}
var req subagentAskRequest
_ = json.Unmarshal(in.Data, &req)
if req.ID == "" {
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "subagent id required"}})
return
}
mode := orchestration.AuditMode(req.Mode)
if mode == "" {
mode = orchestration.AuditFull
}
res, err := c.h.auditor.AuditContext(context.Background(), req.ID, mode, req.N, req.Query)
if err != nil {
c.h.log.Debug("subagent audit", "id", req.ID, "err", err)
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": err.Error()}})
return
}
c.enqueueJSON(wsEvent{Type: "subagent_event", Data: res})
}
// handleTaskOutputQuery fetches the real task output for taskId and replies with
// a ws.task_output frame. When no task store is injected it replies with a clear
// error rather than an empty {}. A task that is still running returns the output
// captured so far.
func (c *wsConn) handleTaskOutputQuery(in wsInbound) {
if c.h.taskOutputs == nil {
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "task output unavailable"}})
return
}
var req taskOutputRequest
_ = json.Unmarshal(in.Data, &req)
if req.TaskID == "" {
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "task id required"}})
return
}
ev, err := c.h.taskOutputs.TaskOutput(context.Background(), req.TaskID)
if err != nil {
c.h.log.Debug("task output query", "id", req.TaskID, "err", err)
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": err.Error()}})
return
}
text := ev.Output.Stdout
if ev.Output.Stderr != "" {
if text != "" {
text += "\n"
}
text += ev.Output.Stderr
}
c.enqueueJSON(wsEvent{Type: "task_output", Data: map[string]any{
"taskId": ev.ID,
"output": text,
"status": string(ev.Status),
}})
}