diff --git a/cmd/clawgo/cmd_gateway.go b/cmd/clawgo/cmd_gateway.go index 5d8725a..7446046 100644 --- a/cmd/clawgo/cmd_gateway.go +++ b/cmd/clawgo/cmd_gateway.go @@ -60,9 +60,47 @@ func gatewayCmd() { msgBus := bus.NewMessageBus() cronStorePath := filepath.Join(filepath.Dir(getConfigPath()), "cron", "jobs.json") - cronService := cron.NewCronService(cronStorePath, nil) + cronService := cron.NewCronService(cronStorePath, func(job *cron.CronJob) (string, error) { + if job == nil || strings.TrimSpace(job.Payload.Message) == "" { + return "", nil + } + targetChannel := strings.TrimSpace(job.Payload.Channel) + targetChatID := strings.TrimSpace(job.Payload.To) + if targetChannel == "" || targetChatID == "" { + targetChannel = "internal" + targetChatID = "cron" + } + msgBus.PublishInbound(bus.InboundMessage{ + Channel: "system", + SenderID: "cron", + ChatID: fmt.Sprintf("%s:%s", targetChannel, targetChatID), + Content: job.Payload.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 := heartbeat.NewHeartbeatService(cfg.WorkspacePath(), nil, 30*60, true) + hbInterval := cfg.Agents.Defaults.Heartbeat.EverySec + if hbInterval <= 0 { + hbInterval = 30 * 60 + } + heartbeatService := 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) sentinelService := sentinel.NewService( getConfigPath(), cfg.WorkspacePath(), diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 72af6fe..587322e 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -36,6 +36,7 @@ type AgentLoop struct { compactionEnabled bool compactionTrigger int compactionKeepRecent int + heartbeatAckMaxChars int running bool } @@ -131,6 +132,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers compactionEnabled: cfg.Agents.Defaults.ContextCompaction.Enabled, compactionTrigger: cfg.Agents.Defaults.ContextCompaction.TriggerMessages, compactionKeepRecent: cfg.Agents.Defaults.ContextCompaction.KeepRecentMessages, + heartbeatAckMaxChars: cfg.Agents.Defaults.Heartbeat.AckMaxChars, running: false, } @@ -162,6 +164,9 @@ func (al *AgentLoop) Run(ctx context.Context) error { } if response != "" { + if al.shouldSuppressOutbound(msg, response) { + continue + } al.bus.PublishOutbound(bus.OutboundMessage{ Channel: msg.Channel, ChatID: msg.ChatID, @@ -178,6 +183,27 @@ func (al *AgentLoop) Stop() { al.running = false } +func (al *AgentLoop) shouldSuppressOutbound(msg bus.InboundMessage, response string) bool { + if msg.Metadata == nil { + return false + } + trigger := strings.ToLower(strings.TrimSpace(msg.Metadata["trigger"])) + if trigger != "heartbeat" { + return false + } + + r := strings.TrimSpace(response) + if !strings.HasPrefix(r, "HEARTBEAT_OK") { + return false + } + + maxChars := al.heartbeatAckMaxChars + if maxChars <= 0 { + maxChars = 64 + } + return len(r) <= maxChars +} + func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey string) (string, error) { msg := bus.InboundMessage{ Channel: "cli", diff --git a/pkg/config/config.go b/pkg/config/config.go index 8e4b5b8..8e269ce 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -37,10 +37,17 @@ type AgentDefaults struct { MaxTokens int `json:"max_tokens" env:"CLAWGO_AGENTS_DEFAULTS_MAX_TOKENS"` Temperature float64 `json:"temperature" env:"CLAWGO_AGENTS_DEFAULTS_TEMPERATURE"` MaxToolIterations int `json:"max_tool_iterations" env:"CLAWGO_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` + Heartbeat HeartbeatConfig `json:"heartbeat"` ContextCompaction ContextCompactionConfig `json:"context_compaction"` RuntimeControl RuntimeControlConfig `json:"runtime_control"` } +type HeartbeatConfig struct { + Enabled bool `json:"enabled" env:"CLAWGO_AGENTS_DEFAULTS_HEARTBEAT_ENABLED"` + EverySec int `json:"every_sec" env:"CLAWGO_AGENTS_DEFAULTS_HEARTBEAT_EVERY_SEC"` + AckMaxChars int `json:"ack_max_chars" env:"CLAWGO_AGENTS_DEFAULTS_HEARTBEAT_ACK_MAX_CHARS"` +} + type RuntimeControlConfig struct { IntentMaxInputChars int `json:"intent_max_input_chars" env:"CLAWGO_INTENT_MAX_INPUT_CHARS"` AutonomyTickIntervalSec int `json:"autonomy_tick_interval_sec" env:"CLAWGO_AUTONOMY_TICK_INTERVAL_SEC"` @@ -254,6 +261,11 @@ func DefaultConfig() *Config { MaxTokens: 8192, Temperature: 0.7, MaxToolIterations: 20, + Heartbeat: HeartbeatConfig{ + Enabled: true, + EverySec: 30 * 60, + AckMaxChars: 64, + }, ContextCompaction: ContextCompactionConfig{ Enabled: true, Mode: "summary", diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go index 9c10b3c..c4d6191 100644 --- a/pkg/heartbeat/service.go +++ b/pkg/heartbeat/service.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "clawgo/pkg/lifecycle" @@ -29,7 +30,7 @@ func NewHeartbeatService(workspace string, onHeartbeat func(string) (string, err func (hs *HeartbeatService) Start() error { if !hs.enabled { - return fmt.Errorf("heartbeat service is disabled") + return nil } hs.runner.Start(hs.runLoop) return nil @@ -73,24 +74,22 @@ func (hs *HeartbeatService) checkHeartbeat() { } func (hs *HeartbeatService) buildPrompt() string { - notesDir := filepath.Join(hs.workspace, "memory") - notesFile := filepath.Join(notesDir, "HEARTBEAT.md") + notesFile := filepath.Join(hs.workspace, "HEARTBEAT.md") var notes string if data, err := os.ReadFile(notesFile); err == nil { - notes = string(data) + candidate := string(data) + if !isEffectivelyEmptyMarkdown(candidate) { + notes = candidate + } } now := time.Now().Format("2006-01-02 15:04") - prompt := fmt.Sprintf(`# Heartbeat Check + prompt := fmt.Sprintf(`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK. Current time: %s -Check if there are any tasks I should be aware of or actions I should take. -Review the memory file for any important updates or changes. -Be proactive in identifying potential issues or improvements. - %s `, now, notes) @@ -108,3 +107,17 @@ func (hs *HeartbeatService) log(message string) { timestamp := time.Now().Format("2006-01-02 15:04:05") f.WriteString(fmt.Sprintf("[%s] %s\n", timestamp, message)) } + +func isEffectivelyEmptyMarkdown(content string) bool { + for _, line := range strings.Split(content, "\n") { + t := strings.TrimSpace(line) + if t == "" { + continue + } + if strings.HasPrefix(t, "#") { + continue + } + return false + } + return true +}