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 reasoning_provider: Option<Arc<dyn LlmProvider>>,
22 config: Config,
23 workspace: PathBuf,
24 printer: Arc<Printer>,
25 max_turns: Option<u32>,
27 dry_run: bool,
28 no_confirm: bool,
29 skip_workspace_setup: bool,
32 memory: Arc<Mutex<MemoryStore>>,
33 lsp_languages: Vec<String>,
35 diff_preview: Arc<AtomicBool>,
37 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 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 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 let mut plan_nudge_count: u32 = 0;
137 let mut turns_since_edit: u32 = 0;
139 let mut stall_nudge_count: u32 = 0;
141 let mut next_turn_uses_reasoning: bool = true; let mut total_input_tokens: u64 = 0;
145 let mut total_output_tokens: u64 = 0;
146 let mut total_reasoning_tokens: u64 = 0;
147 let mut file_last_read: std::collections::HashMap<String, u32> =
149 std::collections::HashMap::new();
150 let mut files_edited_since_read: std::collections::HashSet<String> =
152 std::collections::HashSet::new();
153 let mut consecutive_edit_failures: u32 = 0;
155 let mut consecutive_test_failures: u32 = 0;
157 let mut recent_action_categories: std::collections::VecDeque<&'static str> =
159 std::collections::VecDeque::with_capacity(5);
160 loop {
161 turn += 1;
162
163 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 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 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 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 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 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 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 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 plan_nudge_count = 0;
428
429 {
432 let mut parts: Vec<ContentPart> = Vec::new();
433 if let Some(ref text) = llm_response.text {
434 if !text.is_empty() {
435 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 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 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 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 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 for path in &tools.modified_files {
620 if !session.modified_files.contains(path) {
621 session.modified_files.push(path.clone());
622 }
623 }
624
625 {
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 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 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 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 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 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 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 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 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 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 const MAX_TOOL_OUTPUT_CHARS: usize = 8_000; const MAX_TEST_OUTPUT_CHARS: usize = 3_000; 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 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 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 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 done = false;
877 failed = false;
878 }
879 }
880 Err(e) => {
881 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 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 } }
948}
949
950async fn install_workspace_deps(workspace: &std::path::Path, printer: &Printer) {
954 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
1051fn 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 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
1112fn extract_definitions(content: &str) -> Vec<String> {
1114 use regex::Regex;
1115 let patterns: &[(&str, &str)] = &[
1117 (
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 (r"(?m)^(?:async\s+)?def\s+(\w+)", "def"),
1128 (r"(?m)^class\s+(\w+)", "class"),
1129 (
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 (r"(?m)^func\s+(?:\([^)]*\)\s+)?(\w+)", "func"),
1138 (r"(?m)^type\s+(\w+)\s+struct", "struct"),
1139 (
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 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
1198fn test_passed(output: &str) -> bool {
1200 output.trim_end().ends_with("[exit: 0]")
1201}
1202
1203fn 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 if result.tool_name == "search_codebase" {
1217 if output.trim().is_empty() || output.contains("No matches") {
1218 return "[search: no matches]".to_string();
1220 }
1221 }
1222
1223 if result.tool_name == "read_file" {
1225 }
1231
1232 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 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
1262fn trim_test_output(output: &str, max_chars: usize) -> String {
1264 let lines: Vec<&str> = output.lines().collect();
1265
1266 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 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 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 let start_byte = joined.len().saturating_sub(max_chars);
1300 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
1311fn 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 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 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
1335fn 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 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
1389fn 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}