feat: continuous autonomous operation — re-trigger after every productive cycle

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
This commit is contained in:
Claude 2026-03-21 16:42:04 +00:00
parent 00a0bd4a7c
commit 84ee5dc0c9
No known key found for this signature in database
5 changed files with 102 additions and 32 deletions

View file

@ -689,8 +689,9 @@ func (al *AgentLoop) ProcessDirectWithChannel(
return al.processMessage(ctx, msg) return al.processMessage(ctx, msg)
} }
// ProcessHeartbeat processes a heartbeat request without session history. // ProcessHeartbeat processes a heartbeat request.
// Each heartbeat is independent and doesn't accumulate context. // 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( func (al *AgentLoop) ProcessHeartbeat(
ctx context.Context, ctx context.Context,
content, channel, chatID string, content, channel, chatID string,
@ -699,15 +700,19 @@ func (al *AgentLoop) ProcessHeartbeat(
if agent == nil { if agent == nil {
return "", fmt.Errorf("no default agent for heartbeat") return "", fmt.Errorf("no default agent for heartbeat")
} }
cfg := al.GetConfig()
autonomousMode := cfg.Autonomous.IsEnabled()
return al.runAgentLoop(ctx, agent, processOptions{ return al.runAgentLoop(ctx, agent, processOptions{
SessionKey: "heartbeat", SessionKey: "autonomous",
Channel: channel, Channel: channel,
ChatID: chatID, ChatID: chatID,
UserMessage: content, UserMessage: content,
DefaultResponse: defaultResponse, DefaultResponse: defaultResponse,
EnableSummary: false, EnableSummary: autonomousMode, // summarize when context grows in long sessions
SendResponse: false, SendResponse: false,
NoHistory: true, // Don't load session history for heartbeat NoHistory: !autonomousMode, // preserve history in autonomous mode
}) })
} }

View file

@ -7,6 +7,8 @@ var internalChannels = map[string]struct{}{
"cli": {}, "cli": {},
"system": {}, "system": {},
"subagent": {}, "subagent": {},
"autonomous": {}, // heartbeat / autonomous mode cycles
"heartbeat": {},
} }
// IsInternalChannel returns true if the channel is an internal channel. // IsInternalChannel returns true if the channel is an internal channel.

View file

@ -668,13 +668,21 @@ func createHeartbeatHandler(agentLoop *agent.AgentLoop) func(prompt, channel, ch
channel, chatID = "cli", "direct" 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 { 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) // Real work was done — signal the heartbeat service to re-trigger immediately
return &tools.ToolResult{
ForLLM: response,
Silent: true,
WorkDone: true,
}
} }
} }

View file

@ -26,6 +26,11 @@ import (
const ( const (
minIntervalMinutes = 5 minIntervalMinutes = 5
defaultIntervalMinutes = 30 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. // HeartbeatHandler is the function type for handling heartbeat.
@ -44,6 +49,9 @@ type HeartbeatService struct {
autonomousEnabled bool // when true, heartbeat drives goal-pursuit loop autonomousEnabled bool // when true, heartbeat drives goal-pursuit loop
mu sync.RWMutex mu sync.RWMutex
stopChan chan struct{} 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 // 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, interval: time.Duration(intervalMinutes) * time.Minute,
enabled: enabled, enabled: enabled,
state: state.NewManager(workspace), 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) ticker := time.NewTicker(hs.interval)
defer ticker.Stop() defer ticker.Stop()
// Run first heartbeat after initial delay consecutiveCycles := 0
// Run first heartbeat after a brief startup delay
time.AfterFunc(time.Second, func() { time.AfterFunc(time.Second, func() {
hs.executeHeartbeat() select {
case hs.retrigger <- struct{}{}:
default:
}
}) })
for { for {
select { select {
case <-stopChan: case <-stopChan:
return return
case <-hs.retrigger:
// Drain the ticker so it doesn't fire again right after
select {
case <-ticker.C: 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() hs.executeHeartbeat()
ticker.Reset(hs.interval)
} }
} }
} }
// executeHeartbeat performs a single heartbeat check // executeHeartbeat performs a single heartbeat check.
func (hs *HeartbeatService) executeHeartbeat() { // 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() hs.mu.RLock()
enabled := hs.enabled enabled := hs.enabled
handler := hs.handler handler := hs.handler
if !hs.enabled || hs.stopChan == nil { if !hs.enabled || hs.stopChan == nil {
hs.mu.RUnlock() hs.mu.RUnlock()
return return false
} }
hs.mu.RUnlock() hs.mu.RUnlock()
if !enabled { if !enabled {
return return false
} }
logger.DebugC("heartbeat", "Executing heartbeat") logger.DebugC("heartbeat", "Executing heartbeat")
@ -175,12 +224,12 @@ func (hs *HeartbeatService) executeHeartbeat() {
prompt := hs.buildPrompt() prompt := hs.buildPrompt()
if prompt == "" { if prompt == "" {
logger.InfoC("heartbeat", "No heartbeat prompt (HEARTBEAT.md empty or missing)") logger.InfoC("heartbeat", "No heartbeat prompt (HEARTBEAT.md empty or missing)")
return return false
} }
if handler == nil { if handler == nil {
hs.logErrorf("Heartbeat handler not configured") hs.logErrorf("Heartbeat handler not configured")
return return false
} }
// Get last channel info for context // Get last channel info for context
@ -194,13 +243,13 @@ func (hs *HeartbeatService) executeHeartbeat() {
if result == nil { if result == nil {
hs.logInfof("Heartbeat handler returned nil result") hs.logInfof("Heartbeat handler returned nil result")
return return false
} }
// Handle different result types // Handle different result types
if result.IsError { if result.IsError {
hs.logErrorf("Heartbeat error: %s", result.ForLLM) hs.logErrorf("Heartbeat error: %s", result.ForLLM)
return return false
} }
if result.Async { if result.Async {
@ -209,23 +258,24 @@ func (hs *HeartbeatService) executeHeartbeat() {
map[string]any{ map[string]any{
"message": result.ForLLM, "message": result.ForLLM,
}) })
return return false
} }
// Check if silent // Send result to user if not silent
if result.Silent { if !result.Silent {
hs.logInfof("Heartbeat OK - silent")
return
}
// Send result to user
if result.ForUser != "" { if result.ForUser != "" {
hs.sendResponse(result.ForUser) hs.sendResponse(result.ForUser)
} else if result.ForLLM != "" { } else if result.ForLLM != "" {
hs.sendResponse(result.ForLLM) hs.sendResponse(result.ForLLM)
} }
}
hs.logInfof("Heartbeat completed: %s", result.ForLLM) if result.WorkDone {
hs.logInfof("Heartbeat cycle: work done, re-triggering immediately")
} else {
hs.logInfof("Heartbeat OK - idle")
}
return result.WorkDone
} }
// buildPrompt builds the heartbeat prompt from HEARTBEAT.md and optionally GOALS.md. // buildPrompt builds the heartbeat prompt from HEARTBEAT.md and optionally GOALS.md.

View file

@ -27,6 +27,11 @@ type ToolResult struct {
// When true, the tool will complete later and notify via callback. // When true, the tool will complete later and notify via callback.
Async bool `json:"async"` 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). // Err is the underlying error (not JSON serialized).
// Used for internal error handling and logging. // Used for internal error handling and logging.
Err error `json:"-"` Err error `json:"-"`