2026-02-25 07:47:45 +00:00
|
|
|
package gateway
|
2026-02-18 17:03:34 +00:00
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"os"
|
|
|
|
|
"os/signal"
|
|
|
|
|
"path/filepath"
|
2026-03-13 06:27:46 +00:00
|
|
|
"sync"
|
2026-03-17 01:35:52 +00:00
|
|
|
"syscall"
|
2026-02-18 17:03:34 +00:00
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/agent"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/channels"
|
2026-02-21 08:35:56 +00:00
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
|
2026-02-22 19:47:12 +00:00
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
2026-02-21 08:35:56 +00:00
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
2026-03-05 15:03:10 +00:00
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/irc"
|
2026-02-21 08:35:56 +00:00
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
|
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
2026-03-07 17:44:24 +00:00
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/matrix"
|
2026-02-21 08:35:56 +00:00
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
|
2026-02-22 20:55:15 +00:00
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
|
2026-02-21 08:35:56 +00:00
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
|
2026-02-22 19:47:12 +00:00
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/slack"
|
|
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/telegram"
|
2026-02-21 08:35:56 +00:00
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
|
|
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
|
2026-02-27 06:35:52 +00:00
|
|
|
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native"
|
2026-02-19 16:12:01 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
2026-02-18 17:03:34 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/cron"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/devices"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/health"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/heartbeat"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
2026-02-22 15:27:55 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/media"
|
2026-02-18 17:03:34 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/providers"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/state"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/tools"
|
2026-03-01 08:31:04 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/voice"
|
2026-02-18 17:03:34 +00:00
|
|
|
)
|
|
|
|
|
|
2026-03-13 06:27:46 +00:00
|
|
|
const (
|
|
|
|
|
serviceShutdownTimeout = 30 * time.Second
|
|
|
|
|
providerReloadTimeout = 30 * time.Second
|
|
|
|
|
gracefulShutdownTimeout = 15 * time.Second
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
type services struct {
|
2026-03-13 06:27:46 +00:00
|
|
|
CronService *cron.CronService
|
|
|
|
|
HeartbeatService *heartbeat.HeartbeatService
|
|
|
|
|
MediaStore media.MediaStore
|
|
|
|
|
ChannelManager *channels.Manager
|
|
|
|
|
DeviceService *devices.Service
|
|
|
|
|
HealthServer *health.Server
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
type startupBlockedProvider struct {
|
|
|
|
|
reason string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *startupBlockedProvider) Chat(
|
|
|
|
|
_ context.Context,
|
|
|
|
|
_ []providers.Message,
|
|
|
|
|
_ []providers.ToolDefinition,
|
|
|
|
|
_ string,
|
|
|
|
|
_ map[string]any,
|
|
|
|
|
) (*providers.LLMResponse, error) {
|
|
|
|
|
return nil, fmt.Errorf("%s", p.reason)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *startupBlockedProvider) GetDefaultModel() string {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Run starts the gateway runtime using the configuration loaded from configPath.
|
|
|
|
|
func Run(debug bool, configPath string, allowEmptyStartup bool) error {
|
2026-02-25 07:47:45 +00:00
|
|
|
if debug {
|
|
|
|
|
logger.SetLevel(logger.DEBUG)
|
|
|
|
|
fmt.Println("🔍 Debug mode enabled")
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
cfg, err := config.LoadConfig(configPath)
|
2026-02-18 17:03:34 +00:00
|
|
|
if err != nil {
|
2026-02-25 07:47:45 +00:00
|
|
|
return fmt.Errorf("error loading config: %w", err)
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup)
|
2026-02-18 17:03:34 +00:00
|
|
|
if err != nil {
|
2026-02-25 07:47:45 +00:00
|
|
|
return fmt.Errorf("error creating provider: %w", err)
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
2026-02-25 07:47:45 +00:00
|
|
|
|
2026-02-18 17:03:34 +00:00
|
|
|
if modelID != "" {
|
2026-02-23 08:55:06 +00:00
|
|
|
cfg.Agents.Defaults.ModelName = modelID
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
msgBus := bus.NewMessageBus()
|
|
|
|
|
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
|
|
|
|
|
|
|
|
|
fmt.Println("\n📦 Agent Status:")
|
|
|
|
|
startupInfo := agentLoop.GetStartupInfo()
|
2026-02-20 18:03:11 +00:00
|
|
|
toolsInfo := startupInfo["tools"].(map[string]any)
|
|
|
|
|
skillsInfo := startupInfo["skills"].(map[string]any)
|
2026-02-18 17:03:34 +00:00
|
|
|
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
|
2026-03-17 10:46:00 +00:00
|
|
|
fmt.Printf(" • Skills: %d/%d available\n", skillsInfo["available"], skillsInfo["total"])
|
2026-02-18 17:03:34 +00:00
|
|
|
|
|
|
|
|
logger.InfoCF("agent", "Agent initialized",
|
2026-02-20 18:03:11 +00:00
|
|
|
map[string]any{
|
2026-02-18 17:03:34 +00:00
|
|
|
"tools_count": toolsInfo["count"],
|
|
|
|
|
"skills_total": skillsInfo["total"],
|
|
|
|
|
"skills_available": skillsInfo["available"],
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus)
|
2026-03-13 06:27:46 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
|
|
|
|
fmt.Println("Press Ctrl+C to stop")
|
|
|
|
|
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
go agentLoop.Run(ctx)
|
|
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
var configReloadChan <-chan *config.Config
|
|
|
|
|
stopWatch := func() {}
|
|
|
|
|
if cfg.Gateway.HotReload {
|
|
|
|
|
configReloadChan, stopWatch = setupConfigWatcherPolling(configPath, debug)
|
|
|
|
|
logger.Info("Config hot reload enabled")
|
|
|
|
|
}
|
2026-03-13 06:27:46 +00:00
|
|
|
defer stopWatch()
|
|
|
|
|
|
|
|
|
|
sigChan := make(chan os.Signal, 1)
|
2026-03-17 01:35:52 +00:00
|
|
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
2026-03-13 06:27:46 +00:00
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-sigChan:
|
|
|
|
|
logger.Info("Shutting down...")
|
2026-03-17 10:46:00 +00:00
|
|
|
shutdownGateway(runningServices, agentLoop, provider, true)
|
2026-03-13 06:27:46 +00:00
|
|
|
return nil
|
|
|
|
|
case newCfg := <-configReloadChan:
|
2026-03-17 10:46:00 +00:00
|
|
|
err := handleConfigReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup)
|
2026-03-13 06:27:46 +00:00
|
|
|
if err != nil {
|
|
|
|
|
logger.Errorf("Config reload failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
func createStartupProvider(
|
|
|
|
|
cfg *config.Config,
|
|
|
|
|
allowEmptyStartup bool,
|
|
|
|
|
) (providers.LLMProvider, string, error) {
|
|
|
|
|
modelName := cfg.Agents.Defaults.GetModelName()
|
|
|
|
|
if modelName == "" && allowEmptyStartup {
|
|
|
|
|
reason := "no default model configured; gateway started in limited mode"
|
|
|
|
|
fmt.Printf("⚠ Warning: %s\n", reason)
|
|
|
|
|
logger.WarnCF("gateway", "Gateway started without default model", map[string]any{
|
|
|
|
|
"limited_mode": true,
|
|
|
|
|
})
|
|
|
|
|
return &startupBlockedProvider{reason: reason}, "", nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return providers.CreateProvider(cfg)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 06:27:46 +00:00
|
|
|
func setupAndStartServices(
|
|
|
|
|
cfg *config.Config,
|
|
|
|
|
agentLoop *agent.AgentLoop,
|
|
|
|
|
msgBus *bus.MessageBus,
|
2026-03-17 10:46:00 +00:00
|
|
|
) (*services, error) {
|
|
|
|
|
runningServices := &services{}
|
2026-03-13 06:27:46 +00:00
|
|
|
|
2026-02-18 17:03:34 +00:00
|
|
|
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
2026-03-17 01:35:52 +00:00
|
|
|
var err error
|
2026-03-17 10:46:00 +00:00
|
|
|
runningServices.CronService, err = setupCronTool(
|
2026-02-20 18:03:11 +00:00
|
|
|
agentLoop,
|
|
|
|
|
msgBus,
|
|
|
|
|
cfg.WorkspacePath(),
|
|
|
|
|
cfg.Agents.Defaults.RestrictToWorkspace,
|
|
|
|
|
execTimeout,
|
|
|
|
|
cfg,
|
|
|
|
|
)
|
2026-03-17 01:35:52 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("error setting up cron service: %w", err)
|
|
|
|
|
}
|
2026-03-17 10:46:00 +00:00
|
|
|
if err = runningServices.CronService.Start(); err != nil {
|
2026-03-13 06:27:46 +00:00
|
|
|
return nil, fmt.Errorf("error starting cron service: %w", err)
|
|
|
|
|
}
|
|
|
|
|
fmt.Println("✓ Cron service started")
|
2026-02-18 17:03:34 +00:00
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
runningServices.HeartbeatService = heartbeat.NewHeartbeatService(
|
2026-02-18 17:03:34 +00:00
|
|
|
cfg.WorkspacePath(),
|
|
|
|
|
cfg.Heartbeat.Interval,
|
|
|
|
|
cfg.Heartbeat.Enabled,
|
|
|
|
|
)
|
2026-03-17 10:46:00 +00:00
|
|
|
runningServices.HeartbeatService.SetBus(msgBus)
|
|
|
|
|
runningServices.HeartbeatService.SetHandler(createHeartbeatHandler(agentLoop))
|
|
|
|
|
if err = runningServices.HeartbeatService.Start(); err != nil {
|
2026-03-13 06:27:46 +00:00
|
|
|
return nil, fmt.Errorf("error starting heartbeat service: %w", err)
|
|
|
|
|
}
|
|
|
|
|
fmt.Println("✓ Heartbeat service started")
|
2026-02-18 17:03:34 +00:00
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
runningServices.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
|
2026-02-24 12:24:32 +00:00
|
|
|
Enabled: cfg.Tools.MediaCleanup.Enabled,
|
|
|
|
|
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
|
|
|
|
|
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
|
|
|
|
|
})
|
2026-03-17 10:46:00 +00:00
|
|
|
if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {
|
2026-03-13 06:27:46 +00:00
|
|
|
fms.Start()
|
|
|
|
|
}
|
2026-02-22 15:27:55 +00:00
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore)
|
2026-02-18 17:03:34 +00:00
|
|
|
if err != nil {
|
2026-03-17 10:46:00 +00:00
|
|
|
if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {
|
2026-03-13 06:27:46 +00:00
|
|
|
fms.Stop()
|
|
|
|
|
}
|
|
|
|
|
return nil, fmt.Errorf("error creating channel manager: %w", err)
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
agentLoop.SetChannelManager(runningServices.ChannelManager)
|
|
|
|
|
agentLoop.SetMediaStore(runningServices.MediaStore)
|
2026-02-18 17:03:34 +00:00
|
|
|
|
2026-03-01 21:02:16 +00:00
|
|
|
if transcriber := voice.DetectTranscriber(cfg); transcriber != nil {
|
2026-03-01 08:31:04 +00:00
|
|
|
agentLoop.SetTranscriber(transcriber)
|
2026-03-01 21:02:16 +00:00
|
|
|
logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
|
2026-03-01 08:31:04 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
enabledChannels := runningServices.ChannelManager.GetEnabledChannels()
|
2026-02-18 17:03:34 +00:00
|
|
|
if len(enabledChannels) > 0 {
|
|
|
|
|
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
|
|
|
|
} else {
|
|
|
|
|
fmt.Println("⚠ Warning: No channels enabled")
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 06:27:46 +00:00
|
|
|
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
|
2026-03-17 10:46:00 +00:00
|
|
|
runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
|
|
|
|
runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer)
|
2026-02-18 17:03:34 +00:00
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
if err = runningServices.ChannelManager.StartAll(context.Background()); err != nil {
|
2026-03-13 06:27:46 +00:00
|
|
|
return nil, fmt.Errorf("error starting channels: %w", err)
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-13 06:27:46 +00:00
|
|
|
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
2026-02-18 17:03:34 +00:00
|
|
|
|
|
|
|
|
stateManager := state.NewManager(cfg.WorkspacePath())
|
2026-03-17 10:46:00 +00:00
|
|
|
runningServices.DeviceService = devices.NewService(devices.Config{
|
2026-02-18 17:03:34 +00:00
|
|
|
Enabled: cfg.Devices.Enabled,
|
|
|
|
|
MonitorUSB: cfg.Devices.MonitorUSB,
|
|
|
|
|
}, stateManager)
|
2026-03-17 10:46:00 +00:00
|
|
|
runningServices.DeviceService.SetBus(msgBus)
|
|
|
|
|
if err = runningServices.DeviceService.Start(context.Background()); err != nil {
|
2026-03-13 06:27:46 +00:00
|
|
|
logger.ErrorCF("device", "Error starting device service", map[string]any{"error": err.Error()})
|
2026-02-18 17:03:34 +00:00
|
|
|
} else if cfg.Devices.Enabled {
|
|
|
|
|
fmt.Println("✓ Device event service started")
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
return runningServices, nil
|
2026-03-13 06:27:46 +00:00
|
|
|
}
|
2026-02-18 17:03:34 +00:00
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
func stopAndCleanupServices(runningServices *services, shutdownTimeout time.Duration) {
|
2026-03-13 06:27:46 +00:00
|
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
|
|
|
|
defer shutdownCancel()
|
2026-02-18 17:03:34 +00:00
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
if runningServices.ChannelManager != nil {
|
|
|
|
|
runningServices.ChannelManager.StopAll(shutdownCtx)
|
2026-03-13 06:27:46 +00:00
|
|
|
}
|
2026-03-17 10:46:00 +00:00
|
|
|
if runningServices.DeviceService != nil {
|
|
|
|
|
runningServices.DeviceService.Stop()
|
2026-03-13 06:27:46 +00:00
|
|
|
}
|
2026-03-17 10:46:00 +00:00
|
|
|
if runningServices.HeartbeatService != nil {
|
|
|
|
|
runningServices.HeartbeatService.Stop()
|
2026-03-13 06:27:46 +00:00
|
|
|
}
|
2026-03-17 10:46:00 +00:00
|
|
|
if runningServices.CronService != nil {
|
|
|
|
|
runningServices.CronService.Stop()
|
2026-03-13 06:27:46 +00:00
|
|
|
}
|
2026-03-17 10:46:00 +00:00
|
|
|
if runningServices.MediaStore != nil {
|
|
|
|
|
if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {
|
2026-03-13 06:27:46 +00:00
|
|
|
fms.Stop()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-18 17:03:34 +00:00
|
|
|
|
2026-03-13 06:27:46 +00:00
|
|
|
func shutdownGateway(
|
2026-03-17 10:46:00 +00:00
|
|
|
runningServices *services,
|
2026-03-13 06:27:46 +00:00
|
|
|
agentLoop *agent.AgentLoop,
|
|
|
|
|
provider providers.LLMProvider,
|
|
|
|
|
fullShutdown bool,
|
|
|
|
|
) {
|
|
|
|
|
if cp, ok := provider.(providers.StatefulProvider); ok && fullShutdown {
|
2026-02-22 14:30:53 +00:00
|
|
|
cp.Close()
|
|
|
|
|
}
|
2026-02-22 22:03:23 +00:00
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
stopAndCleanupServices(runningServices, gracefulShutdownTimeout)
|
2026-02-22 22:03:23 +00:00
|
|
|
|
2026-02-18 17:03:34 +00:00
|
|
|
agentLoop.Stop()
|
feat(session): integrate JSONL persistence into agent loop (#1170)
* feat(session): add SessionStore interface and JSONL backend adapter
Extract a SessionStore interface from the methods the agent loop uses
(AddMessage, GetHistory, SetSummary, TruncateHistory, Save, etc.).
Both SessionManager and the new JSONLBackend satisfy this interface,
allowing the persistence layer to be swapped transparently.
JSONLBackend wraps memory.Store and maps its error-returning API to
the fire-and-forget contract that the agent loop expects — write
errors are logged, reads return empty defaults on failure. Save()
triggers compaction to reclaim space after logical truncation.
Part of #1169
* test(session): add JSONLBackend integration tests
8 tests covering the full SessionStore contract through the JSONL
backend: message roundtrip, tool calls, summary, truncation with
compaction, history replacement, empty sessions, session isolation,
and the complete summarization flow (SetSummary → TruncateHistory →
Save).
Includes compile-time interface satisfaction checks for both
SessionManager and JSONLBackend.
Part of #1169
* feat(agent): wire JSONL session store into agent loop
Replace the concrete *SessionManager field with the SessionStore
interface and initialize the JSONL backend by default. Legacy .json
session files are auto-migrated on first startup. Falls back to
SessionManager if the JSONL store cannot be initialized.
The agent loop code (loop.go) requires zero changes — all method
calls work identically through the interface.
Closes #1169
* fix(session): propagate compact error from Save
Save() was swallowing the error returned by Compact and always
returning nil. Callers checking Save's return value would never
see a compaction failure. Return the error directly so the agent
loop can log or handle it as needed.
* feat(session): add Close to SessionStore interface
Add Close() error to SessionStore so callers can release resources
through the interface. JSONLBackend already had Close; this adds
a no-op implementation to SessionManager for compatibility.
* fix(session): close session stores on shutdown and harden migration
- Add Close() to AgentInstance, AgentRegistry, and AgentLoop so JSONL
file handles are released during gateway shutdown and CLI exit.
- Fall back to SessionManager when migration fails, preventing a split
state where some sessions live in JSONL and others remain in JSON.
- Add defer agentLoop.Close() in the CLI agent command path.
- Document SessionStore interface methods (fire-and-forget contract).
2026-03-10 07:14:09 +00:00
|
|
|
agentLoop.Close()
|
2026-03-13 06:27:46 +00:00
|
|
|
|
|
|
|
|
logger.Info("✓ Gateway stopped")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func handleConfigReload(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
al *agent.AgentLoop,
|
|
|
|
|
newCfg *config.Config,
|
|
|
|
|
providerRef *providers.LLMProvider,
|
2026-03-17 10:46:00 +00:00
|
|
|
runningServices *services,
|
2026-03-13 06:27:46 +00:00
|
|
|
msgBus *bus.MessageBus,
|
2026-03-17 10:46:00 +00:00
|
|
|
allowEmptyStartup bool,
|
2026-03-13 06:27:46 +00:00
|
|
|
) error {
|
|
|
|
|
logger.Info("🔄 Config file changed, reloading...")
|
|
|
|
|
|
|
|
|
|
newModel := newCfg.Agents.Defaults.ModelName
|
|
|
|
|
if newModel == "" {
|
|
|
|
|
newModel = newCfg.Agents.Defaults.Model
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.Infof(" New model is '%s', recreating provider...", newModel)
|
|
|
|
|
|
|
|
|
|
logger.Info(" Stopping all services...")
|
2026-03-17 10:46:00 +00:00
|
|
|
stopAndCleanupServices(runningServices, serviceShutdownTimeout)
|
2026-03-13 06:27:46 +00:00
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
newProvider, newModelID, err := createStartupProvider(newCfg, allowEmptyStartup)
|
2026-03-13 06:27:46 +00:00
|
|
|
if err != nil {
|
|
|
|
|
logger.Errorf(" ⚠ Error creating new provider: %v", err)
|
|
|
|
|
logger.Warn(" Attempting to restart services with old provider and config...")
|
2026-03-17 10:46:00 +00:00
|
|
|
if restartErr := restartServices(al, runningServices, msgBus); restartErr != nil {
|
2026-03-13 06:27:46 +00:00
|
|
|
logger.Errorf(" ⚠ Failed to restart services: %v", restartErr)
|
|
|
|
|
}
|
|
|
|
|
return fmt.Errorf("error creating new provider: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if newModelID != "" {
|
|
|
|
|
newCfg.Agents.Defaults.ModelName = newModelID
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
reloadCtx, reloadCancel := context.WithTimeout(context.Background(), providerReloadTimeout)
|
|
|
|
|
defer reloadCancel()
|
|
|
|
|
|
|
|
|
|
if err := al.ReloadProviderAndConfig(reloadCtx, newProvider, newCfg); err != nil {
|
|
|
|
|
logger.Errorf(" ⚠ Error reloading agent loop: %v", err)
|
|
|
|
|
if cp, ok := newProvider.(providers.StatefulProvider); ok {
|
|
|
|
|
cp.Close()
|
|
|
|
|
}
|
|
|
|
|
logger.Warn(" Attempting to restart services with old provider and config...")
|
2026-03-17 10:46:00 +00:00
|
|
|
if restartErr := restartServices(al, runningServices, msgBus); restartErr != nil {
|
2026-03-13 06:27:46 +00:00
|
|
|
logger.Errorf(" ⚠ Failed to restart services: %v", restartErr)
|
|
|
|
|
}
|
|
|
|
|
return fmt.Errorf("error reloading agent loop: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
*providerRef = newProvider
|
|
|
|
|
|
|
|
|
|
logger.Info(" Restarting all services with new configuration...")
|
2026-03-17 10:46:00 +00:00
|
|
|
if err := restartServices(al, runningServices, msgBus); err != nil {
|
2026-03-13 06:27:46 +00:00
|
|
|
logger.Errorf(" ⚠ Error restarting services: %v", err)
|
|
|
|
|
return fmt.Errorf("error restarting services: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.Info(" ✓ Provider, configuration, and services reloaded successfully (thread-safe)")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func restartServices(
|
|
|
|
|
al *agent.AgentLoop,
|
2026-03-17 10:46:00 +00:00
|
|
|
runningServices *services,
|
2026-03-13 06:27:46 +00:00
|
|
|
msgBus *bus.MessageBus,
|
|
|
|
|
) error {
|
|
|
|
|
cfg := al.GetConfig()
|
|
|
|
|
|
|
|
|
|
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
2026-03-17 01:35:52 +00:00
|
|
|
var err error
|
2026-03-17 10:46:00 +00:00
|
|
|
runningServices.CronService, err = setupCronTool(
|
2026-03-13 06:27:46 +00:00
|
|
|
al,
|
|
|
|
|
msgBus,
|
|
|
|
|
cfg.WorkspacePath(),
|
|
|
|
|
cfg.Agents.Defaults.RestrictToWorkspace,
|
|
|
|
|
execTimeout,
|
|
|
|
|
cfg,
|
|
|
|
|
)
|
2026-03-17 01:35:52 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error restarting cron service: %w", err)
|
|
|
|
|
}
|
2026-03-17 10:46:00 +00:00
|
|
|
if err = runningServices.CronService.Start(); err != nil {
|
2026-03-13 06:27:46 +00:00
|
|
|
return fmt.Errorf("error restarting cron service: %w", err)
|
|
|
|
|
}
|
|
|
|
|
fmt.Println(" ✓ Cron service restarted")
|
|
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
runningServices.HeartbeatService = heartbeat.NewHeartbeatService(
|
2026-03-13 06:27:46 +00:00
|
|
|
cfg.WorkspacePath(),
|
|
|
|
|
cfg.Heartbeat.Interval,
|
|
|
|
|
cfg.Heartbeat.Enabled,
|
|
|
|
|
)
|
2026-03-17 10:46:00 +00:00
|
|
|
runningServices.HeartbeatService.SetBus(msgBus)
|
|
|
|
|
runningServices.HeartbeatService.SetHandler(createHeartbeatHandler(al))
|
|
|
|
|
if err = runningServices.HeartbeatService.Start(); err != nil {
|
2026-03-13 06:27:46 +00:00
|
|
|
return fmt.Errorf("error restarting heartbeat service: %w", err)
|
|
|
|
|
}
|
|
|
|
|
fmt.Println(" ✓ Heartbeat service restarted")
|
|
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
runningServices.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
|
2026-03-13 06:27:46 +00:00
|
|
|
Enabled: cfg.Tools.MediaCleanup.Enabled,
|
|
|
|
|
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
|
|
|
|
|
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
|
|
|
|
|
})
|
2026-03-17 10:46:00 +00:00
|
|
|
if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {
|
2026-03-13 06:27:46 +00:00
|
|
|
fms.Start()
|
|
|
|
|
}
|
2026-03-17 10:46:00 +00:00
|
|
|
al.SetMediaStore(runningServices.MediaStore)
|
2026-03-13 06:27:46 +00:00
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore)
|
2026-03-13 06:27:46 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("error recreating channel manager: %w", err)
|
|
|
|
|
}
|
2026-03-17 10:46:00 +00:00
|
|
|
al.SetChannelManager(runningServices.ChannelManager)
|
2026-03-13 06:27:46 +00:00
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
enabledChannels := runningServices.ChannelManager.GetEnabledChannels()
|
2026-03-13 06:27:46 +00:00
|
|
|
if len(enabledChannels) > 0 {
|
|
|
|
|
fmt.Printf(" ✓ Channels enabled: %s\n", enabledChannels)
|
|
|
|
|
} else {
|
|
|
|
|
fmt.Println(" ⚠ Warning: No channels enabled")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
|
2026-03-17 10:46:00 +00:00
|
|
|
runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
|
|
|
|
runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer)
|
2026-03-13 06:27:46 +00:00
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
if err = runningServices.ChannelManager.StartAll(context.Background()); err != nil {
|
2026-03-13 06:27:46 +00:00
|
|
|
return fmt.Errorf("error restarting channels: %w", err)
|
|
|
|
|
}
|
|
|
|
|
fmt.Printf(
|
|
|
|
|
" ✓ Channels restarted, health endpoints at http://%s:%d/health and ready\n",
|
|
|
|
|
cfg.Gateway.Host,
|
|
|
|
|
cfg.Gateway.Port,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
stateManager := state.NewManager(cfg.WorkspacePath())
|
2026-03-17 10:46:00 +00:00
|
|
|
runningServices.DeviceService = devices.NewService(devices.Config{
|
2026-03-13 06:27:46 +00:00
|
|
|
Enabled: cfg.Devices.Enabled,
|
|
|
|
|
MonitorUSB: cfg.Devices.MonitorUSB,
|
|
|
|
|
}, stateManager)
|
2026-03-17 10:46:00 +00:00
|
|
|
runningServices.DeviceService.SetBus(msgBus)
|
|
|
|
|
if err := runningServices.DeviceService.Start(context.Background()); err != nil {
|
2026-03-13 06:27:46 +00:00
|
|
|
logger.WarnCF("device", "Failed to restart device service", map[string]any{"error": err.Error()})
|
|
|
|
|
} else if cfg.Devices.Enabled {
|
|
|
|
|
fmt.Println(" ✓ Device event service restarted")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
transcriber := voice.DetectTranscriber(cfg)
|
2026-03-17 10:46:00 +00:00
|
|
|
al.SetTranscriber(transcriber)
|
2026-03-13 06:27:46 +00:00
|
|
|
if transcriber != nil {
|
|
|
|
|
logger.InfoCF("voice", "Transcription re-enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
|
|
|
|
|
} else {
|
|
|
|
|
logger.InfoCF("voice", "Transcription disabled", nil)
|
|
|
|
|
}
|
2026-02-25 07:47:45 +00:00
|
|
|
|
|
|
|
|
return nil
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-13 06:27:46 +00:00
|
|
|
func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Config, func()) {
|
|
|
|
|
configChan := make(chan *config.Config, 1)
|
|
|
|
|
stop := make(chan struct{})
|
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
|
|
|
|
|
|
wg.Add(1)
|
|
|
|
|
go func() {
|
|
|
|
|
defer wg.Done()
|
|
|
|
|
|
|
|
|
|
lastModTime := getFileModTime(configPath)
|
|
|
|
|
lastSize := getFileSize(configPath)
|
|
|
|
|
|
2026-03-17 10:46:00 +00:00
|
|
|
ticker := time.NewTicker(2 * time.Second)
|
2026-03-13 06:27:46 +00:00
|
|
|
defer ticker.Stop()
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-ticker.C:
|
|
|
|
|
currentModTime := getFileModTime(configPath)
|
|
|
|
|
currentSize := getFileSize(configPath)
|
|
|
|
|
|
|
|
|
|
if currentModTime.After(lastModTime) || currentSize != lastSize {
|
|
|
|
|
if debug {
|
|
|
|
|
logger.Debugf("🔍 Config file change detected")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
|
|
2026-03-17 01:35:52 +00:00
|
|
|
lastModTime = currentModTime
|
|
|
|
|
lastSize = currentSize
|
|
|
|
|
|
2026-03-13 06:27:46 +00:00
|
|
|
newCfg, err := config.LoadConfig(configPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.Errorf("⚠ Error loading new config: %v", err)
|
|
|
|
|
logger.Warn(" Using previous valid config")
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := newCfg.ValidateModelList(); err != nil {
|
|
|
|
|
logger.Errorf(" ⚠ New config validation failed: %v", err)
|
|
|
|
|
logger.Warn(" Using previous valid config")
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.Info("✓ Config file validated and loaded")
|
|
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case configChan <- newCfg:
|
|
|
|
|
default:
|
|
|
|
|
logger.Warn("⚠ Previous config reload still in progress, skipping")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
case <-stop:
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
stopFunc := func() {
|
|
|
|
|
close(stop)
|
|
|
|
|
wg.Wait()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return configChan, stopFunc
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getFileModTime(path string) time.Time {
|
|
|
|
|
info, err := os.Stat(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return time.Time{}
|
|
|
|
|
}
|
|
|
|
|
return info.ModTime()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getFileSize(path string) int64 {
|
|
|
|
|
info, err := os.Stat(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
return info.Size()
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 18:03:11 +00:00
|
|
|
func setupCronTool(
|
|
|
|
|
agentLoop *agent.AgentLoop,
|
|
|
|
|
msgBus *bus.MessageBus,
|
|
|
|
|
workspace string,
|
|
|
|
|
restrict bool,
|
|
|
|
|
execTimeout time.Duration,
|
|
|
|
|
cfg *config.Config,
|
2026-03-17 01:35:52 +00:00
|
|
|
) (*cron.CronService, error) {
|
2026-02-18 17:03:34 +00:00
|
|
|
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
|
|
|
|
|
|
|
|
|
|
cronService := cron.NewCronService(cronStorePath, nil)
|
|
|
|
|
|
2026-03-05 06:53:26 +00:00
|
|
|
var cronTool *tools.CronTool
|
|
|
|
|
if cfg.Tools.IsToolEnabled("cron") {
|
|
|
|
|
var err error
|
|
|
|
|
cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
|
|
|
|
if err != nil {
|
2026-03-17 01:35:52 +00:00
|
|
|
return nil, fmt.Errorf("critical error during CronTool initialization: %w", err)
|
2026-03-05 06:53:26 +00:00
|
|
|
}
|
2026-02-28 08:24:26 +00:00
|
|
|
|
2026-03-05 06:53:26 +00:00
|
|
|
agentLoop.RegisterTool(cronTool)
|
|
|
|
|
}
|
2026-02-18 17:03:34 +00:00
|
|
|
|
2026-03-05 06:53:26 +00:00
|
|
|
if cronTool != nil {
|
|
|
|
|
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
|
|
|
|
result := cronTool.ExecuteJob(context.Background(), job)
|
|
|
|
|
return result, nil
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-02-18 17:03:34 +00:00
|
|
|
|
2026-03-17 01:35:52 +00:00
|
|
|
return cronService, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func createHeartbeatHandler(agentLoop *agent.AgentLoop) func(prompt, channel, chatID string) *tools.ToolResult {
|
|
|
|
|
return func(prompt, channel, chatID string) *tools.ToolResult {
|
|
|
|
|
if channel == "" || chatID == "" {
|
|
|
|
|
channel, chatID = "cli", "direct"
|
|
|
|
|
}
|
2026-03-17 10:46:00 +00:00
|
|
|
|
|
|
|
|
response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
|
2026-03-17 01:35:52 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
|
|
|
|
|
}
|
|
|
|
|
if response == "HEARTBEAT_OK" {
|
|
|
|
|
return tools.SilentResult("Heartbeat OK")
|
|
|
|
|
}
|
|
|
|
|
return tools.SilentResult(response)
|
|
|
|
|
}
|
2026-02-18 17:03:34 +00:00
|
|
|
}
|