feat(agent): add in-loop task reminder to prevent focus drift
Inject ephemeral user-role reminders every N iterations in the tool call loop so the LLM retains the original task instruction even when tool results flood the message array. Includes blocker tracking from failed tool results. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
dc5b98cbcc
commit
5fe734e2aa
4 changed files with 158 additions and 6 deletions
|
|
@ -21,6 +21,7 @@ type AgentInstance struct {
|
|||
Fallbacks []string
|
||||
Workspace string
|
||||
MaxIterations int
|
||||
TaskReminderInterval int
|
||||
ContextWindow int
|
||||
Provider providers.LLMProvider
|
||||
Sessions *session.SessionManager
|
||||
|
|
@ -76,6 +77,11 @@ func NewAgentInstance(
|
|||
maxIter = 20
|
||||
}
|
||||
|
||||
reminderInterval := defaults.TaskReminderInterval
|
||||
if reminderInterval == 0 {
|
||||
reminderInterval = 5
|
||||
}
|
||||
|
||||
// Resolve fallback candidates
|
||||
modelCfg := providers.ModelConfig{
|
||||
Primary: model,
|
||||
|
|
@ -90,6 +96,7 @@ func NewAgentInstance(
|
|||
Fallbacks: fallbacks,
|
||||
Workspace: workspace,
|
||||
MaxIterations: maxIter,
|
||||
TaskReminderInterval: reminderInterval,
|
||||
ContextWindow: defaults.MaxTokens,
|
||||
Provider: provider,
|
||||
Sessions: sessionsManager,
|
||||
|
|
|
|||
|
|
@ -444,6 +444,40 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
|||
return finalContent, nil
|
||||
}
|
||||
|
||||
// Task reminder constants and helpers.
|
||||
const taskReminderMaxChars = 500
|
||||
const blockerMaxChars = 200
|
||||
|
||||
func shouldInjectReminder(iteration, interval int) bool {
|
||||
if interval <= 0 {
|
||||
return false
|
||||
}
|
||||
return iteration > 1 && iteration%interval == 0
|
||||
}
|
||||
|
||||
func buildTaskReminder(userMessage string, lastBlocker string) providers.Message {
|
||||
truncatedTask := utils.Truncate(userMessage, taskReminderMaxChars)
|
||||
|
||||
var content string
|
||||
if lastBlocker != "" {
|
||||
truncatedBlocker := utils.Truncate(lastBlocker, blockerMaxChars)
|
||||
content = fmt.Sprintf(
|
||||
"[TASK REMINDER]\nOriginal task:\n---\n%s\n---\nLast blocker:\n---\n%s\n---\nDecide: fix the blocker if it's essential, or find an alternative approach to complete the original task.",
|
||||
truncatedTask, truncatedBlocker,
|
||||
)
|
||||
} else {
|
||||
content = fmt.Sprintf(
|
||||
"[TASK REMINDER]\nOriginal task:\n---\n%s\n---\nContinue with the next step toward completing this task.",
|
||||
truncatedTask,
|
||||
)
|
||||
}
|
||||
|
||||
return providers.Message{
|
||||
Role: "user",
|
||||
Content: content,
|
||||
}
|
||||
}
|
||||
|
||||
// runLLMIteration executes the LLM call loop with tool handling.
|
||||
func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, messages []providers.Message, opts processOptions) (string, int, error) {
|
||||
iteration := 0
|
||||
|
|
@ -611,6 +645,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
|||
agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)
|
||||
|
||||
// Execute tool calls
|
||||
var lastBlocker string
|
||||
for _, tc := range response.ToolCalls {
|
||||
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||
argsPreview := utils.Truncate(string(argsJSON), 200)
|
||||
|
|
@ -659,6 +694,11 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
|||
contentForLLM = toolResult.Err.Error()
|
||||
}
|
||||
|
||||
// Track blockers for task reminder
|
||||
if toolResult.IsError || toolResult.Err != nil {
|
||||
lastBlocker = contentForLLM
|
||||
}
|
||||
|
||||
toolResultMsg := providers.Message{
|
||||
Role: "tool",
|
||||
Content: contentForLLM,
|
||||
|
|
@ -669,6 +709,18 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
|||
// Save tool result message to session
|
||||
agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
|
||||
}
|
||||
|
||||
// Inject ephemeral task reminder to prevent focus drift.
|
||||
if shouldInjectReminder(iteration, agent.TaskReminderInterval) && !opts.NoHistory {
|
||||
reminderMsg := buildTaskReminder(opts.UserMessage, lastBlocker)
|
||||
messages = append(messages, reminderMsg)
|
||||
logger.DebugCF("agent", "Injected task reminder",
|
||||
map[string]interface{}{
|
||||
"agent_id": agent.ID,
|
||||
"iteration": iteration,
|
||||
"has_blocker": lastBlocker != "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return finalContent, iteration, nil
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -628,3 +629,93 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
|||
t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory))
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldInjectReminder(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
iteration int
|
||||
interval int
|
||||
want bool
|
||||
}{
|
||||
{"first iteration skipped", 1, 5, false},
|
||||
{"iteration 5 interval 5", 5, 5, true},
|
||||
{"iteration 10 interval 5", 10, 5, true},
|
||||
{"iteration 3 interval 5", 3, 5, false},
|
||||
{"interval zero disabled", 5, 0, false},
|
||||
{"interval negative disabled", 5, -1, false},
|
||||
{"iteration 2 interval 1", 2, 1, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := shouldInjectReminder(tt.iteration, tt.interval)
|
||||
if got != tt.want {
|
||||
t.Errorf("shouldInjectReminder(%d, %d) = %v, want %v", tt.iteration, tt.interval, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTaskReminder_WithoutBlocker(t *testing.T) {
|
||||
msg := buildTaskReminder("implement feature X", "")
|
||||
if msg.Role != "user" {
|
||||
t.Errorf("expected role 'user', got %q", msg.Role)
|
||||
}
|
||||
if !strings.Contains(msg.Content, "[TASK REMINDER]") {
|
||||
t.Error("expected content to contain '[TASK REMINDER]'")
|
||||
}
|
||||
if !strings.Contains(msg.Content, "implement feature X") {
|
||||
t.Error("expected content to contain original message")
|
||||
}
|
||||
if strings.Contains(msg.Content, "blocker") {
|
||||
t.Error("expected content NOT to contain 'blocker' when no blocker provided")
|
||||
}
|
||||
if !strings.Contains(msg.Content, "Continue with the next step") {
|
||||
t.Error("expected content to contain continuation prompt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTaskReminder_WithBlocker(t *testing.T) {
|
||||
msg := buildTaskReminder("implement feature X", "ModuleNotFoundError: No module named 'foo'")
|
||||
if msg.Role != "user" {
|
||||
t.Errorf("expected role 'user', got %q", msg.Role)
|
||||
}
|
||||
if !strings.Contains(msg.Content, "[TASK REMINDER]") {
|
||||
t.Error("expected content to contain '[TASK REMINDER]'")
|
||||
}
|
||||
if !strings.Contains(msg.Content, "implement feature X") {
|
||||
t.Error("expected content to contain original message")
|
||||
}
|
||||
if !strings.Contains(msg.Content, "Last blocker") {
|
||||
t.Error("expected content to contain 'Last blocker'")
|
||||
}
|
||||
if !strings.Contains(msg.Content, "ModuleNotFoundError") {
|
||||
t.Error("expected content to contain blocker text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTaskReminder_Truncation(t *testing.T) {
|
||||
// Build a long message (1000 runes)
|
||||
longMsg := strings.Repeat("あ", 1000)
|
||||
longBlocker := strings.Repeat("X", 500)
|
||||
|
||||
msg := buildTaskReminder(longMsg, longBlocker)
|
||||
|
||||
// The full message should NOT contain 1000 'あ' characters
|
||||
runeCount := strings.Count(msg.Content, "あ")
|
||||
if runeCount >= 1000 {
|
||||
t.Errorf("expected task message to be truncated, got %d 'あ' runes", runeCount)
|
||||
}
|
||||
// Should be at most taskReminderMaxChars (500) runes for the task part
|
||||
if runeCount > taskReminderMaxChars {
|
||||
t.Errorf("expected at most %d task runes, got %d", taskReminderMaxChars, runeCount)
|
||||
}
|
||||
|
||||
// Blocker should be truncated too
|
||||
xCount := strings.Count(msg.Content, "X")
|
||||
if xCount >= 500 {
|
||||
t.Errorf("expected blocker to be truncated, got %d 'X' chars", xCount)
|
||||
}
|
||||
if xCount > blockerMaxChars {
|
||||
t.Errorf("expected at most %d blocker chars, got %d", blockerMaxChars, xCount)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ type AgentDefaults struct {
|
|||
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
||||
Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
||||
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
||||
TaskReminderInterval int `json:"task_reminder_interval" env:"PICOCLAW_AGENTS_DEFAULTS_TASK_REMINDER_INTERVAL"`
|
||||
}
|
||||
|
||||
type ChannelsConfig struct {
|
||||
|
|
@ -332,6 +333,7 @@ func DefaultConfig() *Config {
|
|||
MaxTokens: 8192,
|
||||
Temperature: 0.7,
|
||||
MaxToolIterations: 20,
|
||||
TaskReminderInterval: 5,
|
||||
},
|
||||
},
|
||||
Channels: ChannelsConfig{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue