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

@@ -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 {