feat: inject plan-mode reminders during tool-call iterations

Add buildPlanReminder to emit interview/review workflow reminders each
iteration (after the first), preventing the LLM from losing focus during
tool-call loops.  Remove the hard interviewMaxIter cap in favour of the
softer reminder approach.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-22 00:24:48 +09:00
parent 9c4968e2cc
commit 1db86f79d1
2 changed files with 63 additions and 8 deletions

View file

@ -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,

View file

@ -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()) {