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/telegram"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook" "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink" "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" "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 // defaultOwnerPath and defaultPersonasDir point at the on-disk persona/owner
@@ -302,6 +302,7 @@ func (a *app) wireRuntime() error {
PersonaID: personaID, PersonaID: personaID,
MaxRounds: defaultMaxRounds, MaxRounds: defaultMaxRounds,
WorkingMemory: memory.NewWorkingMemory(0), WorkingMemory: memory.NewWorkingMemory(0),
OnStream: a.streamChunk,
Log: logger.WithSource(a.log, "runtime/loop"), Log: logger.WithSource(a.log, "runtime/loop"),
}) })
@@ -331,7 +332,7 @@ func (a *app) wireAuxiliary() error {
} }
a.tasks = tmgr 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 { if err != nil {
return fmt.Errorf("iot: %w", err) return fmt.Errorf("iot: %w", err)
} }
@@ -404,16 +405,19 @@ func (a *app) wireChannels() error {
func (a *app) wireFrontend() error { func (a *app) wireFrontend() error {
jwt := auth.New(a.cfg.Auth) jwt := auth.New(a.cfg.Auth)
gw, err := gateway.New(a.cfg, gateway.Deps{ gw, err := gateway.New(a.cfg, gateway.Deps{
Log: logger.WithSource(a.log, "gateway"), Log: logger.WithSource(a.log, "gateway"),
Auth: jwt, Auth: jwt,
Bus: a.bus, Bus: a.bus,
Registry: a.caps, Registry: a.caps,
Loop: a.loop, Loop: a.loop,
Session: a.sessions, Session: a.sessions,
Personas: a.personas, Personas: a.personas,
Memories: a.memStore, Memories: a.memStore,
IoT: a.iot, IoT: a.iot,
Frontend: resolveFrontendFS(a.log), Scheduler: a.scheduler,
Tasks: a.tasks,
Streaming: true,
Frontend: resolveFrontendFS(a.log),
}) })
if err != nil { if err != nil {
return fmt.Errorf("gateway: %w", err) 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. // state transitions, typically as the result of a Control call.
type DeviceUpdate struct { type DeviceUpdate struct {
// Provider is the name of the provider that owns the device. // 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 is the post-transition device snapshot.
Device Device Device Device `json:"device"`
// Action is the control action that produced the transition. // Action is the control action that produced the transition.
Action string Action string `json:"action"`
// Params are the optional control parameters. // 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 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. // New builds a Manager from cfg, selecting the device Provider by cfg.Provider.
// //
// Supported providers: "" or "mock" registers the built-in mock provider with // 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 // 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. // 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{ m := &Manager{
bus: bus.New(), bus: bus.New(),
log: logger.WithSource(discardLogger(log), "iot/manager"), log: logger.WithSource(discardLogger(log), "iot/manager"),
} }
for _, opt := range opts {
opt(m)
}
switch strings.TrimSpace(cfg.Provider) { switch strings.TrimSpace(cfg.Provider) {
case "", "mock": case "", "mock":
mp, err := newMockProvider(log) 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 // changed, and control should not be reported as failed because an observer did
// not receive the event. // not receive the event.
func (m *Manager) publishUpdate(ctx context.Context, provider string, d Device, action string, params map[string]any) { 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{ if err := m.bus.Publish(ctx, DeviceUpdateSubject, DeviceUpdate{
Provider: provider, Provider: provider,
Device: d, Device: d,
Action: action, Action: action,
Params: params, Params: params,
Message: msg,
}); err != nil { }); err != nil {
m.log.Error("iot publish device update", "device", d.ID, "action", action, "error", err) m.log.Error("iot publish device update", "device", d.ID, "action", action, "error", err)
return 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 // reaches a terminal state. It carries everything the agent needs to consume the
// result without a second lookup. // result without a second lookup.
type DoneEvent struct { type DoneEvent struct {
ID string ID string `json:"id"`
Kind Kind Kind Kind `json:"kind"`
Status Status Status Status `json:"status"`
Output Output Output Output `json:"output"`
ExitCode int ExitCode int `json:"exitCode"`
Truncated bool Truncated bool `json:"truncated"`
Error string Error string `json:"error,omitempty"`
StartedAt time.Time StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time FinishedAt time.Time `json:"finishedAt"`
} }
// OutputEvent is the payload published to the "task.output" subject as output // OutputEvent is the payload published to the "task.output" subject as output
// becomes available. // becomes available.
type OutputEvent struct { type OutputEvent struct {
ID string ID string `json:"id"`
Kind Kind Kind Kind `json:"kind"`
Status Status Status Status `json:"status"`
Output Output Output Output `json:"output"`
Truncated bool Truncated bool `json:"truncated"`
} }
// Manager owns the task registry and the execution lifecycle. It owns an // 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 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 // Abort cancels the task for id. It returns ErrNotFound if there is no such task
// and ErrAlreadyDone if the task has already finished. // and ErrAlreadyDone if the task has already finished.
func (m *Manager) Abort(ctx context.Context, id string) error { 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. // Reminder is a single delayed notification.
type Reminder struct { type Reminder struct {
// ID uniquely identifies the reminder. // ID uniquely identifies the reminder.
ID string ID string `json:"id"`
// At is the time the reminder should fire. // 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 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 // 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 { if _, err := b.store.Append(context.Background(), r); err != nil {
b.log.Error("backthink record failed", "error", err) 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. // 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/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" "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/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" "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 } 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. // stubChannel implements sdk.Channel.
type stubChannel struct{} type stubChannel struct{}
func (stubChannel) Name() string { return "qq" } 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) Disconnect(ctx context.Context) error { return nil }
func (stubChannel) IsConnected() bool { return true } func (stubChannel) IsConnected() bool { return true }
func (stubChannel) HealthCheck(ctx context.Context) error { return nil } 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. // captureRunner records the sessionID each Run call sees.
type captureRunner struct { type captureRunner struct {
got []string got []string
res *loop.Result res *loop.Result
err error err error
} }
func (c *captureRunner) Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, 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) { func TestSPA(t *testing.T) {
t.Parallel() t.Parallel()
front := fstest.MapFS{ front := fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte("<html>root</html>")}, "index.html": &fstest.MapFile{Data: []byte("<html>root</html>")},
"assets/app.js": &fstest.MapFile{Data: []byte("var x = 1;")}, "assets/app.js": &fstest.MapFile{Data: []byte("var x = 1;")},
} }
srv, ts, _ := buildEnv(t, cfgDefault(), Deps{Frontend: front}) srv, ts, _ := buildEnv(t, cfgDefault(), Deps{Frontend: front})
_ = srv _ = srv
@@ -825,20 +848,48 @@ func TestHubRelayChatBranch(t *testing.T) {
func TestHubRoute(t *testing.T) { func TestHubRoute(t *testing.T) {
t.Parallel() t.Parallel()
hb := newTestHub(nil, bus.New()) 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) c := newFakeConn(hb, 32)
hb.register(c) hb.register(c)
// subagent_ask → subagent_event (enqueueJSON). // subagent_ask → subagent_event (real audit data, not a hardcoded pending).
c.route(wsInbound{Type: "subagent_ask"}) c.route(wsInbound{Type: "subagent_ask", Data: json.RawMessage(`{"id":"sub-1"}`)})
if ev := readFakeFrame(t, c); ev.Type != "subagent_event" { if ev := readFakeFrame(t, c); ev.Type != "subagent_event" {
t.Fatalf("subagent frame type = %q, want subagent_event", ev.Type) t.Fatalf("subagent frame type = %q, want subagent_event", ev.Type)
} }
// task_output_query → task_output (enqueueJSON). // subagent_ask with no auditor → clear error, never a fabricated pending.
c.route(wsInbound{Type: "task_output_query"}) 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" { if ev := readFakeFrame(t, c); ev.Type != "task_output" {
t.Fatalf("task frame type = %q, want task_output", ev.Type) 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"}) c.route(wsInbound{Type: "nope"})
if ev := readFakeFrame(t, c); ev.Type != "error" { if ev := readFakeFrame(t, c); ev.Type != "error" {
t.Fatalf("unknown frame type = %q, want error", ev.Type) 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) { func TestPublishPublicNilBus(t *testing.T) {
t.Parallel() t.Parallel()
hb := newTestHub(nil, nil) // nil bus 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/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" "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/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. // 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) 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 // Deps carries the gateway's injected dependencies. Every field is nil-tolerant
// except Auth, which is required (authentication backs login and every RBAC // except Auth, which is required (authentication backs login and every RBAC
// decision). Registry/Loop are satisfied by the concrete *capability.Registry and // decision). Registry/Loop are satisfied by the concrete *capability.Registry and
@@ -121,6 +135,16 @@ type Deps struct {
Memories MemoryStore Memories MemoryStore
// IoT lists IoT devices. Optional. // IoT lists IoT devices. Optional.
IoT IoTStore 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. // 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 // When nil the gateway serves no static assets (API and WS only). The
// embedded dist (internal/webui) or an external dist directory is used. // 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 // Server is the gateway. It owns a ServeMux aggregating REST + WS + admin and a
// WebSocket hub. It is safe for concurrent use once constructed. // WebSocket hub. It is safe for concurrent use once constructed.
type Server struct { type Server struct {
cfg *config.Config cfg *config.Config
auth Authenticator auth Authenticator
bus bus.Bus bus bus.Bus
caps CapabilityStore caps CapabilityStore
runner Chatter runner Chatter
sessions SessionStore sessions SessionStore
personas PersonaStore personas PersonaStore
memories MemoryStore memories MemoryStore
iot IoTStore iot IoTStore
frontend fs.FS auditor SubagentAuditor
log *slog.Logger taskOutputs TaskOutputStore
hub *hub streaming bool
frontend fs.FS
log *slog.Logger
hub *hub
} }
// New validates cfg and deps and returns a ready Server. Auth is required; the // 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") return nil, fmt.Errorf("gateway: auth dependency required")
} }
s := &Server{ s := &Server{
cfg: cfg, cfg: cfg,
auth: deps.Auth, auth: deps.Auth,
bus: deps.Bus, bus: deps.Bus,
caps: deps.Registry, caps: deps.Registry,
runner: deps.Loop, runner: deps.Loop,
sessions: deps.Session, sessions: deps.Session,
personas: deps.Personas, personas: deps.Personas,
memories: deps.Memories, memories: deps.Memories,
iot: deps.IoT, iot: deps.IoT,
frontend: deps.Frontend, auditor: deps.Scheduler,
log: sourcedLog(deps.Log), taskOutputs: deps.Tasks,
streaming: deps.Streaming,
frontend: deps.Frontend,
log: sourcedLog(deps.Log),
} }
s.hub = newHub(s) s.hub = newHub(s)
s.hub.start() 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.>" wsEventsPattern = "ws.>"
wsPrefix = "ws." wsPrefix = "ws."
wsChatSubject = "ws.chat" wsChatSubject = "ws.chat"
wsVoiceInput = "ws.voice_input"
wsReadLimit = 4096 wsReadLimit = 4096
wsWriteWait = 10 * time.Second wsWriteWait = 10 * time.Second
wsPongWait = 60 * 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. // hub owns the connection set and relays bus events to every client.
type hub struct { type hub struct {
mu sync.RWMutex mu sync.RWMutex
conns map[*wsConn]struct{} conns map[*wsConn]struct{}
bus bus.Bus bus bus.Bus
runner Chatter runner Chatter
log *slog.Logger auditor SubagentAuditor
upgrader websocket.Upgrader taskOutputs TaskOutputStore
origins map[string]bool streaming bool
allowAll bool log *slog.Logger
cancel context.CancelFunc upgrader websocket.Upgrader
origins map[string]bool
allowAll bool
cancel context.CancelFunc
} }
// newHub builds a hub bound to the server's dependencies. // newHub builds a hub bound to the server's dependencies.
func newHub(s *Server) *hub { func newHub(s *Server) *hub {
origins, allowAll := s.allowedOrigins() origins, allowAll := s.allowedOrigins()
return &hub{ return &hub{
conns: make(map[*wsConn]struct{}), conns: make(map[*wsConn]struct{}),
bus: s.bus, bus: s.bus,
runner: s.runner, runner: s.runner,
log: s.log, auditor: s.auditor,
origins: origins, taskOutputs: s.taskOutputs,
allowAll: allowAll, streaming: s.streaming,
log: s.log,
origins: origins,
allowAll: allowAll,
upgrader: websocket.Upgrader{ upgrader: websocket.Upgrader{
ReadBufferSize: 1024, ReadBufferSize: 1024,
WriteBufferSize: 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 // 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() { func (h *hub) start() {
if h.bus == nil { if h.bus == nil {
return return
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
h.cancel = cancel h.cancel = cancel
if _, err := h.bus.Subscribe(ctx, wsEventsPattern, h.relay); err != nil { patterns := append([]string{wsEventsPattern}, bridgePatterns...)
h.log.Warn("bus subscribe", "pattern", wsEventsPattern, "err", err) 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 // relay is the bus handler for the ws.> pattern and the producer bridge
// streaming trigger (run the loop and emit chunks); every other subject becomes // patterns. The ws.chat subject is a streaming trigger (run the loop and emit
// an outbound event of its design type. // 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 { func (h *hub) relay(ctx context.Context, e bus.Event) error {
if e.Subject == wsChatSubject { if e.Subject == wsChatSubject {
go h.streamChat(ctx, e.Payload) go h.streamChat(ctx, e.Payload)
return nil 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 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()}}) h.broadcast(wsEvent{Type: "stream_end", Data: map[string]any{"stopReason": "error", "error": err.Error()}})
return 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}}) 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. // publishPublic publishes a bus event without any per-connection reply.
func (c *wsConn) publishPublic(subject string, payload any) { func (c *wsConn) publishPublic(subject string, payload any) {
if c.h.bus == nil { 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) _ = 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() { func (c *wsConn) shutdown() {
c.once.Do(func() { c.once.Do(func() {
close(c.done) 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),
}})
}