package main import ( "bufio" "context" "encoding/json" "fmt" "os" "os/exec" "os/signal" "path/filepath" "reflect" "runtime" "strings" "syscall" "time" "clawgo/pkg/agent" "clawgo/pkg/autonomy" "clawgo/pkg/bus" "clawgo/pkg/channels" "clawgo/pkg/config" "clawgo/pkg/cron" "clawgo/pkg/heartbeat" "clawgo/pkg/logger" "clawgo/pkg/nodes" "clawgo/pkg/providers" "clawgo/pkg/runtimecfg" "clawgo/pkg/sentinel" ) func gatewayCmd() { args := os.Args[2:] if len(args) == 0 { if err := gatewayInstallServiceCmd(); err != nil { fmt.Printf("Error registering gateway service: %v\n", err) os.Exit(1) } return } switch args[0] { case "run": case "start", "stop", "restart", "status": if err := gatewayServiceControlCmd(args[0]); err != nil { fmt.Printf("Error: %v\n", err) os.Exit(1) } return case "autonomy": if err := gatewayAutonomyControlCmd(args[1:]); err != nil { fmt.Printf("Error: %v\n", err) os.Exit(1) } return default: fmt.Printf("Unknown gateway command: %s\n", args[0]) fmt.Println("Usage: clawgo gateway [run|start|stop|restart|status|autonomy on|off|status]") return } cfg, err := loadConfig() if err != nil { fmt.Printf("Error loading config: %v\n", err) os.Exit(1) } runtimecfg.Set(cfg) if strings.EqualFold(strings.TrimSpace(os.Getenv(envRootGranted)), "1") || strings.EqualFold(strings.TrimSpace(os.Getenv(envRootGranted)), "true") { applyMaximumPermissionPolicy(cfg) } msgBus := bus.NewMessageBus() cronStorePath := filepath.Join(filepath.Dir(getConfigPath()), "cron", "jobs.json") cronService := cron.NewCronService(cronStorePath, func(job *cron.CronJob) (string, error) { if job == nil { return "", nil } targetChannel := strings.TrimSpace(job.Payload.Channel) targetChatID := strings.TrimSpace(job.Payload.To) message := strings.TrimSpace(job.Payload.Message) if job.Payload.Deliver && targetChannel != "" && targetChatID != "" && message != "" { msgBus.PublishOutbound(bus.OutboundMessage{ Channel: targetChannel, ChatID: targetChatID, Content: message, }) return "delivered", nil } if message == "" { return "", nil } if targetChannel == "" || targetChatID == "" { targetChannel = "internal" targetChatID = "cron" } msgBus.PublishInbound(bus.InboundMessage{ Channel: "system", SenderID: "cron", ChatID: fmt.Sprintf("%s:%s", targetChannel, targetChatID), Content: message, SessionKey: fmt.Sprintf("cron:%s", job.ID), Metadata: map[string]string{ "trigger": "cron", "job_id": job.ID, }, }) return "scheduled", nil }) configureCronServiceRuntime(cronService, cfg) heartbeatService := buildHeartbeatService(cfg, msgBus) autonomyEngine := buildAutonomyEngine(cfg, msgBus) sentinelService := sentinel.NewService( getConfigPath(), cfg.WorkspacePath(), cfg.Sentinel.IntervalSec, cfg.Sentinel.AutoHeal, func(message string) { if cfg.Sentinel.NotifyChannel != "" && cfg.Sentinel.NotifyChatID != "" { msgBus.PublishOutbound(bus.OutboundMessage{ Channel: cfg.Sentinel.NotifyChannel, ChatID: cfg.Sentinel.NotifyChatID, Content: "[Sentinel] " + message, }) } }, ) ctx, cancel := context.WithCancel(context.Background()) defer cancel() agentLoop, channelManager, err := buildGatewayRuntime(ctx, cfg, msgBus, cronService) if err != nil { fmt.Printf("Error initializing gateway runtime: %v\n", err) os.Exit(1) } sentinelService.SetManager(channelManager) pidFile := filepath.Join(filepath.Dir(getConfigPath()), "gateway.pid") if err := os.WriteFile(pidFile, []byte(fmt.Sprintf("%d\n", os.Getpid())), 0644); err != nil { fmt.Printf("Warning: failed to write PID file: %v\n", err) } else { defer os.Remove(pidFile) } enabledChannels := channelManager.GetEnabledChannels() if len(enabledChannels) > 0 { fmt.Printf("✓ Channels enabled: %s\n", enabledChannels) } else { fmt.Println("⚠ Warning: No channels enabled") } fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) fmt.Println("Press Ctrl+C to stop. Send SIGHUP to hot-reload config.") if err := cronService.Start(); err != nil { fmt.Printf("Error starting cron service: %v\n", err) } fmt.Println("✓ Cron service started") if err := heartbeatService.Start(); err != nil { fmt.Printf("Error starting heartbeat service: %v\n", err) } fmt.Println("✓ Heartbeat service started") autonomyEngine.Start() if cfg.Agents.Defaults.Autonomy.Enabled { fmt.Println("✓ Autonomy engine started") } if cfg.Sentinel.Enabled { sentinelService.Start() fmt.Println("✓ Sentinel service started") } if err := channelManager.StartAll(ctx); err != nil { fmt.Printf("Error starting channels: %v\n", err) } registryServer := nodes.NewRegistryServer(cfg.Gateway.Host, cfg.Gateway.Port, cfg.Gateway.Token, nodes.DefaultManager()) registryServer.SetConfigPath(getConfigPath()) registryServer.SetWebUIDir(filepath.Join(cfg.WorkspacePath(), "webui-dist")) registryServer.SetChatHandler(func(cctx context.Context, sessionKey, content string) (string, error) { if strings.TrimSpace(content) == "" { return "", nil } return agentLoop.ProcessDirect(cctx, content, sessionKey) }) registryServer.SetConfigAfterHook(func() { _ = syscall.Kill(os.Getpid(), syscall.SIGHUP) }) if err := registryServer.Start(ctx); err != nil { fmt.Printf("Error starting node registry server: %v\n", err) } else { fmt.Printf("✓ Node registry server started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) } go agentLoop.Run(ctx) go runGatewayStartupCompactionCheck(ctx, agentLoop) go runGatewayBootstrapInit(ctx, cfg, agentLoop) sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) for { sig := <-sigChan switch sig { case syscall.SIGHUP: fmt.Println("\n↻ Reloading config...") newCfg, err := config.LoadConfig(getConfigPath()) if err != nil { fmt.Printf("✗ Reload failed (load config): %v\n", err) continue } if strings.EqualFold(strings.TrimSpace(os.Getenv(envRootGranted)), "1") || strings.EqualFold(strings.TrimSpace(os.Getenv(envRootGranted)), "true") { applyMaximumPermissionPolicy(newCfg) } configureCronServiceRuntime(cronService, newCfg) heartbeatService.Stop() autonomyEngine.Stop() heartbeatService = buildHeartbeatService(newCfg, msgBus) autonomyEngine = buildAutonomyEngine(newCfg, msgBus) if err := heartbeatService.Start(); err != nil { fmt.Printf("Error starting heartbeat service: %v\n", err) } autonomyEngine.Start() if reflect.DeepEqual(cfg, newCfg) { fmt.Println("✓ Config unchanged, skip reload") continue } runtimeSame := reflect.DeepEqual(cfg.Agents, newCfg.Agents) && reflect.DeepEqual(cfg.Providers, newCfg.Providers) && reflect.DeepEqual(cfg.Tools, newCfg.Tools) && reflect.DeepEqual(cfg.Channels, newCfg.Channels) templateChanges := summarizeDialogTemplateChanges(cfg, newCfg) autonomyChanges := summarizeAutonomyChanges(cfg, newCfg) if runtimeSame { configureLogging(newCfg) sentinelService.Stop() sentinelService = sentinel.NewService( getConfigPath(), newCfg.WorkspacePath(), newCfg.Sentinel.IntervalSec, newCfg.Sentinel.AutoHeal, func(message string) { if newCfg.Sentinel.NotifyChannel != "" && newCfg.Sentinel.NotifyChatID != "" { msgBus.PublishOutbound(bus.OutboundMessage{ Channel: newCfg.Sentinel.NotifyChannel, ChatID: newCfg.Sentinel.NotifyChatID, Content: "[Sentinel] " + message, }) } }, ) if newCfg.Sentinel.Enabled { sentinelService.SetManager(channelManager) sentinelService.Start() } cfg = newCfg runtimecfg.Set(cfg) if len(templateChanges) > 0 { fmt.Printf("↻ Dialog template changes: %s\n", strings.Join(templateChanges, ", ")) } if len(autonomyChanges) > 0 { fmt.Printf("↻ Autonomy changes: %s\n", strings.Join(autonomyChanges, ", ")) } fmt.Println("✓ Config hot-reload applied (logging/metadata only)") continue } newAgentLoop, newChannelManager, err := buildGatewayRuntime(ctx, newCfg, msgBus, cronService) if err != nil { fmt.Printf("✗ Reload failed (init runtime): %v\n", err) continue } channelManager.StopAll(ctx) agentLoop.Stop() channelManager = newChannelManager agentLoop = newAgentLoop cfg = newCfg runtimecfg.Set(cfg) sentinelService.Stop() sentinelService = sentinel.NewService( getConfigPath(), newCfg.WorkspacePath(), newCfg.Sentinel.IntervalSec, newCfg.Sentinel.AutoHeal, func(message string) { if newCfg.Sentinel.NotifyChannel != "" && newCfg.Sentinel.NotifyChatID != "" { msgBus.PublishOutbound(bus.OutboundMessage{ Channel: newCfg.Sentinel.NotifyChannel, ChatID: newCfg.Sentinel.NotifyChatID, Content: "[Sentinel] " + message, }) } }, ) if newCfg.Sentinel.Enabled { sentinelService.Start() } sentinelService.SetManager(channelManager) if err := channelManager.StartAll(ctx); err != nil { fmt.Printf("✗ Reload failed (start channels): %v\n", err) continue } go agentLoop.Run(ctx) if len(templateChanges) > 0 { fmt.Printf("↻ Dialog template changes: %s\n", strings.Join(templateChanges, ", ")) } if len(autonomyChanges) > 0 { fmt.Printf("↻ Autonomy changes: %s\n", strings.Join(autonomyChanges, ", ")) } fmt.Println("✓ Config hot-reload applied") default: fmt.Println("\nShutting down...") cancel() heartbeatService.Stop() autonomyEngine.Stop() sentinelService.Stop() cronService.Stop() agentLoop.Stop() channelManager.StopAll(ctx) fmt.Println("✓ Gateway stopped") return } } } func gatewayAutonomyControlCmd(args []string) error { if len(args) < 1 { return fmt.Errorf("usage: clawgo gateway autonomy [on|off|status]") } cfg, err := loadConfig() if err != nil { return err } memDir := filepath.Join(cfg.WorkspacePath(), "memory") if err := os.MkdirAll(memDir, 0755); err != nil { return err } pausePath := filepath.Join(memDir, "autonomy.pause") ctrlPath := filepath.Join(memDir, "autonomy.control.json") type autonomyControl struct { Enabled bool `json:"enabled"` UpdatedAt string `json:"updated_at"` Source string `json:"source"` } writeControl := func(enabled bool) error { c := autonomyControl{Enabled: enabled, UpdatedAt: time.Now().UTC().Format(time.RFC3339), Source: "manual_cli"} data, err := json.MarshalIndent(c, "", " ") if err != nil { return err } return os.WriteFile(ctrlPath, append(data, '\n'), 0644) } switch strings.ToLower(strings.TrimSpace(args[0])) { case "on": _ = os.Remove(pausePath) if err := writeControl(true); err != nil { return err } fmt.Println("✓ Autonomy enabled") return nil case "off": if err := writeControl(false); err != nil { return err } if err := os.WriteFile(pausePath, []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0644); err != nil { return err } fmt.Println("✓ Autonomy disabled (paused)") return nil case "status": enabled := true reason := "default" updatedAt := "" source := "" if data, err := os.ReadFile(ctrlPath); err == nil { var c autonomyControl if json.Unmarshal(data, &c) == nil { enabled = c.Enabled updatedAt = c.UpdatedAt source = c.Source if !c.Enabled { reason = "control_file" } } } if _, err := os.Stat(pausePath); err == nil { enabled = false reason = "pause_file" } fmt.Printf("Autonomy status: %v (%s)\n", enabled, reason) if strings.TrimSpace(updatedAt) != "" { fmt.Printf("Last switch: %s", updatedAt) if strings.TrimSpace(source) != "" { fmt.Printf(" via %s", source) } fmt.Println() } fmt.Printf("Control file: %s\n", ctrlPath) fmt.Printf("Pause file: %s\n", pausePath) return nil default: return fmt.Errorf("usage: clawgo gateway autonomy [on|off|status]") } } func summarizeAutonomyChanges(oldCfg, newCfg *config.Config) []string { if oldCfg == nil || newCfg == nil { return nil } o := oldCfg.Agents.Defaults.Autonomy n := newCfg.Agents.Defaults.Autonomy changes := make([]string, 0) if o.Enabled != n.Enabled { changes = append(changes, "enabled") } if o.TickIntervalSec != n.TickIntervalSec { changes = append(changes, "tick_interval_sec") } if o.MinRunIntervalSec != n.MinRunIntervalSec { changes = append(changes, "min_run_interval_sec") } if o.UserIdleResumeSec != n.UserIdleResumeSec { changes = append(changes, "user_idle_resume_sec") } if o.WaitingResumeDebounceSec != n.WaitingResumeDebounceSec { changes = append(changes, "waiting_resume_debounce_sec") } if strings.TrimSpace(o.QuietHours) != strings.TrimSpace(n.QuietHours) { changes = append(changes, "quiet_hours") } if o.NotifyCooldownSec != n.NotifyCooldownSec { changes = append(changes, "notify_cooldown_sec") } if o.NotifySameReasonCooldownSec != n.NotifySameReasonCooldownSec { changes = append(changes, "notify_same_reason_cooldown_sec") } return changes } func summarizeDialogTemplateChanges(oldCfg, newCfg *config.Config) []string { if oldCfg == nil || newCfg == nil { return nil } type pair struct { name string a string b string } oldT := oldCfg.Agents.Defaults.Texts newT := newCfg.Agents.Defaults.Texts checks := []pair{ {name: "system_rewrite_template", a: oldT.SystemRewriteTemplate, b: newT.SystemRewriteTemplate}, {name: "lang_usage", a: oldT.LangUsage, b: newT.LangUsage}, {name: "lang_invalid", a: oldT.LangInvalid, b: newT.LangInvalid}, {name: "lang_updated_template", a: oldT.LangUpdatedTemplate, b: newT.LangUpdatedTemplate}, {name: "runtime_compaction_note", a: oldT.RuntimeCompactionNote, b: newT.RuntimeCompactionNote}, {name: "startup_compaction_note", a: oldT.StartupCompactionNote, b: newT.StartupCompactionNote}, {name: "autonomy_completion_template", a: oldT.AutonomyCompletionTemplate, b: newT.AutonomyCompletionTemplate}, {name: "autonomy_blocked_template", a: oldT.AutonomyBlockedTemplate, b: newT.AutonomyBlockedTemplate}, } out := make([]string, 0) for _, c := range checks { if strings.TrimSpace(c.a) != strings.TrimSpace(c.b) { out = append(out, c.name) } } if strings.Join(oldT.AutonomyImportantKeywords, "|") != strings.Join(newT.AutonomyImportantKeywords, "|") { out = append(out, "autonomy_important_keywords") } if oldCfg.Agents.Defaults.Heartbeat.PromptTemplate != newCfg.Agents.Defaults.Heartbeat.PromptTemplate { out = append(out, "heartbeat.prompt_template") } return out } func runGatewayStartupCompactionCheck(parent context.Context, agentLoop *agent.AgentLoop) { if agentLoop == nil { return } checkCtx, cancel := context.WithTimeout(parent, 10*time.Minute) defer cancel() report := agentLoop.RunStartupSelfCheckAllSessions(checkCtx) logger.InfoCF("gateway", "Startup compaction check completed", map[string]interface{}{ "sessions_total": report.TotalSessions, "sessions_compacted": report.CompactedSessions, }) } func runGatewayBootstrapInit(parent context.Context, cfg *config.Config, agentLoop *agent.AgentLoop) { if agentLoop == nil || cfg == nil { return } workspace := cfg.WorkspacePath() bootstrapPath := filepath.Join(workspace, "BOOTSTRAP.md") if _, err := os.Stat(bootstrapPath); err != nil { return } memDir := filepath.Join(workspace, "memory") _ = os.MkdirAll(memDir, 0755) markerPath := filepath.Join(memDir, "bootstrap.init.done") if _, err := os.Stat(markerPath); err == nil { return } initCtx, cancel := context.WithTimeout(parent, 90*time.Second) defer cancel() prompt := "System startup bootstrap: read BOOTSTRAP.md and perform one-time self-initialization checks now. If already initialized, return concise status only." resp, err := agentLoop.ProcessDirect(initCtx, prompt, "system:bootstrap:init") if err != nil { logger.ErrorCF("gateway", "Bootstrap init model call failed", map[string]interface{}{logger.FieldError: err.Error()}) return } line := fmt.Sprintf("%s\n%s\n", time.Now().UTC().Format(time.RFC3339), strings.TrimSpace(resp)) _ = os.WriteFile(markerPath, []byte(line), 0644) logger.InfoC("gateway", "Bootstrap init model call completed") } func maybePromptAndEscalateRoot(command string) { if os.Getenv(envRootPrompted) == "1" { return } if !isInteractiveStdin() { return } fmt.Printf("Grant root permissions for `clawgo %s`? (yes/no): ", command) reader := bufio.NewReader(os.Stdin) line, _ := reader.ReadString('\n') answer := strings.ToLower(strings.TrimSpace(line)) if answer != "yes" && answer != "y" { _ = os.Setenv(envRootPrompted, "1") _ = os.Setenv(envRootGranted, "0") return } _ = os.Setenv(envRootPrompted, "1") _ = os.Setenv(envRootGranted, "1") if os.Geteuid() == 0 { return } exePath, err := os.Executable() if err != nil { fmt.Printf("Error resolving executable for sudo re-run: %v\n", err) os.Exit(1) } exePath, _ = filepath.Abs(exePath) cmdArgs := append([]string{"-E", exePath}, os.Args[1:]...) cmd := exec.Command("sudo", cmdArgs...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Env = append(os.Environ(), envRootPrompted+"=1", envRootGranted+"=1") if err := cmd.Run(); err != nil { if exitErr, ok := err.(*exec.ExitError); ok { os.Exit(exitErr.ExitCode()) } fmt.Printf("Failed to elevate privileges with sudo: %v\n", err) os.Exit(1) } os.Exit(0) } func shouldPromptGatewayRoot(args []string) bool { return len(args) == 2 && args[1] == "gateway" } func isInteractiveStdin() bool { info, err := os.Stdin.Stat() if err != nil { return false } return (info.Mode() & os.ModeCharDevice) != 0 } func applyMaximumPermissionPolicy(cfg *config.Config) { cfg.Tools.Shell.Enabled = true cfg.Tools.Shell.Sandbox.Enabled = false } func gatewayInstallServiceCmd() error { scope, unitPath, err := detectGatewayServiceScopeAndPath() if err != nil { return err } exePath, err := os.Executable() if err != nil { return fmt.Errorf("resolve executable path failed: %w", err) } exePath, _ = filepath.Abs(exePath) configPath := getConfigPath() workDir := filepath.Dir(exePath) unitContent := buildGatewayUnitContent(scope, exePath, configPath, workDir) if err := os.MkdirAll(filepath.Dir(unitPath), 0755); err != nil { return fmt.Errorf("create service directory failed: %w", err) } if err := os.WriteFile(unitPath, []byte(unitContent), 0644); err != nil { return fmt.Errorf("write service unit failed: %w", err) } if err := runSystemctl(scope, "daemon-reload"); err != nil { return err } if err := runSystemctl(scope, "enable", gatewayServiceName); err != nil { return err } fmt.Printf("✓ Gateway service registered: %s (%s)\n", gatewayServiceName, scope) fmt.Printf(" Unit file: %s\n", unitPath) fmt.Println(" Start service: clawgo gateway start") fmt.Println(" Restart service: clawgo gateway restart") fmt.Println(" Stop service: clawgo gateway stop") return nil } func gatewayServiceControlCmd(action string) error { scope, _, err := detectInstalledGatewayService() if err != nil { return err } return runSystemctl(scope, action, gatewayServiceName) } func detectGatewayServiceScopeAndPath() (string, string, error) { if runtime.GOOS != "linux" { return "", "", fmt.Errorf("gateway service registration currently supports Linux systemd only") } if strings.ToLower(strings.TrimSpace(os.Getenv("CLAWGO_GATEWAY_SCOPE"))) == "user" { return userGatewayUnitPath() } if strings.ToLower(strings.TrimSpace(os.Getenv("CLAWGO_GATEWAY_SCOPE"))) == "system" { return "system", "/etc/systemd/system/" + gatewayServiceName, nil } if os.Geteuid() == 0 { return "system", "/etc/systemd/system/" + gatewayServiceName, nil } return userGatewayUnitPath() } func userGatewayUnitPath() (string, string, error) { home, err := os.UserHomeDir() if err != nil { return "", "", fmt.Errorf("resolve user home failed: %w", err) } return "user", filepath.Join(home, ".config", "systemd", "user", gatewayServiceName), nil } func detectInstalledGatewayService() (string, string, error) { systemPath := "/etc/systemd/system/" + gatewayServiceName userScope, userPath, err := userGatewayUnitPath() if err != nil { return "", "", err } systemExists := false if info, err := os.Stat(systemPath); err == nil && !info.IsDir() { systemExists = true } userExists := false if info, err := os.Stat(userPath); err == nil && !info.IsDir() { userExists = true } preferredScope := strings.ToLower(strings.TrimSpace(os.Getenv("CLAWGO_GATEWAY_SCOPE"))) switch preferredScope { case "system": if systemExists { return "system", systemPath, nil } return "", "", fmt.Errorf("gateway service unit not found in system scope: %s", systemPath) case "user": if userExists { return userScope, userPath, nil } return "", "", fmt.Errorf("gateway service unit not found in user scope: %s", userPath) } // Auto-pick scope by current privilege to avoid non-root users accidentally // selecting system scope when both unit files exist. if os.Geteuid() == 0 { if systemExists { return "system", systemPath, nil } if userExists { return userScope, userPath, nil } } else { if userExists { return userScope, userPath, nil } if systemExists { return "system", systemPath, nil } } return "", "", fmt.Errorf("gateway service not registered. Run: clawgo gateway") } func buildGatewayUnitContent(scope, exePath, configPath, workDir string) string { quotedExec := fmt.Sprintf("%q gateway run --config %q", exePath, configPath) installTarget := "default.target" if scope == "system" { installTarget = "multi-user.target" } home, err := os.UserHomeDir() if err != nil { home = filepath.Dir(configPath) } return fmt.Sprintf(`[Unit] Description=ClawGo Gateway After=network.target [Service] Type=simple WorkingDirectory=%s ExecStart=%s Restart=always RestartSec=3 Environment=CLAWGO_CONFIG=%s Environment=HOME=%s [Install] WantedBy=%s `, workDir, quotedExec, configPath, home, installTarget) } func runSystemctl(scope string, args ...string) error { cmdArgs := make([]string, 0, len(args)+1) if scope == "user" { cmdArgs = append(cmdArgs, "--user") } cmdArgs = append(cmdArgs, args...) cmd := exec.Command("systemctl", cmdArgs...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { if scope == "user" { return fmt.Errorf("systemctl --user %s failed: %w", strings.Join(args, " "), err) } return fmt.Errorf("systemctl %s failed: %w", strings.Join(args, " "), err) } return nil } func buildGatewayRuntime(ctx context.Context, cfg *config.Config, msgBus *bus.MessageBus, cronService *cron.CronService) (*agent.AgentLoop, *channels.Manager, error) { provider, err := providers.CreateProvider(cfg) if err != nil { return nil, nil, fmt.Errorf("create provider: %w", err) } agentLoop := agent.NewAgentLoop(cfg, msgBus, provider, cronService) startupInfo := agentLoop.GetStartupInfo() toolsInfo := startupInfo["tools"].(map[string]interface{}) skillsInfo := startupInfo["skills"].(map[string]interface{}) fmt.Println("\n📦 Agent Status:") fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"]) fmt.Printf(" • Skills: %d/%d available\n", skillsInfo["available"], skillsInfo["total"]) logger.InfoCF("agent", "Agent initialized", map[string]interface{}{ "tools_count": toolsInfo["count"], "skills_total": skillsInfo["total"], "skills_available": skillsInfo["available"], }) channelManager, err := channels.NewManager(cfg, msgBus) if err != nil { return nil, nil, fmt.Errorf("create channel manager: %w", err) } return agentLoop, channelManager, nil } func configureCronServiceRuntime(cs *cron.CronService, cfg *config.Config) { if cs == nil || cfg == nil { return } cs.SetRuntimeOptions(cron.RuntimeOptions{ RunLoopMinSleep: time.Duration(cfg.Cron.MinSleepSec) * time.Second, RunLoopMaxSleep: time.Duration(cfg.Cron.MaxSleepSec) * time.Second, RetryBackoffBase: time.Duration(cfg.Cron.RetryBackoffBaseSec) * time.Second, RetryBackoffMax: time.Duration(cfg.Cron.RetryBackoffMaxSec) * time.Second, MaxConsecutiveFailureRetries: int64(cfg.Cron.MaxConsecutiveFailureRetries), MaxWorkers: cfg.Cron.MaxWorkers, }) } func buildHeartbeatService(cfg *config.Config, msgBus *bus.MessageBus) *heartbeat.HeartbeatService { hbInterval := cfg.Agents.Defaults.Heartbeat.EverySec if hbInterval <= 0 { hbInterval = 30 * 60 } return heartbeat.NewHeartbeatService(cfg.WorkspacePath(), func(prompt string) (string, error) { msgBus.PublishInbound(bus.InboundMessage{ Channel: "system", SenderID: "heartbeat", ChatID: "internal:heartbeat", Content: prompt, SessionKey: "heartbeat:default", Metadata: map[string]string{ "trigger": "heartbeat", }, }) return "queued", nil }, hbInterval, cfg.Agents.Defaults.Heartbeat.Enabled, cfg.Agents.Defaults.Heartbeat.PromptTemplate) } func buildAutonomyEngine(cfg *config.Config, msgBus *bus.MessageBus) *autonomy.Engine { a := cfg.Agents.Defaults.Autonomy return autonomy.NewEngine(autonomy.Options{ Enabled: a.Enabled, TickIntervalSec: a.TickIntervalSec, MinRunIntervalSec: a.MinRunIntervalSec, MaxPendingDurationSec: a.MaxPendingDurationSec, MaxConsecutiveStalls: a.MaxConsecutiveStalls, MaxDispatchPerTick: a.MaxDispatchPerTick, NotifyCooldownSec: a.NotifyCooldownSec, NotifySameReasonCooldownSec: a.NotifySameReasonCooldownSec, QuietHours: a.QuietHours, UserIdleResumeSec: a.UserIdleResumeSec, WaitingResumeDebounceSec: a.WaitingResumeDebounceSec, ImportantKeywords: cfg.Agents.Defaults.Texts.AutonomyImportantKeywords, CompletionTemplate: cfg.Agents.Defaults.Texts.AutonomyCompletionTemplate, BlockedTemplate: cfg.Agents.Defaults.Texts.AutonomyBlockedTemplate, Workspace: cfg.WorkspacePath(), DefaultNotifyChannel: a.NotifyChannel, DefaultNotifyChatID: a.NotifyChatID, }, msgBus) }