diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 0fd2b482b..5bc1cae34 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -666,21 +666,32 @@ func buildTaskReminder(userMessage string, lastBlocker string) providers.Message } } +// buildPlanReminder returns a reminder message for plan pre-execution states +// (interviewing / review) to keep the AI focused on the interview workflow +// during tool-call iterations. +func buildPlanReminder(planStatus string) (providers.Message, bool) { + var content string + switch planStatus { + case "interviewing": + content = "[System] You are interviewing the user to build a plan. " + + "Ask clarifying questions and save findings to ## Context in memory/MEMORY.md using edit_file. " + + "When you have enough information, write ## Phases and ## Commands sections." + case "review": + content = "[System] The plan is under review. " + + "Wait for the user to approve or request changes. Do not proceed with execution." + default: + return providers.Message{}, false + } + return providers.Message{Role: "user", Content: content}, true +} + // 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 var finalContent string lastReminderIdx := -1 - // During pre-execution plan modes (interviewing/review), cap iterations - // to prevent runaway tool loops. maxIter := agent.MaxIterations - if isPlanPreExecution(agent.ContextBuilder.GetPlanStatus()) { - const interviewMaxIter = 3 - if maxIter > interviewMaxIter { - maxIter = interviewMaxIter - } - } for iteration < maxIter { iteration++ @@ -953,6 +964,19 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, "has_blocker": lastBlocker != "", }) } + + // Inject plan-mode reminder to keep AI focused on interview/review workflow. + if iteration > 1 && isPlanPreExecution(agent.ContextBuilder.GetPlanStatus()) { + if reminder, ok := buildPlanReminder(agent.ContextBuilder.GetPlanStatus()); ok { + messages = append(messages, reminder) + logger.DebugCF("agent", "Injected plan reminder", + map[string]interface{}{ + "agent_id": agent.ID, + "iteration": iteration, + "plan_status": agent.ContextBuilder.GetPlanStatus(), + }) + } + } } // If max iterations exhausted with tool calls still pending, diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 83067db95..3b01f92ea 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -876,6 +876,37 @@ func TestBuildTaskReminder_Truncation(t *testing.T) { } } +func TestBuildPlanReminder(t *testing.T) { + tests := []struct { + name string + status string + wantOK bool + wantSubstr string + }{ + {"interviewing", "interviewing", true, "interviewing the user"}, + {"review", "review", true, "under review"}, + {"executing returns false", "executing", false, ""}, + {"empty returns false", "", false, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg, ok := buildPlanReminder(tt.status) + if ok != tt.wantOK { + t.Fatalf("buildPlanReminder(%q) ok = %v, want %v", tt.status, ok, tt.wantOK) + } + if !ok { + return + } + if msg.Role != "user" { + t.Errorf("expected role 'user', got %q", msg.Role) + } + if !strings.Contains(msg.Content, tt.wantSubstr) { + t.Errorf("expected content to contain %q, got %q", tt.wantSubstr, msg.Content) + } + }) + } +} + // ---------- /plan command tests ---------- func newTestAgentLoop(t *testing.T) (*AgentLoop, func()) {