Files
Cyrene_v2/internal/services/backthink/backthink.go
T
Aska 9b207a31e8
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 推至后续版本。
2026-09-06 15:53:48 +08:00

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))
}