feat: background plan execution tracking and continuation nudge

Heartbeat/cron running with NoHistory loses context between invocations.
Add two mechanisms to ensure plan progress is recorded in MEMORY.md:

1. System prompt preamble: appends "Background Execution" section to
   system prompt (high attention position) reminding LLM to mark [x]
2. Tool→text transition nudge: at the boundary where LLM switches from
   tool calls to text response, check if unchecked step count decreased.
   If not, inject one continuation message that serves as both a marking
   reminder and a work continuation trigger.

Also fix string concatenation inefficiencies:
- buildRichStatus: use strings.Builder for prefix assembly
- error header: sequential WriteString instead of + concat
- displayAvailableSkills: fmt.Fprintf instead of WriteString(Sprintf)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-22 04:14:48 +09:00
parent 481819deb9
commit 717add953b

View file

@ -748,7 +748,21 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
}) })
} }
// 2c. Snapshot plan status and MEMORY.md size before LLM iteration. // 2c. Background plan preamble: append to system prompt (high attention)
// so the LLM knows from the start that it must mark steps [x].
if opts.Background && agent.ContextBuilder.HasActivePlan() && agent.ContextBuilder.GetPlanStatus() == "executing" {
if len(messages) > 0 && messages[0].Role == "system" {
var sb strings.Builder
sb.WriteString(messages[0].Content)
sb.WriteString("\n\n## Background Execution\n")
sb.WriteString("You are running as a background heartbeat with no conversation history. ")
sb.WriteString("MEMORY.md is the only shared state between heartbeats. ")
sb.WriteString("After completing each plan step, immediately use edit_file to mark it [x] in memory/MEMORY.md.")
messages[0].Content = sb.String()
}
}
// 2d. Snapshot plan status and MEMORY.md size before LLM iteration.
preStatus := agent.ContextBuilder.GetPlanStatus() preStatus := agent.ContextBuilder.GetPlanStatus()
var preMemoryLen int var preMemoryLen int
if preStatus == "interviewing" { if preStatus == "interviewing" {
@ -1121,14 +1135,20 @@ func buildRichStatus(task *activeTask, isBackground bool, workspace string) stri
// Latest entry: always exactly 4 lines // Latest entry: always exactly 4 lines
if latest != nil { if latest != nil {
// Line 1-2: command (truncated to ~2 Telegram lines) // Line 1-2: command (truncated to ~2 Telegram lines)
prefix := latest.Name var lb strings.Builder
lb.WriteString(latest.Name)
if latest.ArgsSnip != "" { if latest.ArgsSnip != "" {
prefix += " " + latest.ArgsSnip lb.WriteByte(' ')
lb.WriteString(latest.ArgsSnip)
} }
prefix := lb.String()
if runes := []rune(prefix); len(runes) > maxLatestWidth { if runes := []rune(prefix); len(runes) > maxLatestWidth {
prefix = string(runes[:maxLatestWidth-1]) + "\u2026" sb.WriteString(string(runes[:maxLatestWidth-1]))
sb.WriteString("\u2026\n")
} else {
sb.WriteString(prefix)
sb.WriteByte('\n')
} }
fmt.Fprintf(&sb, "%s\n", prefix)
// Line 3: result // Line 3: result
fmt.Fprintf(&sb, " %s\n", latest.Result) fmt.Fprintf(&sb, " %s\n", latest.Result)
} else { } else {
@ -1146,8 +1166,9 @@ func buildRichStatus(task *activeTask, isBackground bool, workspace string) stri
errEntry := task.lastError errEntry := task.lastError
if errEntry != nil { if errEntry != nil {
// Header: tool name + result marker // Header: tool name + result marker
header := formatCompactEntry(*errEntry) sb.WriteString("\u274C ")
sb.WriteString("\u274C " + header + "\n") sb.WriteString(formatCompactEntry(*errEntry))
sb.WriteByte('\n')
// Detail lines // Detail lines
var detailLines []string var detailLines []string
@ -1189,9 +1210,16 @@ func (al *AgentLoop) runLLMIteration(
iteration := 0 iteration := 0
var finalContent string var finalContent string
lastReminderIdx := -1 lastReminderIdx := -1
planMarkNudged := false // true after we've already nudged once for [x] marking
maxIter := agent.MaxIterations maxIter := agent.MaxIterations
// Snapshot unchecked step count before tool loop so we can detect progress.
preUnchecked := -1 // -1 = not tracking
if opts.Background && agent.ContextBuilder.GetPlanStatus() == "executing" {
preUnchecked = strings.Count(agent.ContextBuilder.ReadMemory(), "- [ ]")
}
// Determine if this is a background task (cron, heartbeat, etc.) // Determine if this is a background task (cron, heartbeat, etc.)
isBackground := opts.TaskID != "" isBackground := opts.TaskID != ""
@ -1342,6 +1370,35 @@ func (al *AgentLoop) runLLMIteration(
// Check if no tool calls - we're done // Check if no tool calls - we're done
if len(response.ToolCalls) == 0 { if len(response.ToolCalls) == 0 {
// Background plan continuation: if unchecked steps remain and
// none were marked during this heartbeat, nudge the LLM to
// either mark completed steps or continue working on them.
// This serves as both a marking reminder and a continuation trigger
// (otherwise remaining steps wait until the next heartbeat).
curUnchecked := 0
if preUnchecked > 0 {
curUnchecked = strings.Count(agent.ContextBuilder.ReadMemory(), "- [ ]")
}
if preUnchecked > 0 && !planMarkNudged &&
agent.ContextBuilder.GetPlanStatus() == "executing" &&
curUnchecked == preUnchecked {
planMarkNudged = true
messages = append(messages, providers.Message{
Role: "assistant",
Content: response.Content,
})
messages = append(messages, providers.Message{
Role: "user",
Content: fmt.Sprintf("[System] %d unchecked steps remain in MEMORY.md and none were marked [x] during this session. "+
"If you completed any steps, use edit_file to mark them [x] now. "+
"If steps are still in progress, continue working on them.",
curUnchecked),
})
logger.InfoCF("agent", "Nudging background task: mark or continue plan steps",
map[string]any{"agent_id": agent.ID, "iteration": iteration, "unchecked": curUnchecked})
continue
}
finalContent = response.Content finalContent = response.Content
logger.InfoCF("agent", "LLM response without tool calls (direct answer)", logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
map[string]any{ map[string]any{
@ -1580,6 +1637,7 @@ func (al *AgentLoop) runLLMIteration(
}) })
} }
} }
} }
// If max iterations exhausted with tool calls still pending, // If max iterations exhausted with tool calls still pending,
@ -2113,9 +2171,9 @@ func (al *AgentLoop) handleSkillsCommand() string {
var sb strings.Builder var sb strings.Builder
sb.WriteString("Available Skills\n\n") sb.WriteString("Available Skills\n\n")
for _, s := range skillsList { for _, s := range skillsList {
sb.WriteString(fmt.Sprintf("**%s** (%s)\n", s.Name, s.Source)) fmt.Fprintf(&sb, "**%s** (%s)\n", s.Name, s.Source)
if s.Description != "" { if s.Description != "" {
sb.WriteString(fmt.Sprintf("```\n%s\n```\n", s.Description)) fmt.Fprintf(&sb, "```\n%s\n```\n", s.Description)
} }
} }
sb.WriteString("\nUse: /skill <name> [message]") sb.WriteString("\nUse: /skill <name> [message]")