Files
goclaw/internal/channels/instance_loader.go
T
viettranx 1ddd2e547a feat(compaction): configurable provider, model & max_tokens for pending message compaction
Add Provider, Model, MaxTokens to PendingCompactionConfig so users can
override the LLM used for pending message summarization via the config
UI. Falls back to agent's provider/model when not set. Increase default
max_tokens from 512 to 4096. Add allowEmpty prop to ProviderModelSelect
to prevent auto-selecting first provider when empty means "use default".
2026-03-10 14:27:15 +07:00

295 lines
8.9 KiB
Go

package channels
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"maps"
"sync"
"time"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// ChannelFactory creates a Channel from DB instance data.
// name: channel name (registered in Manager, used in session keys).
// creds: decrypted credentials JSON (token, API keys, etc.).
// cfg: non-secret config JSONB (dm_policy, dm_stream, group_stream, etc.).
type ChannelFactory func(name string, creds json.RawMessage, cfg json.RawMessage,
msgBus *bus.MessageBus, pairingSvc store.PairingStore) (Channel, error)
// InstanceLoader loads channel instances from the database and registers them with the Manager.
// Follows the DynamicToolLoader pattern: LoadAll at startup, Reload on cache invalidation.
type InstanceLoader struct {
store store.ChannelInstanceStore
agentStore store.AgentStore
providerReg *providers.Registry
pendingCompactCfg *config.PendingCompactionConfig
factories map[string]ChannelFactory
manager *Manager
msgBus *bus.MessageBus
pairingSvc store.PairingStore
mu sync.Mutex
loaded map[string]struct{} // channel names managed by this loader
}
// NewInstanceLoader creates a new InstanceLoader.
func NewInstanceLoader(
s store.ChannelInstanceStore,
agentStore store.AgentStore,
mgr *Manager,
msgBus *bus.MessageBus,
pairingSvc store.PairingStore,
) *InstanceLoader {
return &InstanceLoader{
store: s,
agentStore: agentStore,
factories: make(map[string]ChannelFactory),
manager: mgr,
msgBus: msgBus,
pairingSvc: pairingSvc,
loaded: make(map[string]struct{}),
}
}
// SetProviderRegistry sets the provider registry for pending message compaction.
// Must be called before LoadAll/Reload.
func (l *InstanceLoader) SetProviderRegistry(reg *providers.Registry) {
l.providerReg = reg
}
// SetPendingCompactionConfig sets the global pending message compaction thresholds.
// Must be called before LoadAll/Reload.
func (l *InstanceLoader) SetPendingCompactionConfig(cfg *config.PendingCompactionConfig) {
l.pendingCompactCfg = cfg
}
// RegisterFactory registers a factory for a channel type (e.g., "telegram", "discord").
func (l *InstanceLoader) RegisterFactory(channelType string, factory ChannelFactory) {
l.factories[channelType] = factory
}
// LoadAll loads all enabled channel instances from the database, creates channels, and registers them.
func (l *InstanceLoader) LoadAll(ctx context.Context) error {
l.mu.Lock()
defer l.mu.Unlock()
instances, err := l.store.ListEnabled(ctx)
if err != nil {
return err
}
registered := 0
for _, inst := range instances {
// Don't start channels here — StartAll() will start them after all channels are registered.
if err := l.loadInstance(ctx, inst, false); err != nil {
slog.Error("failed to load channel instance",
"name", inst.Name, "type", inst.ChannelType, "error", err)
continue
}
registered++
}
if registered > 0 {
slog.Info("channel instances loaded from DB", "count", registered)
}
return nil
}
// Reload stops all managed channels, reloads from DB, and starts new ones.
// Called on cache invalidation events.
func (l *InstanceLoader) Reload(ctx context.Context) {
l.mu.Lock()
defer l.mu.Unlock()
// Stop and unregister old channels
for name := range l.loaded {
if ch, ok := l.manager.GetChannel(name); ok {
if err := ch.Stop(ctx); err != nil {
slog.Warn("failed to stop channel instance on reload", "name", name, "error", err)
}
}
l.manager.UnregisterChannel(name)
}
l.loaded = make(map[string]struct{})
// Brief pause to let external APIs (e.g., Telegram getUpdates) release polling locks.
time.Sleep(500 * time.Millisecond)
// Reload from DB
instances, err := l.store.ListEnabled(ctx)
if err != nil {
slog.Error("failed to reload channel instances", "error", err)
return
}
registered := 0
for _, inst := range instances {
// Reload must start channels immediately (StartAll was called at boot, not again).
if err := l.loadInstance(ctx, inst, true); err != nil {
slog.Error("failed to reload channel instance",
"name", inst.Name, "type", inst.ChannelType, "error", err)
continue
}
registered++
}
slog.Info("channel instances reloaded", "count", registered)
}
// Stop stops all managed channels.
func (l *InstanceLoader) Stop(ctx context.Context) {
l.mu.Lock()
defer l.mu.Unlock()
for name := range l.loaded {
if ch, ok := l.manager.GetChannel(name); ok {
if err := ch.Stop(ctx); err != nil {
slog.Warn("failed to stop channel instance", "name", name, "error", err)
}
}
l.manager.UnregisterChannel(name)
}
l.loaded = make(map[string]struct{})
}
// coerceStringBools converts string "true"/"false" values to JSON booleans
// in a raw config blob. Older UI versions saved select-based bool fields as strings.
func coerceStringBools(data json.RawMessage) json.RawMessage {
if len(data) == 0 {
return data
}
var m map[string]any
if json.Unmarshal(data, &m) != nil {
return data
}
changed := false
for k, v := range m {
if s, ok := v.(string); ok {
switch s {
case "true":
m[k] = true
changed = true
case "false":
m[k] = false
changed = true
}
}
}
if !changed {
return data
}
out, _ := json.Marshal(m)
return out
}
// LoadedNames returns the set of channel names managed by the loader.
func (l *InstanceLoader) LoadedNames() map[string]struct{} {
l.mu.Lock()
defer l.mu.Unlock()
result := make(map[string]struct{}, len(l.loaded))
maps.Copy(result, l.loaded)
return result
}
// loadInstance creates and registers a single channel from a DB instance (caller must hold lock).
// If autoStart is true, the channel is started immediately (used by Reload).
// If false, the caller is responsible for starting (used by LoadAll, where StartAll handles it).
func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelInstanceData, autoStart bool) error {
factory, ok := l.factories[inst.ChannelType]
if !ok {
slog.Warn("no factory for channel type", "type", inst.ChannelType, "name", inst.Name)
return nil
}
// Normalize config: convert string "true"/"false" to JSON booleans.
// Older UI versions saved select-based bool fields as strings.
cfg := coerceStringBools(inst.Config)
ch, err := factory(inst.Name, inst.Credentials, cfg, l.msgBus, l.pairingSvc)
if err != nil {
return err
}
if ch == nil {
slog.Info("channel instance not ready (missing credentials)", "name", inst.Name, "type", inst.ChannelType)
return nil
}
// Resolve agent_key from UUID — the routing system (Router, session keys) uses agent_key, not UUID.
var ag *store.AgentData
if base, ok := ch.(interface{ SetAgentID(string) }); ok {
var err error
ag, err = l.agentStore.GetByID(ctx, inst.AgentID)
if err != nil {
return fmt.Errorf("agent %s not found for channel %s: %w", inst.AgentID, inst.Name, err)
}
base.SetAgentID(ag.AgentKey)
}
// Set the platform type on the channel so Manager.ChannelTypeForName can read it.
if base, ok := ch.(interface{ SetType(string) }); ok {
base.SetType(inst.ChannelType)
}
// Wire pending message auto-compaction.
// Priority: config provider/model > agent's provider/model > fallback.
if pc, ok := ch.(PendingCompactable); ok && l.providerReg != nil {
var p providers.Provider
var model string
// Try config-level provider/model first.
if l.pendingCompactCfg != nil && l.pendingCompactCfg.Provider != "" {
if cp, err := l.providerReg.Get(l.pendingCompactCfg.Provider); err == nil {
p = cp
model = l.pendingCompactCfg.Model
if model == "" {
model = cp.DefaultModel()
}
}
}
// Fallback: agent's provider/model.
if p == nil && ag != nil && ag.Provider != "" {
if ap, err := l.providerReg.Get(ag.Provider); err == nil {
p = ap
model = ag.Model
if model == "" {
model = ap.DefaultModel()
}
}
}
if p != nil && model != "" {
cc := &CompactionConfig{
Provider: p,
Model: model,
}
if l.pendingCompactCfg != nil {
cc.Threshold = l.pendingCompactCfg.Threshold
cc.KeepRecent = l.pendingCompactCfg.KeepRecent
cc.MaxTokens = l.pendingCompactCfg.MaxTokens
}
pc.SetPendingCompaction(cc)
slog.Debug("pending compaction configured", "channel", inst.Name, "provider", p.Name(), "model", model,
"threshold", cc.Threshold, "keep_recent", cc.KeepRecent, "max_tokens", cc.MaxTokens)
}
}
l.manager.RegisterChannel(inst.Name, ch)
l.loaded[inst.Name] = struct{}{}
// Start the channel if requested (Reload path). LoadAll defers to StartAll.
if autoStart {
if err := ch.Start(ctx); err != nil {
slog.Error("channel instance start failed", "name", inst.Name, "error", err)
// Still registered — will show as not running.
}
}
slog.Info("channel instance loaded",
"name", inst.Name, "type", inst.ChannelType, "agent_id", inst.AgentID)
return nil
}