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
|
|
@ -20,8 +20,9 @@ type AgentInstance struct {
|
||||||
Model string
|
Model string
|
||||||
Fallbacks []string
|
Fallbacks []string
|
||||||
Workspace string
|
Workspace string
|
||||||
MaxIterations int
|
MaxIterations int
|
||||||
ContextWindow int
|
TaskReminderInterval int
|
||||||
|
ContextWindow int
|
||||||
Provider providers.LLMProvider
|
Provider providers.LLMProvider
|
||||||
Sessions *session.SessionManager
|
Sessions *session.SessionManager
|
||||||
ContextBuilder *ContextBuilder
|
ContextBuilder *ContextBuilder
|
||||||
|
|
@ -76,6 +77,11 @@ func NewAgentInstance(
|
||||||
maxIter = 20
|
maxIter = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reminderInterval := defaults.TaskReminderInterval
|
||||||
|
if reminderInterval == 0 {
|
||||||
|
reminderInterval = 5
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve fallback candidates
|
// Resolve fallback candidates
|
||||||
modelCfg := providers.ModelConfig{
|
modelCfg := providers.ModelConfig{
|
||||||
Primary: model,
|
Primary: model,
|
||||||
|
|
@ -89,8 +95,9 @@ func NewAgentInstance(
|
||||||
Model: model,
|
Model: model,
|
||||||
Fallbacks: fallbacks,
|
Fallbacks: fallbacks,
|
||||||
Workspace: workspace,
|
Workspace: workspace,
|
||||||
MaxIterations: maxIter,
|
MaxIterations: maxIter,
|
||||||
ContextWindow: defaults.MaxTokens,
|
TaskReminderInterval: reminderInterval,
|
||||||
|
ContextWindow: defaults.MaxTokens,
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
Sessions: sessionsManager,
|
Sessions: sessionsManager,
|
||||||
ContextBuilder: contextBuilder,
|
ContextBuilder: contextBuilder,
|
||||||
|
|
|
||||||
|
|
@ -444,6 +444,40 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
return finalContent, nil
|
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.
|
// 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) {
|
func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, messages []providers.Message, opts processOptions) (string, int, error) {
|
||||||
iteration := 0
|
iteration := 0
|
||||||
|
|
@ -611,6 +645,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)
|
agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)
|
||||||
|
|
||||||
// Execute tool calls
|
// Execute tool calls
|
||||||
|
var lastBlocker string
|
||||||
for _, tc := range response.ToolCalls {
|
for _, tc := range response.ToolCalls {
|
||||||
argsJSON, _ := json.Marshal(tc.Arguments)
|
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||||
argsPreview := utils.Truncate(string(argsJSON), 200)
|
argsPreview := utils.Truncate(string(argsJSON), 200)
|
||||||
|
|
@ -659,6 +694,11 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
contentForLLM = toolResult.Err.Error()
|
contentForLLM = toolResult.Err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track blockers for task reminder
|
||||||
|
if toolResult.IsError || toolResult.Err != nil {
|
||||||
|
lastBlocker = contentForLLM
|
||||||
|
}
|
||||||
|
|
||||||
toolResultMsg := providers.Message{
|
toolResultMsg := providers.Message{
|
||||||
Role: "tool",
|
Role: "tool",
|
||||||
Content: contentForLLM,
|
Content: contentForLLM,
|
||||||
|
|
@ -669,6 +709,18 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
// Save tool result message to session
|
// Save tool result message to session
|
||||||
agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
|
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
|
return finalContent, iteration, nil
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -628,3 +629,93 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
||||||
t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,8 @@ type AgentDefaults struct {
|
||||||
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
||||||
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
||||||
Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
||||||
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
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 {
|
type ChannelsConfig struct {
|
||||||
|
|
@ -331,7 +332,8 @@ func DefaultConfig() *Config {
|
||||||
Model: "glm-4.7",
|
Model: "glm-4.7",
|
||||||
MaxTokens: 8192,
|
MaxTokens: 8192,
|
||||||
Temperature: 0.7,
|
Temperature: 0.7,
|
||||||
MaxToolIterations: 20,
|
MaxToolIterations: 20,
|
||||||
|
TaskReminderInterval: 5,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Channels: ChannelsConfig{
|
Channels: ChannelsConfig{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue