fix: resolve linter errors (gci, golines, gofmt, gofumpt)

- Fix pkg/gateway/gateway.go: remove unnecessary leading/trailing newlines and fix gofumpt formatting
- Fix pkg/config/config.go: reformat long struct tags to meet golines 120-char limit
- Fix pkg/channels/mqtt/mqtt.go: fix gofmt formatting
- Fix gci import ordering across pkg/config/ and pkg/channels/mqtt/ packages

All linter errors resolved: 0 issues remaining
This commit is contained in:
avaksru 2026-03-20 17:32:42 +03:00
parent 596e6bb7d7
commit 689560001c
3 changed files with 19 additions and 412 deletions

View file

@ -203,7 +203,7 @@ func (c *MQTTChannel) onMessage(client mqtt.Client, msg mqtt.Message) {
// Check if subscribe_json_key is configured // Check if subscribe_json_key is configured
if c.config.SubscribeJSONKey != nil && *c.config.SubscribeJSONKey != "" { if c.config.SubscribeJSONKey != nil && *c.config.SubscribeJSONKey != "" {
// Parse as JSON and extract the specified key // Parse as JSON and extract the specified key
var jsonMsg map[string]interface{} var jsonMsg map[string]any
if err := json.Unmarshal(msg.Payload(), &jsonMsg); err == nil { if err := json.Unmarshal(msg.Payload(), &jsonMsg); err == nil {
// Successfully parsed as JSON // Successfully parsed as JSON
if value, exists := jsonMsg[*c.config.SubscribeJSONKey]; exists { if value, exists := jsonMsg[*c.config.SubscribeJSONKey]; exists {

View file

@ -808,9 +808,9 @@ type SearXNGConfig struct {
} }
type GLMSearchConfig struct { type GLMSearchConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"` APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"`
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"` BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"`
// SearchEngine specifies the search backend: "search_std" (default), // SearchEngine specifies the search backend: "search_std" (default),
// "search_pro", "search_pro_sogou", or "search_pro_quark". // "search_pro", "search_pro_sogou", or "search_pro_quark".
SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"` SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"`
@ -840,13 +840,13 @@ type WebToolsConfig struct {
// the client-side web_search tool is hidden to avoid duplicate search surfaces, // the client-side web_search tool is hidden to avoid duplicate search surfaces,
// and the provider's built-in search is used instead. Falls back to client-side // and the provider's built-in search is used instead. Falls back to client-side
// search when the provider does not support native search. // search when the provider does not support native search.
PreferNative bool ` json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"` PreferNative bool `json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"`
// Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h). // Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
// For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config. // For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
Proxy string ` json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"` Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
FetchLimitBytes int64 ` json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
Format string ` json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"` Format string `json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"`
PrivateHostWhitelist FlexibleStringSlice ` json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"` PrivateHostWhitelist FlexibleStringSlice `json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"`
} }
type CronToolsConfig struct { type CronToolsConfig struct {
@ -961,10 +961,10 @@ type MCPServerConfig struct {
// MCPConfig defines configuration for all MCP servers // MCPConfig defines configuration for all MCP servers
type MCPConfig struct { type MCPConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"` ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"`
Discovery ToolDiscoveryConfig ` json:"discovery"` Discovery ToolDiscoveryConfig ` json:"discovery"`
// Servers is a map of server name to server configuration // Servers is a map of server name to server configuration
Servers map[string]MCPServerConfig ` json:"servers,omitempty"` Servers map[string]MCPServerConfig `json:"servers,omitempty"`
} }
func LoadConfig(path string) (*Config, error) { func LoadConfig(path string) (*Config, error) {

View file

@ -89,23 +89,15 @@ func (p *startupBlockedProvider) GetDefaultModel() string {
} }
// Run starts the gateway runtime using the configuration loaded from configPath. // Run starts the gateway runtime using the configuration loaded from configPath.
func Run(debug bool, configPath string, allowEmptyStartup bool) error { func Run(debug bool, configPath string, allowEmptyStartup bool) error {
if debug { if debug {
logger.SetLevel(logger.DEBUG) logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled") fmt.Println("🔍 Debug mode enabled")
} }
cfg, err := config.LoadConfig(configPath) cfg, err := config.LoadConfig(configPath)
if err != nil { if err != nil {
return fmt.Errorf("error loading config: %w", err) return fmt.Errorf("error loading config: %w", err)
} }
logger.SetLevelFromString(cfg.Gateway.LogLevel) logger.SetLevelFromString(cfg.Gateway.LogLevel)
@ -116,954 +108,569 @@ func Run(debug bool, configPath string, allowEmptyStartup bool) error {
} }
provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup) provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup)
if err != nil { if err != nil {
return fmt.Errorf("error creating provider: %w", err) return fmt.Errorf("error creating provider: %w", err)
} }
if modelID != "" { if modelID != "" {
cfg.Agents.Defaults.ModelName = modelID cfg.Agents.Defaults.ModelName = modelID
} }
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
fmt.Println("\n📦 Agent Status:") fmt.Println("\n📦 Agent Status:")
startupInfo := agentLoop.GetStartupInfo() startupInfo := agentLoop.GetStartupInfo()
toolsInfo := startupInfo["tools"].(map[string]any) toolsInfo := startupInfo["tools"].(map[string]any)
skillsInfo := startupInfo["skills"].(map[string]any) skillsInfo := startupInfo["skills"].(map[string]any)
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"]) fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
fmt.Printf(" • Skills: %d/%d available\n", skillsInfo["available"], skillsInfo["total"]) fmt.Printf(" • Skills: %d/%d available\n", skillsInfo["available"], skillsInfo["total"])
logger.InfoCF("agent", "Agent initialized", logger.InfoCF("agent", "Agent initialized",
map[string]any{ map[string]any{
"tools_count": toolsInfo["count"],
"tools_count": toolsInfo["count"], "skills_total": skillsInfo["total"],
"skills_total": skillsInfo["total"],
"skills_available": skillsInfo["available"], "skills_available": skillsInfo["available"],
}) })
runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus) runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus)
if err != nil { if err != nil {
return err return err
} }
// Setup manual reload channel for /reload endpoint // Setup manual reload channel for /reload endpoint
manualReloadChan := make(chan struct{}, 1) manualReloadChan := make(chan struct{}, 1)
runningServices.manualReloadChan = manualReloadChan runningServices.manualReloadChan = manualReloadChan
reloadTrigger := func() error { reloadTrigger := func() error {
if !runningServices.reloading.CompareAndSwap(false, true) { if !runningServices.reloading.CompareAndSwap(false, true) {
return fmt.Errorf("reload already in progress") return fmt.Errorf("reload already in progress")
} }
select { select {
case manualReloadChan <- struct{}{}: case manualReloadChan <- struct{}{}:
return nil return nil
default: default:
// Should not happen, but reset flag if channel is full // Should not happen, but reset flag if channel is full
runningServices.reloading.Store(false) runningServices.reloading.Store(false)
return fmt.Errorf("reload already queued") return fmt.Errorf("reload already queued")
} }
} }
runningServices.HealthServer.SetReloadFunc(reloadTrigger) runningServices.HealthServer.SetReloadFunc(reloadTrigger)
agentLoop.SetReloadFunc(reloadTrigger) agentLoop.SetReloadFunc(reloadTrigger)
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
fmt.Println("Press Ctrl+C to stop") fmt.Println("Press Ctrl+C to stop")
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
go agentLoop.Run(ctx) go agentLoop.Run(ctx)
var configReloadChan <-chan *config.Config var configReloadChan <-chan *config.Config
stopWatch := func() {} stopWatch := func() {}
if cfg.Gateway.HotReload { if cfg.Gateway.HotReload {
configReloadChan, stopWatch = setupConfigWatcherPolling(configPath, debug) configReloadChan, stopWatch = setupConfigWatcherPolling(configPath, debug)
logger.Info("Config hot reload enabled") logger.Info("Config hot reload enabled")
} }
defer stopWatch() defer stopWatch()
sigChan := make(chan os.Signal, 1) sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
for { for {
select { select {
case <-sigChan: case <-sigChan:
logger.Info("Shutting down...") logger.Info("Shutting down...")
shutdownGateway(runningServices, agentLoop, provider, true) shutdownGateway(runningServices, agentLoop, provider, true)
return nil return nil
case newCfg := <-configReloadChan: case newCfg := <-configReloadChan:
if !runningServices.reloading.CompareAndSwap(false, true) { if !runningServices.reloading.CompareAndSwap(false, true) {
logger.Warn("Config reload skipped: another reload is in progress") logger.Warn("Config reload skipped: another reload is in progress")
continue continue
} }
err := executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup) err := executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup)
if err != nil { if err != nil {
logger.Errorf("Config reload failed: %v", err) logger.Errorf("Config reload failed: %v", err)
} }
case <-manualReloadChan: case <-manualReloadChan:
logger.Info("Manual reload triggered via /reload endpoint") logger.Info("Manual reload triggered via /reload endpoint")
newCfg, err := config.LoadConfig(configPath) newCfg, err := config.LoadConfig(configPath)
if err != nil { if err != nil {
logger.Errorf("Error loading config for manual reload: %v", err) logger.Errorf("Error loading config for manual reload: %v", err)
runningServices.reloading.Store(false) runningServices.reloading.Store(false)
continue continue
} }
if err = newCfg.ValidateModelList(); err != nil { if err = newCfg.ValidateModelList(); err != nil {
logger.Errorf("Config validation failed: %v", err) logger.Errorf("Config validation failed: %v", err)
runningServices.reloading.Store(false) runningServices.reloading.Store(false)
continue continue
} }
err = executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup) err = executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup)
if err != nil { if err != nil {
logger.Errorf("Manual reload failed: %v", err) logger.Errorf("Manual reload failed: %v", err)
} else { } else {
logger.Info("Manual reload completed successfully") logger.Info("Manual reload completed successfully")
} }
} }
} }
} }
func executeReload( func executeReload(
ctx context.Context, ctx context.Context,
agentLoop *agent.AgentLoop, agentLoop *agent.AgentLoop,
newCfg *config.Config, newCfg *config.Config,
provider *providers.LLMProvider, provider *providers.LLMProvider,
runningServices *services, runningServices *services,
msgBus *bus.MessageBus, msgBus *bus.MessageBus,
allowEmptyStartup bool, allowEmptyStartup bool,
) error { ) error {
defer runningServices.reloading.Store(false) defer runningServices.reloading.Store(false)
return handleConfigReload(ctx, agentLoop, newCfg, provider, runningServices, msgBus, allowEmptyStartup) return handleConfigReload(ctx, agentLoop, newCfg, provider, runningServices, msgBus, allowEmptyStartup)
} }
func createStartupProvider( func createStartupProvider(
cfg *config.Config, cfg *config.Config,
allowEmptyStartup bool, allowEmptyStartup bool,
) (providers.LLMProvider, string, error) { ) (providers.LLMProvider, string, error) {
modelName := cfg.Agents.Defaults.GetModelName() modelName := cfg.Agents.Defaults.GetModelName()
if modelName == "" && allowEmptyStartup { if modelName == "" && allowEmptyStartup {
reason := "no default model configured; gateway started in limited mode" reason := "no default model configured; gateway started in limited mode"
fmt.Printf("⚠ Warning: %s\n", reason) fmt.Printf("⚠ Warning: %s\n", reason)
logger.WarnCF("gateway", "Gateway started without default model", map[string]any{ logger.WarnCF("gateway", "Gateway started without default model", map[string]any{
"limited_mode": true, "limited_mode": true,
}) })
return &startupBlockedProvider{reason: reason}, "", nil return &startupBlockedProvider{reason: reason}, "", nil
} }
return providers.CreateProvider(cfg) return providers.CreateProvider(cfg)
} }
func setupAndStartServices( func setupAndStartServices(
cfg *config.Config, cfg *config.Config,
agentLoop *agent.AgentLoop, agentLoop *agent.AgentLoop,
msgBus *bus.MessageBus, msgBus *bus.MessageBus,
) (*services, error) { ) (*services, error) {
runningServices := &services{} runningServices := &services{}
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
var err error var err error
runningServices.CronService, err = setupCronTool( runningServices.CronService, err = setupCronTool(
agentLoop, agentLoop,
msgBus, msgBus,
cfg.WorkspacePath(), cfg.WorkspacePath(),
cfg.Agents.Defaults.RestrictToWorkspace, cfg.Agents.Defaults.RestrictToWorkspace,
execTimeout, execTimeout,
cfg, cfg,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("error setting up cron service: %w", err) return nil, fmt.Errorf("error setting up cron service: %w", err)
} }
if err = runningServices.CronService.Start(); err != nil { if err = runningServices.CronService.Start(); err != nil {
return nil, fmt.Errorf("error starting cron service: %w", err) return nil, fmt.Errorf("error starting cron service: %w", err)
} }
fmt.Println("✓ Cron service started") fmt.Println("✓ Cron service started")
runningServices.HeartbeatService = heartbeat.NewHeartbeatService( runningServices.HeartbeatService = heartbeat.NewHeartbeatService(
cfg.WorkspacePath(), cfg.WorkspacePath(),
cfg.Heartbeat.Interval, cfg.Heartbeat.Interval,
cfg.Heartbeat.Enabled, cfg.Heartbeat.Enabled,
) )
runningServices.HeartbeatService.SetBus(msgBus) runningServices.HeartbeatService.SetBus(msgBus)
runningServices.HeartbeatService.SetHandler(createHeartbeatHandler(agentLoop)) runningServices.HeartbeatService.SetHandler(createHeartbeatHandler(agentLoop))
if err = runningServices.HeartbeatService.Start(); err != nil { if err = runningServices.HeartbeatService.Start(); err != nil {
return nil, fmt.Errorf("error starting heartbeat service: %w", err) return nil, fmt.Errorf("error starting heartbeat service: %w", err)
} }
fmt.Println("✓ Heartbeat service started") fmt.Println("✓ Heartbeat service started")
runningServices.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{ runningServices.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
Enabled: cfg.Tools.MediaCleanup.Enabled,
Enabled: cfg.Tools.MediaCleanup.Enabled, MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute, Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
}) })
if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {
fms.Start() fms.Start()
} }
runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore) runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore)
if err != nil { if err != nil {
if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {
fms.Stop() fms.Stop()
} }
return nil, fmt.Errorf("error creating channel manager: %w", err) return nil, fmt.Errorf("error creating channel manager: %w", err)
} }
agentLoop.SetChannelManager(runningServices.ChannelManager) agentLoop.SetChannelManager(runningServices.ChannelManager)
agentLoop.SetMediaStore(runningServices.MediaStore) agentLoop.SetMediaStore(runningServices.MediaStore)
if transcriber := voice.DetectTranscriber(cfg); transcriber != nil { if transcriber := voice.DetectTranscriber(cfg); transcriber != nil {
agentLoop.SetTranscriber(transcriber) agentLoop.SetTranscriber(transcriber)
logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()}) logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
} }
enabledChannels := runningServices.ChannelManager.GetEnabledChannels() enabledChannels := runningServices.ChannelManager.GetEnabledChannels()
if len(enabledChannels) > 0 { if len(enabledChannels) > 0 {
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels) fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
} else { } else {
fmt.Println("⚠ Warning: No channels enabled") fmt.Println("⚠ Warning: No channels enabled")
} }
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port) addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer) runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer)
if err = runningServices.ChannelManager.StartAll(context.Background()); err != nil { if err = runningServices.ChannelManager.StartAll(context.Background()); err != nil {
return nil, fmt.Errorf("error starting channels: %w", err) return nil, fmt.Errorf("error starting channels: %w", err)
} }
fmt.Printf( fmt.Printf(
"✓ Health endpoints available at http://%s:%d/health, /ready and /reload (POST)\n", "✓ Health endpoints available at http://%s:%d/health, /ready and /reload (POST)\n",
cfg.Gateway.Host, cfg.Gateway.Host,
cfg.Gateway.Port, cfg.Gateway.Port,
) )
stateManager := state.NewManager(cfg.WorkspacePath()) stateManager := state.NewManager(cfg.WorkspacePath())
runningServices.DeviceService = devices.NewService(devices.Config{ runningServices.DeviceService = devices.NewService(devices.Config{
Enabled: cfg.Devices.Enabled,
Enabled: cfg.Devices.Enabled,
MonitorUSB: cfg.Devices.MonitorUSB, MonitorUSB: cfg.Devices.MonitorUSB,
}, stateManager) }, stateManager)
runningServices.DeviceService.SetBus(msgBus) runningServices.DeviceService.SetBus(msgBus)
if err = runningServices.DeviceService.Start(context.Background()); err != nil { if err = runningServices.DeviceService.Start(context.Background()); err != nil {
logger.ErrorCF("device", "Error starting device service", map[string]any{"error": err.Error()}) logger.ErrorCF("device", "Error starting device service", map[string]any{"error": err.Error()})
} else if cfg.Devices.Enabled { } else if cfg.Devices.Enabled {
fmt.Println("✓ Device event service started") fmt.Println("✓ Device event service started")
} }
return runningServices, nil return runningServices, nil
} }
func stopAndCleanupServices(runningServices *services, shutdownTimeout time.Duration, isReload bool) { func stopAndCleanupServices(runningServices *services, shutdownTimeout time.Duration, isReload bool) {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout) shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer shutdownCancel() defer shutdownCancel()
// reload should not stop channel manager // reload should not stop channel manager
if !isReload && runningServices.ChannelManager != nil { if !isReload && runningServices.ChannelManager != nil {
runningServices.ChannelManager.StopAll(shutdownCtx) runningServices.ChannelManager.StopAll(shutdownCtx)
} }
if runningServices.DeviceService != nil { if runningServices.DeviceService != nil {
runningServices.DeviceService.Stop() runningServices.DeviceService.Stop()
} }
if runningServices.HeartbeatService != nil { if runningServices.HeartbeatService != nil {
runningServices.HeartbeatService.Stop() runningServices.HeartbeatService.Stop()
} }
if runningServices.CronService != nil { if runningServices.CronService != nil {
runningServices.CronService.Stop() runningServices.CronService.Stop()
} }
if runningServices.MediaStore != nil { if runningServices.MediaStore != nil {
if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {
fms.Stop() fms.Stop()
} }
} }
} }
func shutdownGateway( func shutdownGateway(
runningServices *services, runningServices *services,
agentLoop *agent.AgentLoop, agentLoop *agent.AgentLoop,
provider providers.LLMProvider, provider providers.LLMProvider,
fullShutdown bool, fullShutdown bool,
) { ) {
if cp, ok := provider.(providers.StatefulProvider); ok && fullShutdown { if cp, ok := provider.(providers.StatefulProvider); ok && fullShutdown {
cp.Close() cp.Close()
} }
stopAndCleanupServices(runningServices, gracefulShutdownTimeout, false) stopAndCleanupServices(runningServices, gracefulShutdownTimeout, false)
agentLoop.Stop() agentLoop.Stop()
agentLoop.Close() agentLoop.Close()
logger.Info("✓ Gateway stopped") logger.Info("✓ Gateway stopped")
} }
func handleConfigReload( func handleConfigReload(
ctx context.Context, ctx context.Context,
al *agent.AgentLoop, al *agent.AgentLoop,
newCfg *config.Config, newCfg *config.Config,
providerRef *providers.LLMProvider, providerRef *providers.LLMProvider,
runningServices *services, runningServices *services,
msgBus *bus.MessageBus, msgBus *bus.MessageBus,
allowEmptyStartup bool, allowEmptyStartup bool,
) error { ) error {
logger.Info("🔄 Config file changed, reloading...") logger.Info("🔄 Config file changed, reloading...")
newModel := newCfg.Agents.Defaults.ModelName newModel := newCfg.Agents.Defaults.ModelName
if newModel == "" { if newModel == "" {
newModel = newCfg.Agents.Defaults.Model newModel = newCfg.Agents.Defaults.Model
} }
logger.Infof(" New model is '%s', recreating provider...", newModel) logger.Infof(" New model is '%s', recreating provider...", newModel)
logger.Info(" Stopping all services...") logger.Info(" Stopping all services...")
stopAndCleanupServices(runningServices, serviceShutdownTimeout, true) stopAndCleanupServices(runningServices, serviceShutdownTimeout, true)
newProvider, newModelID, err := createStartupProvider(newCfg, allowEmptyStartup) newProvider, newModelID, err := createStartupProvider(newCfg, allowEmptyStartup)
if err != nil { if err != nil {
logger.Errorf(" ⚠ Error creating new provider: %v", err) logger.Errorf(" ⚠ Error creating new provider: %v", err)
logger.Warn(" Attempting to restart services with old provider and config...") logger.Warn(" Attempting to restart services with old provider and config...")
if restartErr := restartServices(al, runningServices, msgBus); restartErr != nil { if restartErr := restartServices(al, runningServices, msgBus); restartErr != nil {
logger.Errorf(" ⚠ Failed to restart services: %v", restartErr) logger.Errorf(" ⚠ Failed to restart services: %v", restartErr)
} }
return fmt.Errorf("error creating new provider: %w", err) return fmt.Errorf("error creating new provider: %w", err)
} }
if newModelID != "" { if newModelID != "" {
newCfg.Agents.Defaults.ModelName = newModelID newCfg.Agents.Defaults.ModelName = newModelID
} }
reloadCtx, reloadCancel := context.WithTimeout(context.Background(), providerReloadTimeout) reloadCtx, reloadCancel := context.WithTimeout(context.Background(), providerReloadTimeout)
defer reloadCancel() defer reloadCancel()
if err := al.ReloadProviderAndConfig(reloadCtx, newProvider, newCfg); err != nil { if err := al.ReloadProviderAndConfig(reloadCtx, newProvider, newCfg); err != nil {
logger.Errorf(" ⚠ Error reloading agent loop: %v", err) logger.Errorf(" ⚠ Error reloading agent loop: %v", err)
if cp, ok := newProvider.(providers.StatefulProvider); ok { if cp, ok := newProvider.(providers.StatefulProvider); ok {
cp.Close() cp.Close()
} }
logger.Warn(" Attempting to restart services with old provider and config...") logger.Warn(" Attempting to restart services with old provider and config...")
if restartErr := restartServices(al, runningServices, msgBus); restartErr != nil { if restartErr := restartServices(al, runningServices, msgBus); restartErr != nil {
logger.Errorf(" ⚠ Failed to restart services: %v", restartErr) logger.Errorf(" ⚠ Failed to restart services: %v", restartErr)
} }
return fmt.Errorf("error reloading agent loop: %w", err) return fmt.Errorf("error reloading agent loop: %w", err)
} }
*providerRef = newProvider *providerRef = newProvider
logger.Info(" Restarting all services with new configuration...") logger.Info(" Restarting all services with new configuration...")
if err := restartServices(al, runningServices, msgBus); err != nil { if err := restartServices(al, runningServices, msgBus); err != nil {
logger.Errorf(" ⚠ Error restarting services: %v", err) logger.Errorf(" ⚠ Error restarting services: %v", err)
return fmt.Errorf("error restarting services: %w", err) return fmt.Errorf("error restarting services: %w", err)
} }
logger.Info(" ✓ Provider, configuration, and services reloaded successfully (thread-safe)") logger.Info(" ✓ Provider, configuration, and services reloaded successfully (thread-safe)")
return nil return nil
} }
func restartServices( func restartServices(
al *agent.AgentLoop, al *agent.AgentLoop,
runningServices *services, runningServices *services,
msgBus *bus.MessageBus, msgBus *bus.MessageBus,
) error { ) error {
cfg := al.GetConfig() cfg := al.GetConfig()
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
var err error var err error
runningServices.CronService, err = setupCronTool( runningServices.CronService, err = setupCronTool(
al, al,
msgBus, msgBus,
cfg.WorkspacePath(), cfg.WorkspacePath(),
cfg.Agents.Defaults.RestrictToWorkspace, cfg.Agents.Defaults.RestrictToWorkspace,
execTimeout, execTimeout,
cfg, cfg,
) )
if err != nil { if err != nil {
return fmt.Errorf("error restarting cron service: %w", err) return fmt.Errorf("error restarting cron service: %w", err)
} }
if err = runningServices.CronService.Start(); err != nil { if err = runningServices.CronService.Start(); err != nil {
return fmt.Errorf("error restarting cron service: %w", err) return fmt.Errorf("error restarting cron service: %w", err)
} }
fmt.Println(" ✓ Cron service restarted") fmt.Println(" ✓ Cron service restarted")
runningServices.HeartbeatService = heartbeat.NewHeartbeatService( runningServices.HeartbeatService = heartbeat.NewHeartbeatService(
cfg.WorkspacePath(), cfg.WorkspacePath(),
cfg.Heartbeat.Interval, cfg.Heartbeat.Interval,
cfg.Heartbeat.Enabled, cfg.Heartbeat.Enabled,
) )
runningServices.HeartbeatService.SetBus(msgBus) runningServices.HeartbeatService.SetBus(msgBus)
runningServices.HeartbeatService.SetHandler(createHeartbeatHandler(al)) runningServices.HeartbeatService.SetHandler(createHeartbeatHandler(al))
if err = runningServices.HeartbeatService.Start(); err != nil { if err = runningServices.HeartbeatService.Start(); err != nil {
return fmt.Errorf("error restarting heartbeat service: %w", err) return fmt.Errorf("error restarting heartbeat service: %w", err)
} }
fmt.Println(" ✓ Heartbeat service restarted") fmt.Println(" ✓ Heartbeat service restarted")
runningServices.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{ runningServices.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
Enabled: cfg.Tools.MediaCleanup.Enabled,
Enabled: cfg.Tools.MediaCleanup.Enabled, MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute, Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
}) })
if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {
fms.Start() fms.Start()
} }
al.SetMediaStore(runningServices.MediaStore) al.SetMediaStore(runningServices.MediaStore)
runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore) runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore)
if err != nil { if err != nil {
return fmt.Errorf("error recreating channel manager: %w", err) return fmt.Errorf("error recreating channel manager: %w", err)
} }
al.SetChannelManager(runningServices.ChannelManager) al.SetChannelManager(runningServices.ChannelManager)
enabledChannels := runningServices.ChannelManager.GetEnabledChannels() enabledChannels := runningServices.ChannelManager.GetEnabledChannels()
if len(enabledChannels) > 0 { if len(enabledChannels) > 0 {
fmt.Printf(" ✓ Channels enabled: %s\n", enabledChannels) fmt.Printf(" ✓ Channels enabled: %s\n", enabledChannels)
} else { } else {
fmt.Println(" ⚠ Warning: No channels enabled") fmt.Println(" ⚠ Warning: No channels enabled")
} }
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port) addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
// Reuse existing HealthServer to preserve reloadFunc // Reuse existing HealthServer to preserve reloadFunc
if runningServices.HealthServer == nil { if runningServices.HealthServer == nil {
runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
} }
runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer) runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer)
if err = runningServices.ChannelManager.Reload(context.Background(), cfg); err != nil { if err = runningServices.ChannelManager.Reload(context.Background(), cfg); err != nil {
return fmt.Errorf("error reload channels: %w", err) return fmt.Errorf("error reload channels: %w", err)
} }
fmt.Println(" ✓ Channels restarted.") fmt.Println(" ✓ Channels restarted.")
stateManager := state.NewManager(cfg.WorkspacePath()) stateManager := state.NewManager(cfg.WorkspacePath())
runningServices.DeviceService = devices.NewService(devices.Config{ runningServices.DeviceService = devices.NewService(devices.Config{
Enabled: cfg.Devices.Enabled,
Enabled: cfg.Devices.Enabled,
MonitorUSB: cfg.Devices.MonitorUSB, MonitorUSB: cfg.Devices.MonitorUSB,
}, stateManager) }, stateManager)
runningServices.DeviceService.SetBus(msgBus) runningServices.DeviceService.SetBus(msgBus)
if err := runningServices.DeviceService.Start(context.Background()); err != nil { if err := runningServices.DeviceService.Start(context.Background()); err != nil {
logger.WarnCF("device", "Failed to restart device service", map[string]any{"error": err.Error()}) logger.WarnCF("device", "Failed to restart device service", map[string]any{"error": err.Error()})
} else if cfg.Devices.Enabled { } else if cfg.Devices.Enabled {
fmt.Println(" ✓ Device event service restarted") fmt.Println(" ✓ Device event service restarted")
} }
transcriber := voice.DetectTranscriber(cfg) transcriber := voice.DetectTranscriber(cfg)
al.SetTranscriber(transcriber) al.SetTranscriber(transcriber)
if transcriber != nil { if transcriber != nil {
logger.InfoCF("voice", "Transcription re-enabled (agent-level)", map[string]any{"provider": transcriber.Name()}) logger.InfoCF("voice", "Transcription re-enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
} else { } else {
logger.InfoCF("voice", "Transcription disabled", nil) logger.InfoCF("voice", "Transcription disabled", nil)
} }
return nil return nil
} }
func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Config, func()) { func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Config, func()) {
configChan := make(chan *config.Config, 1) configChan := make(chan *config.Config, 1)
stop := make(chan struct{}) stop := make(chan struct{})
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
lastModTime := getFileModTime(configPath) lastModTime := getFileModTime(configPath)
lastSize := getFileSize(configPath) lastSize := getFileSize(configPath)
ticker := time.NewTicker(2 * time.Second) ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
currentModTime := getFileModTime(configPath) currentModTime := getFileModTime(configPath)
currentSize := getFileSize(configPath) currentSize := getFileSize(configPath)
if currentModTime.After(lastModTime) || currentSize != lastSize { if currentModTime.After(lastModTime) || currentSize != lastSize {
if debug { if debug {
logger.Debugf("🔍 Config file change detected") logger.Debugf("🔍 Config file change detected")
} }
time.Sleep(500 * time.Millisecond) time.Sleep(500 * time.Millisecond)
lastModTime = currentModTime lastModTime = currentModTime
lastSize = currentSize lastSize = currentSize
newCfg, err := config.LoadConfig(configPath) newCfg, err := config.LoadConfig(configPath)
if err != nil { if err != nil {
logger.Errorf("⚠ Error loading new config: %v", err) logger.Errorf("⚠ Error loading new config: %v", err)
logger.Warn(" Using previous valid config") logger.Warn(" Using previous valid config")
continue continue
} }
if err := newCfg.ValidateModelList(); err != nil { if err := newCfg.ValidateModelList(); err != nil {
logger.Errorf(" ⚠ New config validation failed: %v", err) logger.Errorf(" ⚠ New config validation failed: %v", err)
logger.Warn(" Using previous valid config") logger.Warn(" Using previous valid config")
continue continue
} }
logger.Info("✓ Config file validated and loaded") logger.Info("✓ Config file validated and loaded")
select { select {
case configChan <- newCfg: case configChan <- newCfg:
default: default:
logger.Warn("⚠ Previous config reload still in progress, skipping") logger.Warn("⚠ Previous config reload still in progress, skipping")
} }
} }
case <-stop: case <-stop:
return return
} }
} }
}() }()
stopFunc := func() { stopFunc := func() {
close(stop) close(stop)
wg.Wait() wg.Wait()
} }
return configChan, stopFunc return configChan, stopFunc
} }
func getFileModTime(path string) time.Time { func getFileModTime(path string) time.Time {
info, err := os.Stat(path) info, err := os.Stat(path)
if err != nil { if err != nil {
return time.Time{} return time.Time{}
} }
return info.ModTime() return info.ModTime()
} }
func getFileSize(path string) int64 { func getFileSize(path string) int64 {
info, err := os.Stat(path) info, err := os.Stat(path)
if err != nil { if err != nil {
return 0 return 0
} }
return info.Size() return info.Size()
} }
func setupCronTool( func setupCronTool(
agentLoop *agent.AgentLoop, agentLoop *agent.AgentLoop,
msgBus *bus.MessageBus, msgBus *bus.MessageBus,
workspace string, workspace string,
restrict bool, restrict bool,
execTimeout time.Duration, execTimeout time.Duration,
cfg *config.Config, cfg *config.Config,
) (*cron.CronService, error) { ) (*cron.CronService, error) {
cronStorePath := filepath.Join(workspace, "cron", "jobs.json") cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
cronService := cron.NewCronService(cronStorePath, nil) cronService := cron.NewCronService(cronStorePath, nil)
var cronTool *tools.CronTool var cronTool *tools.CronTool
if cfg.Tools.IsToolEnabled("cron") { if cfg.Tools.IsToolEnabled("cron") {
var err error var err error
cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg) cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
if err != nil { if err != nil {
return nil, fmt.Errorf("critical error during CronTool initialization: %w", err) return nil, fmt.Errorf("critical error during CronTool initialization: %w", err)
} }
agentLoop.RegisterTool(cronTool) agentLoop.RegisterTool(cronTool)
} }
if cronTool != nil { if cronTool != nil {
cronService.SetOnJob(func(job *cron.CronJob) (string, error) { cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
result := cronTool.ExecuteJob(context.Background(), job) result := cronTool.ExecuteJob(context.Background(), job)
return result, nil return result, nil
}) })
} }
return cronService, nil return cronService, nil
} }
func createHeartbeatHandler(agentLoop *agent.AgentLoop) func(prompt, channel, chatID string) *tools.ToolResult { func createHeartbeatHandler(agentLoop *agent.AgentLoop) func(prompt, channel, chatID string) *tools.ToolResult {
return func(prompt, channel, chatID string) *tools.ToolResult { return func(prompt, channel, chatID string) *tools.ToolResult {
if channel == "" || chatID == "" { if channel == "" || chatID == "" {
channel, chatID = "cli", "direct" channel, chatID = "cli", "direct"
} }
response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID) response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
if err != nil { if err != nil {
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err)) return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
} }
if response == "HEARTBEAT_OK" { if response == "HEARTBEAT_OK" {
return tools.SilentResult("Heartbeat OK") return tools.SilentResult("Heartbeat OK")
} }
return tools.SilentResult(response) return tools.SilentResult(response)
} }
} }