打通前端 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 推至后续版本。
446 lines
15 KiB
Go
446 lines
15 KiB
Go
// Package backthink implements the M8 autonomous background-think service
|
|
// (DevDocs/design/12-后台思考与主动.md §0-§3).
|
|
//
|
|
// It runs unattended thinking passes on two triggers:
|
|
//
|
|
// - A periodic heartbeat (config.Background.IntervalSec) that asks the injected
|
|
// runtime to reflect and is allowed to call tools (IoT, calendar, reminders)
|
|
// but must not emit chat-style messages by default.
|
|
// - Asynchronous events on the in-process bus (iot.device_update,
|
|
// reminder.trigger, system.event) that wake a thinking pass.
|
|
//
|
|
// Each pass produces a conclusion, then consults Decide to judge whether the user
|
|
// should be interrupted at all (event importance / time-of-day / user state such
|
|
// as heart rate, sleep, work, busy / boredom). When it is worth interrupting the
|
|
// push goes to the active client first and falls back to the IM bridge, bounded
|
|
// by the proactive-push rate limit of DevDocs/design/11 §6. Every pass is
|
|
// persisted to SQLite via Store so the M9/M10 monitor panel can render the
|
|
// background-thinking page (design/12 §4).
|
|
//
|
|
// The package is concurrency-safe: a mutex guards the lifecycle state, the
|
|
// injected store/pusher/thinker are required to be concurrency-safe by contract,
|
|
// and the internal publish path never waits on a slow consumer.
|
|
package backthink
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
|
)
|
|
|
|
// Defaults applied by normalize when a Config field is left at its zero value.
|
|
// They are compile-time constants so no magic number appears in the run path.
|
|
const (
|
|
// defaultIntervalSec is the heartbeat period in seconds when unused.
|
|
defaultIntervalSec = 300
|
|
// defaultQuietStartHour and defaultQuietEndHour bound the do-not-disturb
|
|
// window in local clock hours (00:00-07:00 vs 23:00-07:00: the window may
|
|
// wrap midnight; two equal non-zero hours disable it).
|
|
defaultQuietStartHour = 23
|
|
defaultQuietEndHour = 7
|
|
// defaultMaxPushWindow caps proactive pushes within defaultPushWindow to
|
|
// honor the anti-spam rule of design/11 §6 (QQ wind control, iLink window).
|
|
defaultMaxPushWindow = 3
|
|
defaultPushWindow = 10 * time.Minute
|
|
// defaultThinkTimeout bounds a single unattended thought so it can never
|
|
// wedge a worker goroutine.
|
|
defaultThinkTimeout = 30 * time.Second
|
|
// defaultSessionID scopes a think when the caller supplies none. The
|
|
// background pass owns no conversation, so a stable synthetic id is used.
|
|
defaultSessionID = "sess-backthink"
|
|
)
|
|
|
|
// Sentinel errors returned by this package. Use errors.Is to test for them; they
|
|
// are returned directly or wrapped with %w so the underlying cause stays intact.
|
|
var (
|
|
// ErrNilDB is returned when Backthink or Store is built with a nil database.
|
|
ErrNilDB = errors.New("backthink: nil database handle")
|
|
// ErrNoThinker is returned when New is given no Thinker.
|
|
ErrNoThinker = errors.New("backthink: nil thinker")
|
|
// ErrNoLoop is returned by LoopThinker when it wraps a nil loop.
|
|
ErrNoLoop = errors.New("backthink: nil loop")
|
|
)
|
|
|
|
// Thinker runs one unattended thinking pass over the injected runtime. It is the
|
|
// seam between the service and the loop/agent: the production wiring hands back a
|
|
// LoopThinker over a *loop.Loop, while tests inject a mock. Implementations may
|
|
// call tools (IoT, calendar, reminders) and must be concurrency-safe.
|
|
type Thinker interface {
|
|
// Think invokes one autonomous thinking pass for the given session/trigger
|
|
// and returns the concluded text.
|
|
Think(ctx context.Context, sessionID, trigger string) (string, error)
|
|
}
|
|
|
|
// Config carries the autonomous-think tuning knobs. Zero values pick safe
|
|
// defaults via normalize, so a partially filled Config is still usable.
|
|
type Config struct {
|
|
// Interval is the heartbeat period. When zero it is derived from
|
|
// IntervalSec (or its default).
|
|
Interval time.Duration
|
|
// IntervalSec is the heartbeat period in seconds, mapped from
|
|
// config.BackgroundConfig.IntervalSec. Ignored when Interval is set.
|
|
IntervalSec int
|
|
// QuietStartHour and QuietEndHour bound the do-not-disturb window in local
|
|
// clock hours. Equal non-zero hours disable the window.
|
|
QuietStartHour int
|
|
QuietEndHour int
|
|
// MaxPushWindow bounds proactive pushes within PushWindow. Zero uses the
|
|
// default. Non-positive PushWindow or MaxPushWindow disables the limit.
|
|
MaxPushWindow int
|
|
// PushWindow is the sliding window over which MaxPushWindow is enforced.
|
|
PushWindow time.Duration
|
|
// ThinkTimeout bounds a single thinking pass. Zero uses the default.
|
|
ThinkTimeout time.Duration
|
|
}
|
|
|
|
// ConfigFromBackground maps a config.BackgroundConfig (the source of the
|
|
// heartbeat interval) into a backthink.Config, leaving the push/decide knobs at
|
|
// their defaults for normalize to fill in.
|
|
func ConfigFromBackground(bg config.BackgroundConfig) Config {
|
|
return Config{IntervalSec: bg.IntervalSec}
|
|
}
|
|
|
|
// Deps is the injected dependency set for a Backthink. Only Thinker and DB are
|
|
// required; the rest degrade gracefully (a nil Bus disables event triggers, a nil
|
|
// State yields a neutral user state, and a nil Pusher is built from Active/IM).
|
|
type Deps struct {
|
|
// Bus is the event-trigger source. Nil disables event-driven thinking.
|
|
Bus bus.Bus
|
|
// Thinker runs one unattended thinking pass. Required.
|
|
Thinker Thinker
|
|
// State supplies the user's current condition for the interrupt decision.
|
|
// Nil yields a neutral state.
|
|
State StateProvider
|
|
// Pusher delivers a proactive message. When nil it is built from Active (the
|
|
// active-client sink) and IM (the IM bridge sink) plus Gate.
|
|
Pusher Pusher
|
|
// Active is the active web/native client sink, preferred for pushes.
|
|
Active ActiveClient
|
|
// IM is the IM bridge sink, used as the fallback when no active client is up.
|
|
IM IMAdapter
|
|
// Gate is the proactive-push rate limiter. Nil builds a default from Config.
|
|
Gate PushGate
|
|
// DB is the SQLite handle for the think store. Required.
|
|
DB *sql.DB
|
|
// SessionID scopes a think. Empty uses defaultSessionID.
|
|
SessionID string
|
|
// Now is the wall-clock source for decision/record timestamps. Nil uses
|
|
// time.Now, which makes time-dependent behaviour injectable in tests.
|
|
Now func() time.Time
|
|
// Log is the base logger, tagged with source "services/backthink".
|
|
Log *slog.Logger
|
|
}
|
|
|
|
// Backthink is the autonomous background-think service. Build it with New, start
|
|
// the heartbeat and subscriptions with Start, and stop them with Stop.
|
|
type Backthink struct {
|
|
cfg Config
|
|
deps Deps
|
|
log *slog.Logger
|
|
store *Store
|
|
pusher Pusher
|
|
now func() time.Time
|
|
sessionID string
|
|
|
|
mu sync.Mutex
|
|
running bool
|
|
cancel context.CancelFunc
|
|
subs []func()
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// New builds a Backthink from cfg and deps, applying defaults and validating the
|
|
// required dependencies. It creates the SQLite think store (via Store) up front
|
|
// and resolves the push path (an injected Pusher, or a default built from
|
|
// Active/IM and the rate Gate). It returns ErrNilDB when DB is nil and
|
|
// ErrNoThinker when Thinker is nil.
|
|
func New(cfg Config, deps Deps) (*Backthink, error) {
|
|
if deps.Thinker == nil {
|
|
return nil, ErrNoThinker
|
|
}
|
|
if deps.DB == nil {
|
|
return nil, ErrNilDB
|
|
}
|
|
cfg = normalize(cfg)
|
|
|
|
log := logger.WithSource(baseLog(deps.Log), "services/backthink")
|
|
store, err := NewStore(context.Background(), deps.DB, log)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("backthink: new: %w", err)
|
|
}
|
|
|
|
pusher := deps.Pusher
|
|
if pusher == nil {
|
|
gate := deps.Gate
|
|
if gate == nil {
|
|
gate = newRateGate(cfg.MaxPushWindow, cfg.PushWindow, log)
|
|
}
|
|
pusher = newPusher(deps.Active, deps.IM, gate, log)
|
|
}
|
|
|
|
now := deps.Now
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
sessionID := deps.SessionID
|
|
if sessionID == "" {
|
|
sessionID = defaultSessionID
|
|
}
|
|
|
|
return &Backthink{
|
|
cfg: cfg,
|
|
deps: deps,
|
|
log: log,
|
|
store: store,
|
|
pusher: pusher,
|
|
now: now,
|
|
sessionID: sessionID,
|
|
}, nil
|
|
}
|
|
|
|
// Start begins the background work: the heartbeat ticker and, when a Bus is
|
|
// configured, the event subscriptions. It is idempotent and safe to call twice; a
|
|
// second call is a no-op. The provided ctx (may be nil) is the parent of all
|
|
// internal work: cancelling it stops the service exactly like Stop.
|
|
func (b *Backthink) Start(ctx context.Context) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
b.mu.Lock()
|
|
if b.running {
|
|
b.mu.Unlock()
|
|
return
|
|
}
|
|
b.running = true
|
|
cctx, cancel := context.WithCancel(ctx)
|
|
b.cancel = cancel
|
|
b.wg.Add(1)
|
|
go b.tickLoop(cctx)
|
|
b.mu.Unlock()
|
|
|
|
if b.deps.Bus != nil {
|
|
b.subscribe(cctx)
|
|
}
|
|
|
|
b.log.Info("backthink started",
|
|
"interval", b.cfg.Interval.String(),
|
|
"quiet_hours", quietHoursLabel(b.cfg.QuietStartHour, b.cfg.QuietEndHour),
|
|
"event_triggers", b.deps.Bus != nil,
|
|
"session_id", b.sessionID)
|
|
}
|
|
|
|
// Stop halts the heartbeat and unsubscribes every bus subscription, then waits
|
|
// for the in-flight thinking pass (bounded by ThinkTimeout) to drain. It is
|
|
// idempotent; a second call returns nil immediately. When ctx is cancelled before
|
|
// the drain completes it returns a wrapped context error rather than leaking the
|
|
// goroutine.
|
|
func (b *Backthink) Stop(ctx context.Context) error {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
b.mu.Lock()
|
|
if !b.running {
|
|
b.mu.Unlock()
|
|
return nil
|
|
}
|
|
b.running = false
|
|
cancel := b.cancel
|
|
subs := b.subs
|
|
b.subs = nil
|
|
b.mu.Unlock()
|
|
|
|
cancel()
|
|
for _, unsub := range subs {
|
|
unsub()
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
b.wg.Wait()
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
b.log.Info("backthink stopped")
|
|
return nil
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("backthink: stop: %w", ctx.Err())
|
|
}
|
|
}
|
|
|
|
// List returns the most recent thinking records for the M9/M10 monitor panel,
|
|
// newest first. A non-positive limit selects the store default.
|
|
func (b *Backthink) List(ctx context.Context, limit int) ([]Record, error) {
|
|
return b.store.List(ctx, limit)
|
|
}
|
|
|
|
// triggers returns the bus subjects the service watches for event-driven
|
|
// thinking. It is a method (not a package var) so no package-level global slice
|
|
// appears, per the coding constraint.
|
|
func (b *Backthink) triggers() []string {
|
|
return []string{iot.DeviceUpdateSubject, SubjectReminderTrigger, SubjectSystemEvent}
|
|
}
|
|
|
|
// subscribe registers one handler per trigger subject. Each subscription is
|
|
// bound to ctx so cancelling it removes the registration; the unregister
|
|
// functions are kept so Stop can also tear them down explicitly.
|
|
func (b *Backthink) subscribe(ctx context.Context) {
|
|
for _, subj := range b.triggers() {
|
|
unsub, err := b.deps.Bus.Subscribe(ctx, subj, b.onEvent)
|
|
if err != nil {
|
|
b.log.Error("backthink subscribe failed", "subject", subj, "error", err)
|
|
continue
|
|
}
|
|
b.mu.Lock()
|
|
b.subs = append(b.subs, unsub)
|
|
b.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// onEvent is the bus handler for event-triggered thinking. It runs on the bus's
|
|
// own worker goroutine, so it must not block on another subscriber.
|
|
func (b *Backthink) onEvent(ctx context.Context, e bus.Event) error {
|
|
b.think(ctx, triggerForEvent(e))
|
|
return nil
|
|
}
|
|
|
|
// tickLoop is the heartbeat goroutine. It fires one thinking pass per tick and
|
|
// exits when ctx is cancelled.
|
|
func (b *Backthink) tickLoop(ctx context.Context) {
|
|
defer b.wg.Done()
|
|
ticker := time.NewTicker(b.cfg.Interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
b.think(ctx, heartbeatTrigger())
|
|
}
|
|
}
|
|
}
|
|
|
|
// think runs one unattended thinking pass: invoke the runtime, read the user
|
|
// state, decide whether to interrupt, push when warranted and persist the record.
|
|
// It is concurrency-safe provided the injected Thinker, StateProvider and Pusher
|
|
// are concurrency-safe, which they are by contract.
|
|
func (b *Backthink) think(parent context.Context, tr Trigger) {
|
|
at := b.now()
|
|
cctx, cancel := context.WithTimeout(parent, b.cfg.ThinkTimeout)
|
|
defer cancel()
|
|
|
|
b.log.Debug("backthink trigger", "source", tr.Source, "summary", tr.Summary)
|
|
|
|
conclusion, err := b.deps.Thinker.Think(cctx, b.sessionID, tr.Summary)
|
|
if err != nil {
|
|
b.log.Error("backthink think failed", "source", tr.Source, "error", err)
|
|
b.record(Record{
|
|
Trigger: tr.Source,
|
|
At: at,
|
|
Conclusion: fmt.Sprintf("thinking failed: %v", err),
|
|
ShouldPush: false,
|
|
PushResult: PushResult{Route: RouteNone, Detail: err.Error()},
|
|
})
|
|
return
|
|
}
|
|
|
|
state := b.currentState(cctx)
|
|
decision := b.Decide(DecisionInput{Trigger: tr.Source, Import: tr.Import, At: at, UserState: state})
|
|
|
|
pushRes := PushResult{Route: RouteNone, Detail: decision.Reason}
|
|
if decision.ShouldPush {
|
|
pushRes = b.pusher.Push(cctx, b.buildProactiveMessage(conclusion))
|
|
}
|
|
|
|
b.record(Record{
|
|
Trigger: tr.Source,
|
|
At: at,
|
|
Conclusion: conclusion,
|
|
ShouldPush: decision.ShouldPush,
|
|
PushResult: pushRes,
|
|
})
|
|
|
|
if decision.ShouldPush {
|
|
b.log.Info("backthink proactive push", "source", tr.Source, "route", pushRes.Route, "pushed", pushRes.Pushed, "reason", decision.Reason)
|
|
} else {
|
|
b.log.Debug("backthink did not interrupt", "source", tr.Source, "reason", decision.Reason)
|
|
}
|
|
}
|
|
|
|
// currentState reads the user's current condition for the interrupt decision,
|
|
// falling back to a neutral state (and a warn log) when the provider errors.
|
|
func (b *Backthink) currentState(ctx context.Context) UserState {
|
|
if b.deps.State == nil {
|
|
return UserState{}
|
|
}
|
|
s, err := b.deps.State.Current(ctx)
|
|
if err != nil {
|
|
b.log.Warn("backthink state provider failed", "error", err)
|
|
return UserState{}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// record persists a thinking pass. It uses a background context so an audit row
|
|
// is written even when the think's own context has already been cancelled.
|
|
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.
|
|
func normalize(cfg Config) Config {
|
|
if cfg.IntervalSec <= 0 {
|
|
cfg.IntervalSec = defaultIntervalSec
|
|
}
|
|
if cfg.Interval <= 0 {
|
|
cfg.Interval = time.Duration(cfg.IntervalSec) * time.Second
|
|
}
|
|
if cfg.QuietStartHour == 0 && cfg.QuietEndHour == 0 {
|
|
cfg.QuietStartHour = defaultQuietStartHour
|
|
cfg.QuietEndHour = defaultQuietEndHour
|
|
}
|
|
if cfg.MaxPushWindow <= 0 {
|
|
cfg.MaxPushWindow = defaultMaxPushWindow
|
|
}
|
|
if cfg.PushWindow <= 0 {
|
|
cfg.PushWindow = defaultPushWindow
|
|
}
|
|
if cfg.ThinkTimeout <= 0 {
|
|
cfg.ThinkTimeout = defaultThinkTimeout
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// quietHoursLabel renders the do-not-disturb window for logs.
|
|
func quietHoursLabel(start, end int) string {
|
|
return fmt.Sprintf("%02d:00-%02d:00", start, end)
|
|
}
|
|
|
|
// baseLog returns l when non-nil, otherwise a discard logger, so New always has a
|
|
// safe logger to tag.
|
|
func baseLog(l *slog.Logger) *slog.Logger {
|
|
if l != nil {
|
|
return l
|
|
}
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|