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:
parent
00a0bd4a7c
commit
84ee5dc0c9
5 changed files with 102 additions and 32 deletions
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ var internalChannels = map[string]struct{}{
|
|||
"cli": {},
|
||||
"system": {},
|
||||
"subagent": {},
|
||||
"autonomous": {}, // heartbeat / autonomous mode cycles
|
||||
"heartbeat": {},
|
||||
}
|
||||
|
||||
// IsInternalChannel returns true if the channel is an internal channel.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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:"-"`
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue