From 84ee5dc0c95d2fd2e622d689abb320d4d25c2069 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 16:42:04 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20continuous=20autonomous=20operation=20?= =?UTF-8?q?=E2=80=94=20re-trigger=20after=20every=20productive=20cycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the autonomous heartbeat fired once every N minutes then stopped, even if there was more work to do. Now: 1. WorkDone flag on ToolResult: the heartbeat handler sets WorkDone=true whenever the agent did real work (response != HEARTBEAT_OK). The heartbeat service uses this to decide whether to re-trigger immediately. 2. Continuous re-trigger: runLoop re-schedules the next cycle after a short cooldown (5s) when WorkDone=true, instead of waiting the full interval. A maxConsecutiveCycles cap (20) prevents runaway loops. 3. Persistent session history: ProcessHeartbeat now uses NoHistory=false in autonomous mode (session key "autonomous"), so the agent builds context across consecutive cycles and knows what it did in the previous step. 4. exec allowed in autonomous mode: Added "autonomous" and "heartbeat" to the internalChannels map, and the heartbeat handler always uses the "autonomous" channel for the agent session so exec/shell tools work. https://claude.ai/code/session_0118WWK5KLRM7ZBUoC8EMgKS --- pkg/agent/loop.go | 15 ++++--- pkg/constants/channels.go | 8 ++-- pkg/gateway/gateway.go | 12 ++++- pkg/heartbeat/service.go | 94 ++++++++++++++++++++++++++++++--------- pkg/tools/result.go | 5 +++ 5 files changed, 102 insertions(+), 32 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 81ab95056..862f566bd 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -689,8 +689,9 @@ func (al *AgentLoop) ProcessDirectWithChannel( return al.processMessage(ctx, msg) } -// ProcessHeartbeat processes a heartbeat request without session history. -// Each heartbeat is independent and doesn't accumulate context. +// ProcessHeartbeat processes a heartbeat request. +// In autonomous mode, session history is preserved across cycles so the agent +// can build context continuously. In standard mode, each heartbeat is isolated. func (al *AgentLoop) ProcessHeartbeat( ctx context.Context, content, channel, chatID string, @@ -699,15 +700,19 @@ func (al *AgentLoop) ProcessHeartbeat( if agent == nil { return "", fmt.Errorf("no default agent for heartbeat") } + + cfg := al.GetConfig() + autonomousMode := cfg.Autonomous.IsEnabled() + return al.runAgentLoop(ctx, agent, processOptions{ - SessionKey: "heartbeat", + SessionKey: "autonomous", Channel: channel, ChatID: chatID, UserMessage: content, DefaultResponse: defaultResponse, - EnableSummary: false, + EnableSummary: autonomousMode, // summarize when context grows in long sessions SendResponse: false, - NoHistory: true, // Don't load session history for heartbeat + NoHistory: !autonomousMode, // preserve history in autonomous mode }) } diff --git a/pkg/constants/channels.go b/pkg/constants/channels.go index 0a46e6cd9..44d6a014d 100644 --- a/pkg/constants/channels.go +++ b/pkg/constants/channels.go @@ -4,9 +4,11 @@ package constants // internalChannels defines channels that are used for internal communication // and should not be exposed to external users or recorded as last active channel. var internalChannels = map[string]struct{}{ - "cli": {}, - "system": {}, - "subagent": {}, + "cli": {}, + "system": {}, + "subagent": {}, + "autonomous": {}, // heartbeat / autonomous mode cycles + "heartbeat": {}, } // IsInternalChannel returns true if the channel is an internal channel. diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index e4be811ce..b5f0f8713 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -668,13 +668,21 @@ func createHeartbeatHandler(agentLoop *agent.AgentLoop) func(prompt, channel, ch channel, chatID = "cli", "direct" } - response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID) + // Always run the agent session on the "autonomous" internal channel so that + // tools like exec (which require an internal channel) work in autonomous mode. + // The original channel/chatID is only used for sending responses back to the user. + response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, "autonomous", chatID) 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) + // Real work was done — signal the heartbeat service to re-trigger immediately + return &tools.ToolResult{ + ForLLM: response, + Silent: true, + WorkDone: true, + } } } diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go index adbfa8186..e145ae6ce 100644 --- a/pkg/heartbeat/service.go +++ b/pkg/heartbeat/service.go @@ -26,6 +26,11 @@ import ( const ( minIntervalMinutes = 5 defaultIntervalMinutes = 30 + // continuousCooldown is the pause between cycles when the agent did real work. + // Short enough to feel continuous, long enough to avoid hammering the LLM. + continuousCooldown = 5 * time.Second + // maxConsecutiveCycles caps back-to-back productive cycles before a longer pause. + maxConsecutiveCycles = 20 ) // HeartbeatHandler is the function type for handling heartbeat. @@ -44,6 +49,9 @@ type HeartbeatService struct { autonomousEnabled bool // when true, heartbeat drives goal-pursuit loop mu sync.RWMutex stopChan chan struct{} + // retrigger is signalled by executeHeartbeat when WorkDone=true + // so the runLoop can immediately schedule the next cycle instead of waiting. + retrigger chan struct{} } // NewHeartbeatService creates a new heartbeat service @@ -62,6 +70,7 @@ func NewHeartbeatService(workspace string, intervalMinutes int, enabled bool) *H interval: time.Duration(intervalMinutes) * time.Minute, enabled: enabled, state: state.NewManager(workspace), + retrigger: make(chan struct{}, 1), } } @@ -140,34 +149,74 @@ func (hs *HeartbeatService) runLoop(stopChan chan struct{}) { ticker := time.NewTicker(hs.interval) defer ticker.Stop() - // Run first heartbeat after initial delay + consecutiveCycles := 0 + + // Run first heartbeat after a brief startup delay time.AfterFunc(time.Second, func() { - hs.executeHeartbeat() + select { + case hs.retrigger <- struct{}{}: + default: + } }) for { select { case <-stopChan: return + + case <-hs.retrigger: + // Drain the ticker so it doesn't fire again right after + select { + case <-ticker.C: + default: + } + ticker.Reset(hs.interval) + + workDone := hs.executeHeartbeat() + + if workDone && hs.autonomousEnabled { + consecutiveCycles++ + if consecutiveCycles >= maxConsecutiveCycles { + // Take a longer rest to avoid runaway loops + consecutiveCycles = 0 + logger.InfoC("heartbeat", "Reached max consecutive cycles, pausing before next cycle") + ticker.Reset(hs.interval) + continue + } + // Schedule next cycle almost immediately + time.AfterFunc(continuousCooldown, func() { + select { + case hs.retrigger <- struct{}{}: + default: + } + }) + } else { + consecutiveCycles = 0 + } + case <-ticker.C: + consecutiveCycles = 0 hs.executeHeartbeat() + ticker.Reset(hs.interval) } } } -// executeHeartbeat performs a single heartbeat check -func (hs *HeartbeatService) executeHeartbeat() { +// executeHeartbeat performs a single heartbeat check. +// Returns true if meaningful work was done (WorkDone=true in the result), +// which signals the caller to re-trigger the next cycle immediately. +func (hs *HeartbeatService) executeHeartbeat() bool { hs.mu.RLock() enabled := hs.enabled handler := hs.handler if !hs.enabled || hs.stopChan == nil { hs.mu.RUnlock() - return + return false } hs.mu.RUnlock() if !enabled { - return + return false } logger.DebugC("heartbeat", "Executing heartbeat") @@ -175,12 +224,12 @@ func (hs *HeartbeatService) executeHeartbeat() { prompt := hs.buildPrompt() if prompt == "" { logger.InfoC("heartbeat", "No heartbeat prompt (HEARTBEAT.md empty or missing)") - return + return false } if handler == nil { hs.logErrorf("Heartbeat handler not configured") - return + return false } // Get last channel info for context @@ -194,13 +243,13 @@ func (hs *HeartbeatService) executeHeartbeat() { if result == nil { hs.logInfof("Heartbeat handler returned nil result") - return + return false } // Handle different result types if result.IsError { hs.logErrorf("Heartbeat error: %s", result.ForLLM) - return + return false } if result.Async { @@ -209,23 +258,24 @@ func (hs *HeartbeatService) executeHeartbeat() { map[string]any{ "message": result.ForLLM, }) - return + return false } - // Check if silent - if result.Silent { - hs.logInfof("Heartbeat OK - silent") - return + // Send result to user if not silent + if !result.Silent { + if result.ForUser != "" { + hs.sendResponse(result.ForUser) + } else if result.ForLLM != "" { + hs.sendResponse(result.ForLLM) + } } - // Send result to user - if result.ForUser != "" { - hs.sendResponse(result.ForUser) - } else if result.ForLLM != "" { - hs.sendResponse(result.ForLLM) + if result.WorkDone { + hs.logInfof("Heartbeat cycle: work done, re-triggering immediately") + } else { + hs.logInfof("Heartbeat OK - idle") } - - hs.logInfof("Heartbeat completed: %s", result.ForLLM) + return result.WorkDone } // buildPrompt builds the heartbeat prompt from HEARTBEAT.md and optionally GOALS.md. diff --git a/pkg/tools/result.go b/pkg/tools/result.go index cab833284..4c4df3fac 100644 --- a/pkg/tools/result.go +++ b/pkg/tools/result.go @@ -27,6 +27,11 @@ type ToolResult struct { // When true, the tool will complete later and notify via callback. Async bool `json:"async"` + // WorkDone indicates that meaningful work was performed. + // Used by the heartbeat service to decide whether to re-trigger immediately + // rather than waiting for the next scheduled interval. + WorkDone bool `json:"work_done,omitempty"` + // Err is the underlying error (not JSON serialized). // Used for internal error handling and logging. Err error `json:"-"`