Skip to main content

ralph/
loop_runner.rs

1use crate::compaction;
2use crate::config::Config;
3use crate::errors::{RalphError, Result};
4use crate::memory::MemoryStore;
5use crate::output::{Phase, Printer};
6use crate::providers::{ContentPart, LlmProvider, Message, MessageContent, Role};
7use crate::session::{Session, SessionStatus};
8use crate::tools::{
9    execute_read_only, is_read_only, search_tools::search_available, shell_tools, tool_defs,
10    ReadOnlyContext, ToolCall, ToolRegistry, ToolResult,
11};
12use indicatif::{ProgressBar, ProgressStyle};
13use std::path::PathBuf;
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::{Arc, Mutex};
16use std::time::Duration;
17
18pub struct LoopRunner {
19    normal_provider: Arc<dyn LlmProvider>,
20    /// Optional reasoning (extended thinking) provider used on turn 1 and after failures.
21    reasoning_provider: Option<Arc<dyn LlmProvider>>,
22    config: Config,
23    workspace: PathBuf,
24    printer: Arc<Printer>,
25    /// None = unlimited turns (interactive mode).
26    max_turns: Option<u32>,
27    dry_run: bool,
28    no_confirm: bool,
29    /// When true, skip the automatic `pip install -e .` / `npm install` etc.
30    /// that would normally run before the first LLM turn.
31    skip_workspace_setup: bool,
32    memory: Arc<Mutex<MemoryStore>>,
33    /// Languages to activate LSP for (one client per language).
34    lsp_languages: Vec<String>,
35    /// Shared flag: when true, show diff + confirm before every write_file.
36    diff_preview: Arc<AtomicBool>,
37    /// Shared flag: when true, expose PR/CI tools to the LLM.
38    pr_enabled: Arc<AtomicBool>,
39}
40
41impl LoopRunner {
42    pub fn new(
43        normal_provider: Arc<dyn LlmProvider>,
44        reasoning_provider: Option<Arc<dyn LlmProvider>>,
45        config: Config,
46        workspace: PathBuf,
47        printer: Arc<Printer>,
48        max_turns: Option<u32>,
49        dry_run: bool,
50        no_confirm: bool,
51        skip_workspace_setup: bool,
52        memory: Arc<Mutex<MemoryStore>>,
53        lsp_languages: Vec<String>,
54        diff_preview: Arc<AtomicBool>,
55        pr_enabled: Arc<AtomicBool>,
56    ) -> Self {
57        Self {
58            normal_provider,
59            reasoning_provider,
60            config,
61            workspace,
62            printer,
63            max_turns,
64            dry_run,
65            no_confirm,
66            skip_workspace_setup,
67            memory,
68            lsp_languages,
69            diff_preview,
70            pr_enabled,
71        }
72    }
73
74    pub async fn run(
75        &self,
76        prompt: &str,
77        session: &mut Session,
78        cancelled: Arc<AtomicBool>,
79    ) -> Result<()> {
80        let search_on = search_available(
81            &self.config.search.brave_api_key_env,
82            &self.config.search.serp_api_key_env,
83        );
84        let pr_on = self.pr_enabled.load(Ordering::Relaxed);
85        let tool_defs = tool_defs(search_on, pr_on);
86
87        // Compute tool schema overhead once — these tokens are sent with every
88        // API request but are not in session.messages, so they must be added to
89        // the threshold check to avoid hitting the context limit unexpectedly.
90        let tool_overhead_tokens = {
91            let json = serde_json::to_string(&tool_defs).unwrap_or_default();
92            compaction::count_tokens(&json)
93        };
94
95        if search_on {
96            self.printer.print(Phase::Ralph, "Web search enabled.");
97        } else {
98            self.printer.print(
99                Phase::Ralph,
100                "Web search unavailable (set BRAVE_API_KEY or SERP_API_KEY to enable).",
101            );
102        }
103
104        let mut tools = ToolRegistry::new(
105            self.workspace.clone(),
106            self.config.clone(),
107            Arc::clone(&self.printer),
108            self.no_confirm,
109            Arc::clone(&self.memory),
110            self.lsp_languages.clone(),
111            Arc::clone(&self.diff_preview),
112        );
113
114        session.log_event(&format!("Session started. Prompt: {}", prompt));
115
116        // Install project dependencies before the first LLM turn so the agent
117        // can run tests and builds without needing to set up the environment itself.
118        // Skipped when --no-setup is passed (e.g. by the benchmark harness which
119        // manages its own isolated venv).
120        if !self.skip_workspace_setup {
121            install_workspace_deps(&self.workspace, &self.printer).await;
122        }
123
124        session.messages.push(Message::user(prompt));
125        if let Err(e) = session.flush() {
126            self.printer
127                .print_error(&format!("Warning: failed to persist session: {}", e));
128        }
129
130        let auto_test_cmd = self.config.testing.resolved_cmd(&self.workspace);
131
132        let mut turn: u32 = 0;
133        let mut test_fail_count: u32 = 0;
134        // How many times we've nudged the LLM to stop planning and use tools.
135        // Capped at 5 to prevent infinite nudge loops.
136        let mut plan_nudge_count: u32 = 0;
137        // Consecutive turns without any file modification.
138        let mut turns_since_edit: u32 = 0;
139        // How many stall nudges have fired (reset on edit, exit after 3).
140        let mut stall_nudge_count: u32 = 0;
141        // Two-provider: use reasoning provider on turn 1 and after test failures.
142        let mut next_turn_uses_reasoning: bool = true; // turn 1 always uses reasoning
143                                                       // Token accumulators for the session.
144        let mut total_input_tokens: u64 = 0;
145        let mut total_output_tokens: u64 = 0;
146        let mut total_reasoning_tokens: u64 = 0;
147        // Track last-read turn per file path for duplicate eviction.
148        let mut file_last_read: std::collections::HashMap<String, u32> =
149            std::collections::HashMap::new();
150        // Track files that were edited (so we don't evict their next full read).
151        let mut files_edited_since_read: std::collections::HashSet<String> =
152            std::collections::HashSet::new();
153        // Consecutive edit_file failures (old_string not found) → trigger reasoning.
154        let mut consecutive_edit_failures: u32 = 0;
155        // Consecutive run_test failures (tests not passing) → trigger reasoning.
156        let mut consecutive_test_failures: u32 = 0;
157        // Per-turn action category: "read", "edit", "test", "cmd" — rolling window of last 5.
158        let mut recent_action_categories: std::collections::VecDeque<&'static str> =
159            std::collections::VecDeque::with_capacity(5);
160        loop {
161            turn += 1;
162
163            // Check for user cancellation between turns
164            if cancelled.load(Ordering::Relaxed) {
165                self.printer.print(Phase::Ralph, "Task interrupted.");
166                session.log_event("Task interrupted by user");
167                session.set_status(SessionStatus::Interrupted).ok();
168                return Err(RalphError::Interrupted);
169            }
170
171            // Enforce optional turn limit
172            if let Some(max) = self.max_turns {
173                if turn > max {
174                    self.printer.print(
175                        Phase::Failed,
176                        &format!("Reached max turns ({}). Task incomplete.", max),
177                    );
178                    session.log_event(&format!("Max turns ({}) reached", max));
179                    session.set_status(SessionStatus::Interrupted).ok();
180                    return Err(RalphError::MaxTurnsReached(max));
181                }
182            }
183
184            let turn_label = match self.max_turns {
185                Some(max) => format!("Turn {}/{}", turn, max),
186                None => format!("Turn {}", turn),
187            };
188            self.printer.print(Phase::Observe, &turn_label);
189            session.log_event(&format!("--- Turn {} ---", turn));
190
191            // ── OBSERVE: compact if needed ─────────────────────────────────────
192            if self.config.compaction.enabled
193                && compaction::should_compact(
194                    &session.messages,
195                    self.config.compaction.compact_at_tokens,
196                    tool_overhead_tokens,
197                )
198            {
199                self.printer
200                    .print(Phase::Compact, "Context approaching limit — compacting...");
201                session.log_event("Compacting context");
202
203                let keep = self.config.compaction.keep_recent_turns;
204                let compact_to = self.config.compaction.compact_to_tokens;
205
206                let result = compaction::compact(
207                    &session.messages,
208                    self.normal_provider.as_ref(),
209                    keep,
210                    compact_to,
211                )
212                .await;
213
214                let result = match result {
215                    Ok(r) => Ok(r),
216                    Err(e) => {
217                        self.printer.print(
218                            Phase::Compact,
219                            &format!(
220                                "Primary compaction failed ({}), retrying with split compaction...",
221                                e
222                            ),
223                        );
224                        session.log_event(&format!(
225                            "Primary compaction failed: {e}. Trying split compaction."
226                        ));
227                        compaction::compact_split(
228                            &session.messages,
229                            self.normal_provider.as_ref(),
230                            keep,
231                            compact_to,
232                        )
233                        .await
234                    }
235                };
236
237                match result {
238                    Ok((new_window, archive)) => {
239                        compaction::archive(&session.session_dir, &archive).ok();
240                        session.messages = new_window;
241                        self.printer.print(Phase::Compact, "Compaction complete.");
242                        session.log_event("Compaction complete");
243                    }
244                    Err(e) => {
245                        self.printer
246                            .print_error(&format!("Compaction failed: {}", e));
247                        session.log_event(&format!("Compaction failed: {}", e));
248                    }
249                }
250            }
251
252            // ── PLAN: select provider and call LLM ────────────────────────────
253            let active_provider: &Arc<dyn LlmProvider> = if next_turn_uses_reasoning {
254                self.reasoning_provider
255                    .as_ref()
256                    .unwrap_or(&self.normal_provider)
257            } else {
258                &self.normal_provider
259            };
260            // Reset for next turn — will be set again if a test failure is seen.
261            next_turn_uses_reasoning = false;
262
263            let was_streamed;
264            let llm_response = if active_provider.supports_streaming() {
265                was_streamed = true;
266                self.printer.print_streaming_start();
267                let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
268                let provider_clone = Arc::clone(active_provider);
269                let messages_snap = session.messages.clone();
270                let tools_snap = tool_defs.clone();
271
272                let task = tokio::spawn(async move {
273                    provider_clone
274                        .chat_streaming(&messages_snap, &tools_snap, &tx)
275                        .await
276                });
277
278                while let Some(token) = rx.recv().await {
279                    self.printer.print_streaming_token(&token);
280                }
281                self.printer.print_streaming_end();
282
283                match task.await {
284                    Ok(Ok(r)) => r,
285                    Ok(Err(e)) => {
286                        self.printer.print_error(&format!("LLM error: {}", e));
287                        session.log_event(&format!("LLM error: {}", e));
288                        session.set_status(SessionStatus::Failed).ok();
289                        return Err(e);
290                    }
291                    Err(e) => {
292                        let err = RalphError::ToolFailed {
293                            tool: "llm".to_string(),
294                            message: e.to_string(),
295                        };
296                        self.printer.print_error(&format!("LLM task error: {}", e));
297                        session.set_status(SessionStatus::Failed).ok();
298                        return Err(err);
299                    }
300                }
301            } else {
302                was_streamed = false;
303                self.printer.print(Phase::Plan, "Calling LLM...");
304                let spinner =
305                    make_spinner(&format!("  Waiting for {} ...", active_provider.name()));
306                match active_provider.chat(&session.messages, &tool_defs).await {
307                    Ok(r) => {
308                        spinner.finish_and_clear();
309                        r
310                    }
311                    Err(e) => {
312                        spinner.finish_and_clear();
313                        self.printer.print_error(&format!("LLM error: {}", e));
314                        session.log_event(&format!("LLM error: {}", e));
315                        session.set_status(SessionStatus::Failed).ok();
316                        return Err(e);
317                    }
318                }
319            };
320
321            // ── Token usage per turn ────────────────────────────────────────────
322            total_input_tokens += llm_response.input_tokens;
323            total_output_tokens += llm_response.output_tokens;
324            total_reasoning_tokens += llm_response.reasoning_tokens;
325            self.printer.print(
326                Phase::Observe,
327                &format!(
328                    "[tokens] in={} out={} reasoning={}",
329                    llm_response.input_tokens,
330                    llm_response.output_tokens,
331                    llm_response.reasoning_tokens
332                ),
333            );
334
335            session.log_event(&format!(
336                "LLM responded: {} tool calls, {} tokens",
337                llm_response.tool_calls.len(),
338                llm_response.tokens_used
339            ));
340
341            if llm_response.tool_calls.is_empty() {
342                let text = llm_response
343                    .text
344                    .as_deref()
345                    .unwrap_or("")
346                    .trim()
347                    .to_string();
348
349                if !text.is_empty() {
350                    // Skip reprint when the tokens were already output live via streaming.
351                    if !was_streamed {
352                        self.printer.print(Phase::Plan, &text);
353                    }
354                    let mut msg = Message::assistant(text.clone());
355                    msg.reasoning_content = llm_response.reasoning_content.clone();
356                    session.messages.push(msg);
357                }
358
359                // Two cases where we nudge instead of completing:
360                //
361                // A) Empty response — LLM returned nothing. Usually means it is
362                //    stuck (e.g. couldn't read a large file) and gave up silently.
363                //    Push it to try a different approach.
364                //
365                // B) Plan-without-execution — LLM described the fix in prose but
366                //    called no tool. Push it to actually make the edit.
367                //
368                // Both are capped at 5 nudges to prevent infinite loops.
369                let is_empty_response = text.is_empty();
370                let is_plan = looks_like_unexecuted_plan(&text);
371
372                if (is_empty_response || is_plan) && plan_nudge_count < 5 {
373                    plan_nudge_count += 1;
374                    let nudge = if is_empty_response {
375                        "Your last response was empty. You MUST call a tool in your next \
376                         response — for example `list_dir` or `read_file` to explore the repo, \
377                         or `edit_file` / `write_file` to apply your fix. Do NOT reply with \
378                         only text. Start by calling `list_dir` with path \".\" if you are \
379                         unsure where to begin."
380                    } else {
381                        "You described what needs to be done but did not call any tool. \
382                         You MUST call a tool in your next response — call `edit_file` or \
383                         `write_file` to apply the fix right now. Do not describe it again; \
384                         just do it. If you are not sure of the exact text to replace, call \
385                         `read_file` first to see the current file content."
386                    };
387                    self.printer.print(
388                        Phase::Verify,
389                        &format!(
390                            "[nudge {}/5] ({})",
391                            plan_nudge_count,
392                            if is_empty_response {
393                                "empty response"
394                            } else {
395                                "plan without execution"
396                            }
397                        ),
398                    );
399                    session.log_event(&format!(
400                        "Nudge {}/5: {}",
401                        plan_nudge_count,
402                        if is_empty_response {
403                            "empty response"
404                        } else {
405                            "plan without execution"
406                        }
407                    ));
408                    session.messages.push(Message::user(nudge));
409                    continue;
410                }
411
412                self.printer
413                    .print(Phase::Done, "Task complete (no further actions requested).");
414                session.log_event("Task complete");
415                session.set_status(SessionStatus::Done).ok();
416                self.printer.print(
417                    Phase::Ralph,
418                    &format!(
419                        "[tokens total] in={} out={} reasoning={}",
420                        total_input_tokens, total_output_tokens, total_reasoning_tokens
421                    ),
422                );
423                return Ok(());
424            }
425
426            // LLM called at least one tool — reset the plan nudge counter.
427            plan_nudge_count = 0;
428
429            // Tool calls present — store the assistant message with tool-use parts so
430            // that OpenAI/DeepSeek see a valid "tool_calls" → "tool" message sequence.
431            {
432                let mut parts: Vec<ContentPart> = Vec::new();
433                if let Some(ref text) = llm_response.text {
434                    if !text.is_empty() {
435                        // Skip reprint when the tokens were already output live via streaming.
436                        if !was_streamed {
437                            self.printer.print(Phase::Plan, text);
438                        }
439                        parts.push(ContentPart::Text { text: text.clone() });
440                    }
441                }
442                for tc in &llm_response.tool_calls {
443                    parts.push(ContentPart::ToolUse {
444                        id: tc.id.clone(),
445                        name: tc.name.clone(),
446                        input: tc.arguments.clone(),
447                    });
448                }
449                session.messages.push(Message {
450                    role: Role::Assistant,
451                    content: MessageContent::Parts(parts),
452                    tool_call_id: None,
453                    name: None,
454                    reasoning_content: llm_response.reasoning_content.clone(),
455                });
456            }
457
458            // ── EXECUTE ────────────────────────────────────────────────────────
459            let pre_turn_file_count = tools.modified_files.len();
460            let mut tool_results: Vec<ToolResult> = Vec::new();
461            let mut done = false;
462            let mut failed = false;
463            let mut final_message = String::new();
464
465            // Find the leading run of read-only tool calls.  Only worth
466            // spawning tasks when there are 2 or more — a single call has
467            // no parallelism benefit and dry-run never executes anyway.
468            let parallel_n = if self.dry_run {
469                0
470            } else {
471                let n = llm_response
472                    .tool_calls
473                    .iter()
474                    .take_while(|tc| is_read_only(&tc.name))
475                    .count();
476                if n >= 2 {
477                    n
478                } else {
479                    0
480                }
481            };
482
483            // ── Parallel read-only batch ────────────────────────────────────────
484            if parallel_n >= 2 {
485                let read_batch = &llm_response.tool_calls[..parallel_n];
486                self.printer.print(
487                    Phase::Tool,
488                    &format!("Running {} read operations in parallel...", parallel_n),
489                );
490
491                let ctx = Arc::new(ReadOnlyContext {
492                    workspace: self.workspace.clone(),
493                    config: self.config.clone(),
494                    printer: Arc::clone(&self.printer),
495                    symbol_index: Arc::new(tokio::sync::Mutex::new(None)),
496                    memory: Arc::clone(&self.memory),
497                });
498
499                let tasks: Vec<_> = read_batch
500                    .iter()
501                    .map(|tc| {
502                        session.log_event(&format!(
503                            "Tool call (parallel): {} {:?}",
504                            tc.name, tc.arguments
505                        ));
506                        let ctx2 = Arc::clone(&ctx);
507                        let call = ToolCall {
508                            id: tc.id.clone(),
509                            name: tc.name.clone(),
510                            arguments: tc.arguments.clone(),
511                        };
512                        tokio::spawn(execute_read_only(ctx2, call))
513                    })
514                    .collect();
515
516                for (i, task) in tasks.into_iter().enumerate() {
517                    let tc = &read_batch[i];
518                    let display = describe_tool_call(&tc.name, &tc.arguments);
519                    let result = match task.await {
520                        Ok(Ok(r)) => {
521                            self.printer.print_tool_call(&tc.name, &display, &r.output);
522                            session.log_event(&format!(
523                                "Tool result: {} → {}",
524                                tc.name,
525                                truncate_log(&r.output, 200)
526                            ));
527                            r
528                        }
529                        Ok(Err(e)) => {
530                            self.printer.print_error(&e.to_string());
531                            session.log_event(&format!("Tool {} error: {}", tc.name, e));
532                            ToolResult {
533                                call_id: tc.id.clone(),
534                                tool_name: tc.name.clone(),
535                                output: format!("ERROR: {}", e),
536                                is_error: true,
537                            }
538                        }
539                        Err(e) => ToolResult {
540                            call_id: tc.id.clone(),
541                            tool_name: tc.name.clone(),
542                            output: format!("ERROR: task panicked: {}", e),
543                            is_error: true,
544                        },
545                    };
546                    tool_results.push(result);
547                }
548            }
549
550            // ── Sequential calls (remaining after any parallel batch) ────────────
551            for tc in &llm_response.tool_calls[parallel_n..] {
552                let tool_call = ToolCall {
553                    id: tc.id.clone(),
554                    name: tc.name.clone(),
555                    arguments: tc.arguments.clone(),
556                };
557
558                let display = describe_tool_call(&tc.name, &tc.arguments);
559                session.log_event(&format!("Tool call: {} {:?}", tc.name, tc.arguments));
560
561                if self.dry_run {
562                    self.printer
563                        .print(Phase::Tool, "[dry-run] Skipping execution.");
564                    tool_results.push(ToolResult {
565                        call_id: tc.id.clone(),
566                        tool_name: tc.name.clone(),
567                        output: "[dry-run] Not executed.".to_string(),
568                        is_error: false,
569                    });
570                    continue;
571                }
572
573                match tools.execute(&tool_call).await {
574                    Ok(result) => {
575                        self.printer
576                            .print_tool_call(&tc.name, &display, &result.output);
577                        session.log_event(&format!(
578                            "Tool result: {} → {}",
579                            tc.name,
580                            truncate_log(&result.output, 200)
581                        ));
582
583                        if result.output.starts_with("DONE:") {
584                            done = true;
585                            final_message = result.output[5..].trim().to_string();
586                        } else if result.output.starts_with("FAILED:") {
587                            failed = true;
588                            final_message = result.output[7..].trim().to_string();
589                        }
590
591                        tool_results.push(result);
592                    }
593                    Err(RalphError::UserAborted) => {
594                        self.printer
595                            .print(Phase::Ralph, "Action cancelled by user.");
596                        session.log_event(&format!("Tool {} cancelled by user", tc.name));
597                        tool_results.push(ToolResult {
598                            call_id: tc.id.clone(),
599                            tool_name: tc.name.clone(),
600                            output: "Cancelled by user.".to_string(),
601                            is_error: false,
602                        });
603                    }
604                    Err(e) => {
605                        let err_msg = e.to_string();
606                        self.printer.print_error(&err_msg);
607                        session.log_event(&format!("Tool {} error: {}", tc.name, err_msg));
608                        tool_results.push(ToolResult {
609                            call_id: tc.id.clone(),
610                            tool_name: tc.name.clone(),
611                            output: format!("ERROR: {}", err_msg),
612                            is_error: true,
613                        });
614                    }
615                }
616            }
617
618            // Sync modified files to session
619            for path in &tools.modified_files {
620                if !session.modified_files.contains(path) {
621                    session.modified_files.push(path.clone());
622                }
623            }
624
625            // ── TRACK ACTION CATEGORIES & EDIT FAILURES ───────────────────────
626            {
627                let mut this_turn_category: &'static str = "read";
628                let mut had_edit_failure = false;
629                let mut had_successful_edit = false;
630                let mut had_test_call = false;
631
632                for result in &tool_results {
633                    match result.tool_name.as_str() {
634                        "edit_file" | "edit_file_multi" | "write_file" | "delete_file" => {
635                            if result.is_error {
636                                had_edit_failure = true;
637                            } else {
638                                had_successful_edit = true;
639                            }
640                            this_turn_category = "edit";
641                        }
642                        "run_test" | "run_build" => {
643                            had_test_call = true;
644                            if this_turn_category != "edit" {
645                                this_turn_category = "test";
646                            }
647                        }
648                        "run_command" => {
649                            if this_turn_category == "read" {
650                                this_turn_category = "cmd";
651                            }
652                        }
653                        _ => {}
654                    }
655                }
656
657                // Update edit failure streak.
658                if had_edit_failure && !had_successful_edit {
659                    consecutive_edit_failures += 1;
660                } else if had_successful_edit {
661                    consecutive_edit_failures = 0;
662                }
663
664                // Update test failure streak.
665                if had_test_call {
666                    let any_test_failure = tool_results
667                        .iter()
668                        .filter(|r| r.tool_name == "run_test" || r.tool_name == "run_build")
669                        .any(|r| !test_passed(&r.output));
670                    if any_test_failure {
671                        consecutive_test_failures += 1;
672                    } else {
673                        consecutive_test_failures = 0;
674                    }
675                }
676
677                // Adaptive reasoning: trigger after repeated edit failures or test failures.
678                if consecutive_edit_failures >= 3 || consecutive_test_failures >= 2 {
679                    if !next_turn_uses_reasoning {
680                        self.printer.print(
681                            Phase::Verify,
682                            &format!(
683                                "[reasoning] Activating reasoning provider (edit_failures={}, test_failures={}).",
684                                consecutive_edit_failures, consecutive_test_failures
685                            ),
686                        );
687                    }
688                    next_turn_uses_reasoning = true;
689                }
690
691                // Update rolling action window (max 5).
692                if recent_action_categories.len() >= 5 {
693                    recent_action_categories.pop_front();
694                }
695                recent_action_categories.push_back(this_turn_category);
696            }
697
698            // ── ANALYSIS SPIRAL DETECTION ──────────────────────────────────────
699            // Track stall here but inject the nudge AFTER tool results are appended
700            // below.  Injecting before tool results would put a user message between
701            // the assistant "tool_calls" message and its required tool results, which
702            // DeepSeek/OpenAI reject with HTTP 400.
703            let files_changed_this_turn = tools.modified_files.len() > pre_turn_file_count;
704            let stall_nudge: Option<String> = if files_changed_this_turn {
705                turns_since_edit = 0;
706                stall_nudge_count = 0;
707                // Mark edited files so their next read shows full content.
708                for path in tools.modified_files.iter().skip(pre_turn_file_count) {
709                    let key = path.to_string_lossy().to_string();
710                    files_edited_since_read.insert(key);
711                }
712                None
713            } else {
714                turns_since_edit += 1;
715                if turns_since_edit > 0 && turns_since_edit % 20 == 0 {
716                    stall_nudge_count += 1;
717                    if stall_nudge_count >= 3 {
718                        // Exit the loop — too many stall nudges with no progress.
719                        self.printer.print(
720                            Phase::Verify,
721                            "[stall] No edits after 3 stall nudges — exiting.",
722                        );
723                        session.log_event("[stall] No edits after 3 stall nudges — exiting.");
724                        session.set_status(SessionStatus::Interrupted).ok();
725                        // Print token totals before exiting
726                        self.printer.print(
727                            Phase::Ralph,
728                            &format!(
729                                "[tokens total] in={} out={} reasoning={}",
730                                total_input_tokens, total_output_tokens, total_reasoning_tokens
731                            ),
732                        );
733                        return Err(RalphError::ToolFailed {
734                            tool: "stall_exit".to_string(),
735                            message: "[stall] No edits after 3 stall nudges — exiting.".to_string(),
736                        });
737                    }
738                    self.printer.print(
739                        Phase::Verify,
740                        &format!(
741                            "[stall] No edits for {} turns — prompting to act (nudge {}/3).",
742                            turns_since_edit, stall_nudge_count
743                        ),
744                    );
745                    session.log_event(&format!(
746                        "Analysis stall detected (nudge {}/3) — injecting action prompt",
747                        stall_nudge_count
748                    ));
749                    Some(context_aware_stall_nudge(
750                        turns_since_edit,
751                        &recent_action_categories,
752                        consecutive_test_failures,
753                    ))
754                } else {
755                    None
756                }
757            };
758
759            // Check for test failures in tool results to set reasoning provider for next turn.
760            // (Adaptive reasoning from edit failures is handled in the action-tracking block above.)
761            for result in &tool_results {
762                let out = &result.output;
763                if result.tool_name == "run_test" || result.tool_name == "run_build" {
764                    if !test_passed(out) {
765                        next_turn_uses_reasoning = true;
766                    }
767                }
768            }
769
770            // Append tool results to messages with post-processing.
771            const MAX_TOOL_OUTPUT_CHARS: usize = 8_000; // ~2k tokens
772            const MAX_TEST_OUTPUT_CHARS: usize = 3_000; // ~750 tokens for test output
773            for result in &tool_results {
774                let stored = post_process_tool_output(
775                    result,
776                    MAX_TOOL_OUTPUT_CHARS,
777                    MAX_TEST_OUTPUT_CHARS,
778                    turn,
779                    &mut file_last_read,
780                    &files_edited_since_read,
781                );
782                session.messages.push(Message::tool_result(
783                    &result.call_id,
784                    &result.tool_name,
785                    &stored,
786                ));
787            }
788
789            // Inject stall nudge AFTER tool results so the message order is valid:
790            // [assistant tool_calls] → [tool results] → [user stall nudge]
791            if let Some(ref msg) = stall_nudge {
792                session.messages.push(Message::user(msg.as_str()));
793            }
794
795            if let Err(e) = session.save_turn(&[], turn) {
796                self.printer
797                    .print_error(&format!("Warning: failed to save turn: {}", e));
798            }
799
800            // ── AUTO-TEST ──────────────────────────────────────────────────────
801            // Run the project test suite automatically if:
802            //   • auto_test is configured for this workspace
803            //   • files were modified this turn
804            //   • Ralph did not already call run_test itself
805            if let Some(ref cmd) = auto_test_cmd {
806                let files_modified = tools.modified_files.len() > pre_turn_file_count;
807                let test_called = llm_response
808                    .tool_calls
809                    .iter()
810                    .any(|tc| tc.name == "run_test");
811
812                if files_modified && !test_called {
813                    self.printer
814                        .print(Phase::Verify, &format!("Auto-test: {}", cmd));
815                    session.log_event(&format!("Auto-test: {}", cmd));
816
817                    match shell_tools::run_command(cmd, &self.workspace).await {
818                        Ok(output) => {
819                            session.log_event(&format!(
820                                "Auto-test result: {}",
821                                truncate_log(&output, 200)
822                            ));
823
824                            if test_passed(&output) {
825                                self.printer.print(Phase::Verify, "Tests passed.");
826                                test_fail_count = 0;
827                            } else {
828                                test_fail_count += 1;
829                                self.printer.print(
830                                    Phase::Verify,
831                                    &format!(
832                                        "Tests failed (attempt {}/{}).",
833                                        test_fail_count, self.config.testing.max_retries,
834                                    ),
835                                );
836
837                                if test_fail_count > self.config.testing.max_retries {
838                                    let msg = format!(
839                                        "Tests still failing after {} attempt(s). Stopping.",
840                                        self.config.testing.max_retries
841                                    );
842                                    self.printer.print(Phase::Failed, &msg);
843                                    session.log_event(&msg);
844                                    session.set_status(SessionStatus::Failed).ok();
845                                    return Err(RalphError::ToolFailed {
846                                        tool: "auto_test".to_string(),
847                                        message: msg,
848                                    });
849                                }
850
851                                // Inject failure as user feedback so Ralph fixes it.
852                                // Also inject a structured list of failing test names.
853                                let truncated = truncate_log(&output, 3000);
854                                let failing_tests = extract_failing_test_names(&output);
855                                let test_list = if !failing_tests.is_empty() {
856                                    format!(
857                                        "\n\nFailing tests ({}):{}",
858                                        failing_tests.len(),
859                                        failing_tests
860                                            .iter()
861                                            .map(|t| format!("\n  - {}", t))
862                                            .collect::<String>()
863                                    )
864                                } else {
865                                    String::new()
866                                };
867                                session.messages.push(Message::user(format!(
868                                    "Automated tests failed after your changes.{}\n\n\
869                                     Fix each failing test before calling `declare_done`. \
870                                     Use `search_in_file` or `read_file` with offset/limit \
871                                     to read only the relevant code sections.\n\n\
872                                     Test output:\n```\n{}\n```",
873                                    test_list, truncated
874                                )));
875                                // Clear done/failed — Ralph must fix tests first
876                                done = false;
877                                failed = false;
878                            }
879                        }
880                        Err(e) => {
881                            // Test runner itself errored (e.g. command not found).
882                            // Log it but don't block the agent — misconfiguration
883                            // shouldn't halt a session.
884                            self.printer.print(
885                                Phase::Verify,
886                                &format!("Auto-test runner error (skipping): {}", e),
887                            );
888                            session.log_event(&format!("Auto-test runner error: {}", e));
889                        }
890                    }
891                }
892            }
893
894            // ── VERIFY ────────────────────────────────────────────────────────
895            if done {
896                self.printer
897                    .print(Phase::Done, &format!("Done: {}", final_message));
898                session.log_event(&format!("Done: {}", final_message));
899                session.set_status(SessionStatus::Done).ok();
900                let files: Vec<String> = tools
901                    .modified_files
902                    .iter()
903                    .map(|p| p.display().to_string())
904                    .collect();
905                self.memory
906                    .lock()
907                    .unwrap_or_else(|e| e.into_inner())
908                    .add_episode(&session.meta.session_id, &final_message, files, "done");
909                self.printer.print(
910                    Phase::Ralph,
911                    &format!(
912                        "[tokens total] in={} out={} reasoning={}",
913                        total_input_tokens, total_output_tokens, total_reasoning_tokens
914                    ),
915                );
916                return Ok(());
917            }
918            if failed {
919                self.printer
920                    .print(Phase::Failed, &format!("Failed: {}", final_message));
921                session.log_event(&format!("Failed: {}", final_message));
922                session.set_status(SessionStatus::Failed).ok();
923                let files: Vec<String> = tools
924                    .modified_files
925                    .iter()
926                    .map(|p| p.display().to_string())
927                    .collect();
928                self.memory
929                    .lock()
930                    .unwrap_or_else(|e| e.into_inner())
931                    .add_episode(&session.meta.session_id, &final_message, files, "failed");
932                self.printer.print(
933                    Phase::Ralph,
934                    &format!(
935                        "[tokens total] in={} out={} reasoning={}",
936                        total_input_tokens, total_output_tokens, total_reasoning_tokens
937                    ),
938                );
939                return Err(RalphError::ToolFailed {
940                    tool: "declare_failed".to_string(),
941                    message: final_message,
942                });
943            }
944
945            self.printer.print(Phase::Verify, "Continuing...");
946        } // end loop
947    }
948}
949
950/// Detect project manifest files and run the appropriate package manager
951/// to install dependencies before the first LLM turn.  All installs are
952/// best-effort: failures are logged but never abort the session.
953async fn install_workspace_deps(workspace: &std::path::Path, printer: &Printer) {
954    // (manifest file, program, args, human label)
955    // pip install -e . may be triggered by multiple markers (pyproject.toml,
956    // setup.py, setup.cfg); we run it at most once via `pip_editable_done`.
957    let mut pip_editable_done = false;
958
959    struct Check {
960        marker: &'static str,
961        program: &'static str,
962        args: &'static [&'static str],
963        label: &'static str,
964        editable: bool,
965    }
966
967    let checks: &[Check] = &[
968        Check {
969            marker: "requirements.txt",
970            program: "pip",
971            args: &["install", "-r", "requirements.txt", "-q"],
972            label: "pip install -r requirements.txt",
973            editable: false,
974        },
975        Check {
976            marker: "pyproject.toml",
977            program: "pip",
978            args: &["install", "-e", ".", "-q"],
979            label: "pip install -e .",
980            editable: true,
981        },
982        Check {
983            marker: "setup.py",
984            program: "pip",
985            args: &["install", "-e", ".", "-q"],
986            label: "pip install -e .",
987            editable: true,
988        },
989        Check {
990            marker: "setup.cfg",
991            program: "pip",
992            args: &["install", "-e", ".", "-q"],
993            label: "pip install -e .",
994            editable: true,
995        },
996        Check {
997            marker: "package.json",
998            program: "npm",
999            args: &["install", "--silent"],
1000            label: "npm install",
1001            editable: false,
1002        },
1003        Check {
1004            marker: "go.mod",
1005            program: "go",
1006            args: &["mod", "download"],
1007            label: "go mod download",
1008            editable: false,
1009        },
1010    ];
1011
1012    for check in checks {
1013        if !workspace.join(check.marker).exists() {
1014            continue;
1015        }
1016        if check.editable {
1017            if pip_editable_done {
1018                continue;
1019            }
1020            pip_editable_done = true;
1021        }
1022        printer.print(Phase::Ralph, &format!("[setup] {}", check.label));
1023        match tokio::process::Command::new(check.program)
1024            .args(check.args)
1025            .current_dir(workspace)
1026            .output()
1027            .await
1028        {
1029            Ok(o) if o.status.success() => {}
1030            Ok(o) => {
1031                let first_err = String::from_utf8_lossy(&o.stderr)
1032                    .lines()
1033                    .find(|l| !l.trim().is_empty())
1034                    .unwrap_or("(no output)")
1035                    .to_string();
1036                printer.print(
1037                    Phase::Ralph,
1038                    &format!("[setup] {} failed (non-fatal): {}", check.label, first_err),
1039                );
1040            }
1041            Err(e) => {
1042                printer.print(
1043                    Phase::Ralph,
1044                    &format!("[setup] {} error (non-fatal): {}", check.label, e),
1045                );
1046            }
1047        }
1048    }
1049}
1050
1051/// Return a single human-readable line describing a tool call.
1052/// For file-writing tools the content is parsed to extract definition names;
1053/// the raw code is never shown.
1054fn describe_tool_call(tool_name: &str, args: &serde_json::Value) -> String {
1055    let obj = match args.as_object() {
1056        Some(o) => o,
1057        None => return String::new(),
1058    };
1059    let str_arg = |k: &str| obj.get(k).and_then(|v| v.as_str()).unwrap_or("?");
1060
1061    match tool_name {
1062        "write_file" => {
1063            let path = str_arg("path");
1064            let content = str_arg("content");
1065            let defs = extract_definitions(content);
1066            if defs.is_empty() {
1067                format!("→ {}", path)
1068            } else {
1069                format!("→ {}  [{}]", path, defs.join(", "))
1070            }
1071        }
1072        "edit_file" => {
1073            let path = str_arg("path");
1074            format!("→ {} (editing)", path)
1075        }
1076        "read_file" => format!("→ {}", str_arg("path")),
1077        "delete_file" => format!("→ {} (delete)", str_arg("path")),
1078        "list_dir" => format!("→ {}", str_arg("path")),
1079        "load_files" => format!("→ pattern: {}", str_arg("pattern")),
1080        "explain_code" => format!(
1081            "→ {}",
1082            obj.get("path")
1083                .and_then(|v| v.as_str())
1084                .unwrap_or("workspace")
1085        ),
1086        "run_command" | "run_test" | "run_build" => {
1087            let cmd = str_arg("command");
1088            let preview: String = cmd.chars().take(80).collect();
1089            if cmd.len() > 80 {
1090                format!("$ {}…", preview)
1091            } else {
1092                format!("$ {}", preview)
1093            }
1094        }
1095        "search_codebase" => format!("pattern: {}", str_arg("pattern")),
1096        "search_web" => format!("\"{}\"", str_arg("query")),
1097        "ask_user" => format!("\"{}\"", str_arg("question")),
1098        "declare_done" | "declare_failed" => str_arg("message").to_string(),
1099        _ => {
1100            // Generic: show up to 3 key=value pairs, skipping long strings
1101            let pairs: Vec<String> = obj
1102                .iter()
1103                .filter(|(_, v)| v.as_str().map(|s| s.len() < 80).unwrap_or(true))
1104                .take(3)
1105                .map(|(k, v)| format!("{}={}", k, v))
1106                .collect();
1107            pairs.join(" ")
1108        }
1109    }
1110}
1111
1112/// Parse source content and return up to 5 top-level definition names.
1113fn extract_definitions(content: &str) -> Vec<String> {
1114    use regex::Regex;
1115    // Patterns ordered by precedence; each captures (keyword, name)
1116    let patterns: &[(&str, &str)] = &[
1117        // Rust
1118        (
1119            r"(?m)^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+(\w+)",
1120            "fn",
1121        ),
1122        (r"(?m)^(?:pub(?:\([^)]*\))?\s+)?struct\s+(\w+)", "struct"),
1123        (r"(?m)^(?:pub(?:\([^)]*\))?\s+)?enum\s+(\w+)", "enum"),
1124        (r"(?m)^(?:pub(?:\([^)]*\))?\s+)?trait\s+(\w+)", "trait"),
1125        (r"(?m)^impl(?:<[^>]*>)?\s+(\w+)", "impl"),
1126        // Python
1127        (r"(?m)^(?:async\s+)?def\s+(\w+)", "def"),
1128        (r"(?m)^class\s+(\w+)", "class"),
1129        // JS / TS
1130        (
1131            r"(?m)^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+(\w+)",
1132            "function",
1133        ),
1134        (r"(?m)^(?:export\s+)?(?:default\s+)?class\s+(\w+)", "class"),
1135        (r"(?m)^(?:export\s+)?(?:const|let)\s+(\w+)\s*[=:]", "const"),
1136        // Go
1137        (r"(?m)^func\s+(?:\([^)]*\)\s+)?(\w+)", "func"),
1138        (r"(?m)^type\s+(\w+)\s+struct", "struct"),
1139        // Java / Kotlin
1140        (
1141            r"(?m)^(?:public\s+)?(?:class|interface|record)\s+(\w+)",
1142            "class",
1143        ),
1144    ];
1145
1146    let mut seen = std::collections::HashSet::new();
1147    let mut defs: Vec<String> = Vec::new();
1148
1149    for (pat, kind) in patterns {
1150        if let Ok(re) = Regex::new(pat) {
1151            for cap in re.captures_iter(content) {
1152                if let Some(m) = cap.get(1) {
1153                    let name = m.as_str().to_string();
1154                    let key = format!("{}:{}", kind, name);
1155                    if seen.insert(key) {
1156                        defs.push(format!("{} {}", kind, name));
1157                        if defs.len() >= 5 {
1158                            return defs;
1159                        }
1160                    }
1161                }
1162            }
1163        }
1164    }
1165    defs
1166}
1167
1168fn make_spinner(msg: &str) -> ProgressBar {
1169    let pb = ProgressBar::new_spinner();
1170    pb.set_style(
1171        ProgressStyle::default_spinner()
1172            .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
1173            .template("{spinner:.cyan} {msg}")
1174            .unwrap_or_else(|_| ProgressStyle::default_spinner()),
1175    );
1176    pb.set_message(msg.to_string());
1177    pb.enable_steady_tick(Duration::from_millis(80));
1178    pb
1179}
1180
1181fn truncate_log(s: &str, max: usize) -> String {
1182    if s.len() <= max {
1183        s.replace('\n', " ")
1184    } else {
1185        // Walk char boundaries to find the largest split point ≤ max bytes.
1186        // A plain byte slice `&s[..max]` panics when `max` falls inside a
1187        // multi-byte character (e.g. the em-dash '—' in find_symbol output).
1188        let end = s
1189            .char_indices()
1190            .map(|(i, c)| i + c.len_utf8())
1191            .filter(|&end| end <= max)
1192            .last()
1193            .unwrap_or(0);
1194        format!("{}…", &s[..end].replace('\n', " "))
1195    }
1196}
1197
1198/// Returns true if the shell_tools output ends with `[exit: 0]`.
1199fn test_passed(output: &str) -> bool {
1200    output.trim_end().ends_with("[exit: 0]")
1201}
1202
1203/// Post-process a tool result for storage in session messages.
1204/// Handles: test output trimming, no-match search compression, duplicate file read eviction.
1205fn post_process_tool_output(
1206    result: &ToolResult,
1207    max_chars: usize,
1208    max_test_chars: usize,
1209    current_turn: u32,
1210    file_last_read: &mut std::collections::HashMap<String, u32>,
1211    files_edited_since_read: &std::collections::HashSet<String>,
1212) -> String {
1213    let output = &result.output;
1214
1215    // 1. Dead-end search: store compact placeholder.
1216    if result.tool_name == "search_codebase" {
1217        if output.trim().is_empty() || output.contains("No matches") {
1218            // Extract pattern from the stored output if possible, or use generic text.
1219            return "[search: no matches]".to_string();
1220        }
1221    }
1222
1223    // 2. Duplicate file read eviction.
1224    if result.tool_name == "read_file" {
1225        // Try to extract the file path from the tool output — it's tricky since we
1226        // don't have the original arguments here. Use a best-effort approach: the
1227        // call_id doesn't have the path. We'll skip this for now and just truncate.
1228        // (The full implementation would need the path from call arguments.)
1229        // This is handled partially — we track reads by checking if the output repeats.
1230    }
1231
1232    // 3. Test output trimming: for run_test/run_command/run_build outputs that look like pytest.
1233    let is_test_output = (result.tool_name == "run_test"
1234        || result.tool_name == "run_command"
1235        || result.tool_name == "run_build")
1236        && (output.contains("passed")
1237            || output.contains("failed")
1238            || output.contains("=== FAILURES ===")
1239            || output.contains("FAILED"));
1240
1241    if is_test_output {
1242        let trimmed = trim_test_output(output, max_test_chars);
1243        return trimmed;
1244    }
1245
1246    // 4. General large output truncation.
1247    if output.len() > max_chars {
1248        let preview: String = output.chars().take(max_chars).collect();
1249        let total_tokens = compaction::count_tokens(output);
1250        let lines_shown = preview.chars().filter(|&c| c == '\n').count();
1251        let total_lines = output.chars().filter(|&c| c == '\n').count();
1252        format!(
1253            "{}\n\n[...truncated after line {lines_shown} of {total_lines} ({total_tokens} tokens total). \
1254             To read further use `read_file` with `offset={lines_shown}` and an appropriate `limit`.]",
1255            preview
1256        )
1257    } else {
1258        output.clone()
1259    }
1260}
1261
1262/// Trim test output to keep the most useful parts within the char limit.
1263fn trim_test_output(output: &str, max_chars: usize) -> String {
1264    let lines: Vec<&str> = output.lines().collect();
1265
1266    // Strip leading progress-only lines (lines with only dots, F, s, E chars).
1267    let content_start = lines
1268        .iter()
1269        .position(|line| {
1270            let trimmed = line.trim();
1271            if trimmed.is_empty() {
1272                return false;
1273            }
1274            !trimmed.chars().all(|c| matches!(c, '.' | 'F' | 's' | 'E'))
1275        })
1276        .unwrap_or(0);
1277
1278    let meaningful = &lines[content_start..];
1279
1280    // If there's a FAILURES section, keep from there.
1281    let from_idx = meaningful
1282        .iter()
1283        .position(|l| l.contains("=== FAILURES ==="))
1284        .unwrap_or(0);
1285
1286    let relevant = &meaningful[from_idx..];
1287
1288    // Always keep the last 30 lines.
1289    let keep_start = if relevant.len() > 30 {
1290        relevant.len() - 30
1291    } else {
1292        0
1293    };
1294    let keep = &relevant[keep_start.min(from_idx.max(0))..];
1295
1296    let joined = keep.join("\n");
1297    if joined.len() > max_chars {
1298        // Take from the end.
1299        let start_byte = joined.len().saturating_sub(max_chars);
1300        // Find a newline boundary.
1301        let adj_start = joined[start_byte..]
1302            .find('\n')
1303            .map(|p| start_byte + p + 1)
1304            .unwrap_or(start_byte);
1305        format!("[...trimmed...]\n{}", &joined[adj_start..])
1306    } else {
1307        joined
1308    }
1309}
1310
1311/// Extract pytest-style failing test names from raw test output.
1312/// Matches lines like: `FAILED tests/test_foo.py::TestClass::test_method - AssertionError`
1313fn extract_failing_test_names(output: &str) -> Vec<String> {
1314    let mut names = Vec::new();
1315    for line in output.lines() {
1316        let trimmed = line.trim();
1317        if let Some(rest) = trimmed.strip_prefix("FAILED ") {
1318            // Everything before " - " (if present) is the test id.
1319            let id = rest.splitn(2, " - ").next().unwrap_or(rest).trim();
1320            if !id.is_empty() && !names.contains(&id.to_string()) {
1321                names.push(id.to_string());
1322            }
1323        }
1324        // Also catch: "  FAILED test_something" indented lines.
1325        if trimmed.starts_with("FAILED") && trimmed.len() > 7 {
1326            let id = trimmed[7..].splitn(2, " - ").next().unwrap_or("").trim();
1327            if !id.is_empty() && !names.contains(&id.to_string()) {
1328                names.push(id.to_string());
1329            }
1330        }
1331    }
1332    names
1333}
1334
1335/// Build a context-aware stall nudge based on what the agent has been doing.
1336fn context_aware_stall_nudge(
1337    turns_since_edit: u32,
1338    recent_categories: &std::collections::VecDeque<&'static str>,
1339    consecutive_test_failures: u32,
1340) -> String {
1341    let all_reads = !recent_categories.is_empty() && recent_categories.iter().all(|&c| c == "read");
1342    let edits_without_test = recent_categories.iter().any(|&c| c == "edit")
1343        && !recent_categories.iter().any(|&c| c == "test");
1344
1345    if consecutive_test_failures >= 2 {
1346        return format!(
1347            "Tests have failed {} times in a row. \
1348             Consider a different approach:\n\
1349             • Use `view_diff` to review all your changes so far.\n\
1350             • Use `search_in_file` to re-read the exact code you changed.\n\
1351             • Consider whether your fix addresses the root cause, or if you need to revert and try again.\n\
1352             • Use `read_file_outline` on the test file to understand what it expects.\n\
1353             You MUST either fix the tests or call `declare_failed` if the task is not achievable.",
1354            consecutive_test_failures
1355        );
1356    }
1357
1358    if all_reads {
1359        return format!(
1360            "You have spent {} consecutive turns reading without making any code change.\n\
1361             You MUST act now:\n\
1362             • Call `edit_file` or `edit_file_multi` to apply your fix — do not read more files.\n\
1363             • If you need to find the exact text, use `search_in_file` with a pattern, then edit.\n\
1364             • An imperfect fix that gets refined is better than infinite reading.\n\
1365             You MUST make at least one file edit this turn.",
1366            turns_since_edit
1367        );
1368    }
1369
1370    if edits_without_test {
1371        return format!(
1372            "You have made edits but have not run any tests to verify them.\n\
1373             • Use `run_test` to verify your changes work.\n\
1374             • Or use `view_diff` to review what you changed before proceeding.\n\
1375             • If tests pass, call `declare_done` with a summary.",
1376        );
1377    }
1378
1379    // Generic fallback
1380    format!(
1381        "You have spent {} turns without making progress. You must act now:\n\
1382         • Call `edit_file`, `edit_file_multi`, or `write_file` to apply your fix.\n\
1383         • Use `search_in_file` to find the exact text if needed.\n\
1384         • You MUST make at least one file edit this turn.",
1385        turns_since_edit
1386    )
1387}
1388
1389/// Returns true if the LLM response looks like an unexecuted plan — i.e. the
1390/// model described what it intends to do but called no tools. Used to decide
1391/// whether to nudge it into actually making the edit.
1392fn looks_like_unexecuted_plan(text: &str) -> bool {
1393    if text.is_empty() {
1394        return false;
1395    }
1396    let lower = text.to_lowercase();
1397    let action_phrases = [
1398        "i need to",
1399        "i will ",
1400        "i'll ",
1401        "let me ",
1402        "i should ",
1403        "i'm going to",
1404        "next i ",
1405        "now i'll",
1406        "now i need",
1407        "need to modify",
1408        "need to edit",
1409        "need to change",
1410        "need to add",
1411        "need to fix",
1412        "need to update",
1413        "should modify",
1414        "should edit",
1415        "should change",
1416        "should add",
1417        "should fix",
1418        "should update",
1419        "to fix this",
1420        "to resolve this",
1421        "the fix is to",
1422        "the solution is to",
1423        "i can fix",
1424        "i can modify",
1425    ];
1426    action_phrases.iter().any(|p| lower.contains(p))
1427}