feat(gateway): 地基③ 打通实时信号 producer→前端WS + loop 真流式 + 上行路由补全
打通前端 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:
1 parent
04bfa2c7a7
commit
9b207a31e8
14 files changed
+673
-109
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}})
|
||||
}
|
||||
Reference in new issue
Block a user