1use anyhow::Result;
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::process::Stdio;
17use std::sync::Arc;
18use std::time::Duration;
19use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
20use tokio::process::{Child, Command};
21use tokio::time::timeout;
22
23use crate::provider::{CompletionRequest, ContentPart, Message, Provider, Role};
24
25use crate::cognition::tool_router::{ToolCallRouter, ToolRouterConfig};
26
27use super::context_trace::{ContextEvent, ContextTrace, ContextTraceSummary};
28use super::oracle::{FinalPayload, GrepMatch, GrepOracle, GrepPayload, QueryType, TraceStep};
29use super::tools::{RlmToolResult, dispatch_tool_call, rlm_tool_definitions};
30use super::{RlmAnalysisResult, SubQuery};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
34#[serde(rename_all = "lowercase")]
35pub enum ReplRuntime {
36 #[default]
38 Rust,
39 Bun,
41 Python,
43}
44
45pub struct RlmRepl {
47 runtime: ReplRuntime,
48 context: String,
49 context_lines: Vec<String>,
50 variables: HashMap<String, String>,
51}
52
53#[derive(Debug, Clone)]
55pub struct ReplResult {
56 pub stdout: String,
57 pub stderr: String,
58 pub final_answer: Option<String>,
59}
60
61impl RlmRepl {
62 pub fn new(context: String, runtime: ReplRuntime) -> Self {
64 let context_lines = context.lines().map(|s| s.to_string()).collect();
65 Self {
66 runtime,
67 context,
68 context_lines,
69 variables: HashMap::new(),
70 }
71 }
72
73 pub fn context(&self) -> &str {
75 &self.context
76 }
77
78 pub fn lines(&self) -> &[String] {
80 &self.context_lines
81 }
82
83 pub fn head(&self, n: usize) -> Vec<&str> {
85 self.context_lines
86 .iter()
87 .take(n)
88 .map(|s| s.as_str())
89 .collect()
90 }
91
92 pub fn tail(&self, n: usize) -> Vec<&str> {
94 let start = self.context_lines.len().saturating_sub(n);
95 self.context_lines
96 .iter()
97 .skip(start)
98 .map(|s| s.as_str())
99 .collect()
100 }
101
102 pub fn grep(&self, pattern: &str) -> Vec<(usize, &str)> {
104 let re = match regex::Regex::new(pattern) {
105 Ok(r) => r,
106 Err(_) => {
107 return self
109 .context_lines
110 .iter()
111 .enumerate()
112 .filter(|(_, line)| line.contains(pattern))
113 .map(|(i, line)| (i + 1, line.as_str()))
114 .collect();
115 }
116 };
117
118 self.context_lines
119 .iter()
120 .enumerate()
121 .filter(|(_, line)| re.is_match(line))
122 .map(|(i, line)| (i + 1, line.as_str()))
123 .collect()
124 }
125
126 pub fn count(&self, pattern: &str) -> usize {
128 let re = match regex::Regex::new(pattern) {
129 Ok(r) => r,
130 Err(_) => return self.context.matches(pattern).count(),
131 };
132 re.find_iter(&self.context).count()
133 }
134
135 pub fn slice(&self, start: usize, end: usize) -> &str {
137 let total_chars = self.context.chars().count();
138 let end = end.min(total_chars);
139 let start = start.min(end);
140 let start_byte = char_index_to_byte_index(&self.context, start);
141 let end_byte = char_index_to_byte_index(&self.context, end);
142 &self.context[start_byte..end_byte]
143 }
144
145 pub fn chunks(&self, n: usize) -> Vec<String> {
147 if n == 0 {
148 return vec![self.context.clone()];
149 }
150
151 let chunk_size = self.context_lines.len().div_ceil(n);
152 self.context_lines
153 .chunks(chunk_size)
154 .map(|chunk| chunk.join("\n"))
155 .collect()
156 }
157
158 pub fn set_var(&mut self, name: &str, value: String) {
160 self.variables.insert(name.to_string(), value);
161 }
162
163 pub fn get_var(&self, name: &str) -> Option<&str> {
165 self.variables.get(name).map(|s| s.as_str())
166 }
167
168 pub fn execute(&mut self, code: &str) -> ReplResult {
179 match self.runtime {
180 ReplRuntime::Rust => self.execute_rust_dsl(code),
181 ReplRuntime::Bun | ReplRuntime::Python => {
182 self.execute_rust_dsl(code)
185 }
186 }
187 }
188
189 fn execute_rust_dsl(&mut self, code: &str) -> ReplResult {
190 let mut stdout = Vec::new();
191 let mut final_answer = None;
192
193 for line in code.lines() {
194 let line = line.trim();
195 if line.is_empty() || line.starts_with("//") || line.starts_with('#') {
196 continue;
197 }
198
199 if let Some(result) = self.execute_dsl_line(line) {
201 match result {
202 DslResult::Output(s) => stdout.push(s),
203 DslResult::Final(s) => {
204 final_answer = Some(s);
205 break;
206 }
207 DslResult::Error(s) => stdout.push(format!("Error: {}", s)),
208 }
209 }
210 }
211
212 ReplResult {
213 stdout: stdout.join("\n"),
214 stderr: String::new(),
215 final_answer,
216 }
217 }
218
219 pub fn execute_dsl_line(&mut self, line: &str) -> Option<DslResult> {
220 match self.try_execute_dsl_line(line) {
221 Ok(res) => res,
222 Err(e) => Some(DslResult::Error(e.to_string())),
223 }
224 }
225
226 fn try_execute_dsl_line(&mut self, line: &str) -> anyhow::Result<Option<DslResult>> {
227 if line.starts_with("FINAL(") || line.starts_with("FINAL!(") {
229 let start = line
230 .find('(')
231 .ok_or_else(|| anyhow::anyhow!("missing paren"))?
232 + 1;
233 let end = line.rfind(')').unwrap_or(line.len());
234 let answer = line[start..end]
235 .trim()
236 .trim_matches(|c| c == '"' || c == '\'' || c == '`');
237 return Ok(Some(DslResult::Final(answer.to_string())));
238 }
239
240 if line.starts_with("print(")
242 || line.starts_with("println!(")
243 || line.starts_with("console.log(")
244 {
245 let start = line
246 .find('(')
247 .ok_or_else(|| anyhow::anyhow!("missing paren"))?
248 + 1;
249 let end = line.rfind(')').unwrap_or(line.len());
250 let content = line[start..end]
251 .trim()
252 .trim_matches(|c| c == '"' || c == '\'' || c == '`');
253
254 let expanded = self.expand_expression(content);
256 return Ok(Some(DslResult::Output(expanded)));
257 }
258
259 if let Some(eq_pos) = line.find('=')
261 && !line.contains("==")
262 && !line.starts_with("if ")
263 {
264 let var_name = line[..eq_pos]
265 .trim()
266 .trim_start_matches("let ")
267 .trim_start_matches("const ")
268 .trim_start_matches("var ")
269 .trim();
270 let expr = line[eq_pos + 1..].trim().trim_end_matches(';');
271
272 let value = self.evaluate_expression(expr);
273 self.set_var(var_name, value);
274 return Ok(None);
275 }
276
277 if line.starts_with("head(")
279 || line.starts_with("tail(")
280 || line.starts_with("grep(")
281 || line.starts_with("count(")
282 || line.starts_with("lines()")
283 || line.starts_with("slice(")
284 || line.starts_with("chunks(")
285 || line.starts_with("ast_query(")
286 || line.starts_with("context")
287 {
288 let result = self.evaluate_expression(line);
289 return Ok(Some(DslResult::Output(result)));
290 }
291
292 Ok(None)
293 }
294
295 fn expand_expression(&self, expr: &str) -> String {
296 let mut result = expr.to_string();
298
299 for (name, value) in &self.variables {
300 let patterns = [
301 format!("${{{}}}", name),
302 format!("${}", name),
303 format!("{{{}}}", name),
304 ];
305 for p in patterns {
306 result = result.replace(&p, value);
307 }
308 }
309
310 if result.contains("context.len()") || result.contains("context.length") {
312 result = result
313 .replace("context.len()", &self.context.len().to_string())
314 .replace("context.length", &self.context.len().to_string());
315 }
316
317 if result.contains("lines().len()") || result.contains("lines().length") {
318 result = result
319 .replace("lines().len()", &self.context_lines.len().to_string())
320 .replace("lines().length", &self.context_lines.len().to_string());
321 }
322
323 result
324 }
325
326 pub fn evaluate_expression(&mut self, expr: &str) -> String {
327 let expr = expr.trim().trim_end_matches(';');
328
329 if expr.starts_with("head(") {
331 let n = self.extract_number(expr).unwrap_or(10);
332 return self.head(n).join("\n");
333 }
334
335 if expr.starts_with("tail(") {
337 let n = self.extract_number(expr).unwrap_or(10);
338 return self.tail(n).join("\n");
339 }
340
341 if expr.starts_with("grep(") {
343 let pattern = self.extract_string(expr).unwrap_or_default();
344 let matches = self.grep(&pattern);
345 return matches
346 .iter()
347 .map(|(i, line)| format!("{}:{}", i, line))
348 .collect::<Vec<_>>()
349 .join("\n");
350 }
351
352 if expr.starts_with("count(") {
354 let pattern = self.extract_string(expr).unwrap_or_default();
355 return self.count(&pattern).to_string();
356 }
357
358 if expr == "lines()" || expr == "lines" {
360 return format!("Lines: {}", self.context_lines.len());
361 }
362
363 if expr.starts_with("slice(") {
365 let nums = self.extract_numbers(expr);
366 if nums.len() >= 2 {
367 return self.slice(nums[0], nums[1]).to_string();
368 }
369 }
370
371 if expr.starts_with("chunks(") || expr.starts_with("chunk(") {
373 let n = self.extract_number(expr).unwrap_or(5);
374 let chunks = self.chunks(n);
375 return format!(
376 "[{} chunks of {} lines each]",
377 chunks.len(),
378 chunks.first().map(|c| c.lines().count()).unwrap_or(0)
379 );
380 }
381
382 if expr == "context" || expr.starts_with("context.slice") || expr.starts_with("context[") {
384 return format!(
385 "[Context: {} chars, {} lines]",
386 self.context.len(),
387 self.context_lines.len()
388 );
389 }
390
391 if expr.starts_with("ast_query(") {
394 let query = self.extract_string(expr).unwrap_or_default();
395 return self.execute_ast_query(&query);
396 }
397
398 if let Some(val) = self.get_var(expr) {
400 return val.to_string();
401 }
402
403 if (expr.starts_with('"') && expr.ends_with('"'))
405 || (expr.starts_with('\'') && expr.ends_with('\''))
406 {
407 let mut chars = expr.chars();
408 let _ = chars.next();
409 let _ = chars.next_back();
410 return chars.collect();
411 }
412
413 expr.to_string()
414 }
415
416 fn extract_number(&self, expr: &str) -> Option<usize> {
417 let start = expr.find('(')?;
418 let end = expr.find(')')?;
419 let inner = expr[start + 1..end].trim();
420 inner.parse().ok()
421 }
422
423 fn extract_numbers(&self, expr: &str) -> Vec<usize> {
424 let start = expr.find('(').unwrap_or(0);
425 let end = expr.find(')').unwrap_or(expr.len());
426 let inner = &expr[start + 1..end];
427
428 inner
429 .split(',')
430 .filter_map(|s| s.trim().parse().ok())
431 .collect()
432 }
433
434 fn extract_string(&self, expr: &str) -> Option<String> {
435 let start = expr.find('(')?;
436 let end = expr.rfind(')')?;
437 let inner = expr[start + 1..end].trim();
438
439 let unquoted = inner
441 .trim_start_matches(['"', '\'', '`', '/'])
442 .trim_end_matches(['"', '\'', '`', '/']);
443
444 Some(unquoted.to_string())
445 }
446
447 fn execute_ast_query(&self, query: &str) -> String {
449 let mut oracle = super::oracle::TreeSitterOracle::new(self.context.clone());
450
451 match oracle.query(query) {
452 Ok(result) => {
453 if result.matches.is_empty() {
454 "(no AST matches)".to_string()
455 } else {
456 let lines: Vec<String> = result
457 .matches
458 .iter()
459 .map(|m| {
460 let captures_str: Vec<String> = m
461 .captures
462 .iter()
463 .map(|(k, v)| format!("{}={:?}", k, v))
464 .collect();
465 format!("L{}: {} [{}]", m.line, m.text, captures_str.join(", "))
466 })
467 .collect();
468 lines.join("\n")
469 }
470 }
471 Err(e) => format!("AST query error: {}", e),
472 }
473 }
474}
475
476pub enum DslResult {
477 Output(String),
478 Final(String),
479 #[allow(dead_code)]
480 Error(String),
481}
482
483pub struct RlmExecutor {
495 repl: RlmRepl,
496 provider: Arc<dyn Provider>,
497 model: String,
498 analysis_temperature: f32,
499 max_iterations: usize,
500 sub_queries: Vec<SubQuery>,
501 trace_steps: Vec<TraceStep>,
502 context_budget_tokens: usize,
503 context_trace: ContextTrace,
504 verbose: bool,
505
506 tool_router: Option<ToolCallRouter>,
507}
508
509impl RlmExecutor {
510 pub fn new(context: String, provider: Arc<dyn Provider>, model: String) -> Self {
512 let context_budget_tokens = std::env::var("CODETETHER_RLM_CONTEXT_BUDGET")
513 .ok()
514 .and_then(|v| v.parse::<usize>().ok())
515 .unwrap_or(32_768);
516
517 let tool_router = {
518 let cfg = ToolRouterConfig::from_env();
519 ToolCallRouter::from_config(&cfg)
520 .inspect_err(|e| {
521 tracing::debug!(error = %e, "FunctionGemma router unavailable for RLM");
522 })
523 .ok()
524 .flatten()
525 };
526
527 Self {
528 repl: RlmRepl::new(context, ReplRuntime::Rust),
529 provider,
530 model,
531 analysis_temperature: 0.3,
532 max_iterations: 5, sub_queries: Vec::new(),
534 trace_steps: Vec::new(),
535 context_budget_tokens,
536 context_trace: ContextTrace::new(context_budget_tokens),
537 verbose: false,
538
539 tool_router,
540 }
541 }
542
543 pub fn with_max_iterations(mut self, max: usize) -> Self {
545 self.max_iterations = max;
546 self
547 }
548
549 pub fn with_temperature(mut self, temperature: f32) -> Self {
551 self.analysis_temperature = temperature.clamp(0.0, 2.0);
552 self
553 }
554
555 pub fn with_verbose(mut self, verbose: bool) -> Self {
560 self.verbose = verbose;
561 self
562 }
563
564 pub fn trace_steps(&self) -> &[TraceStep] {
566 &self.trace_steps
567 }
568
569 pub fn context_trace_summary(&self) -> ContextTraceSummary {
571 self.context_trace.summary()
572 }
573
574 pub async fn analyze(&mut self, query: &str) -> Result<RlmAnalysisResult> {
576 let start = std::time::Instant::now();
577 let mut iterations = 0;
578 let mut total_input_tokens = 0;
579 let mut total_output_tokens = 0;
580 self.sub_queries.clear();
581 self.trace_steps.clear();
582 self.context_trace = ContextTrace::new(self.context_budget_tokens);
583
584 let tools = rlm_tool_definitions();
586
587 let context_summary = format!(
589 "=== CONTEXT LOADED ===\n\
590 Total: {} chars, {} lines\n\
591 Available functions:\n\
592 - head(n) - first n lines\n\
593 - tail(n) - last n lines\n\
594 - grep(\"pattern\") - find lines matching regex\n\
595 - count(\"pattern\") - count regex matches\n\
596 - slice(start, end) - slice by char position\n\
597 - chunks(n) - split into n chunks\n\
598 - ast_query(\"s-expr\") - tree-sitter AST query for structural analysis\n\
599 - llm_query(\"question\", context?) - ask sub-LM a question\n\
600 - FINAL({{json_payload}}) - return structured final payload\n\
601 === END CONTEXT INFO ===",
602 self.repl.context().len(),
603 self.repl.lines().len()
604 );
605
606 if self.verbose {
608 tracing::info!("RLM Context Summary:\n{}", context_summary);
609 println!(
610 "[RLM] Context loaded: {} chars, {} lines",
611 self.repl.context().len(),
612 self.repl.lines().len()
613 );
614 }
615
616 let system_prompt = format!(
617 "You are a code analysis assistant. Answer questions by examining the provided context.\n\n\
618 CRITICAL OUTPUT CONTRACT:\n\
619 - Your final response MUST be exactly one FINAL(<json>) call.\n\
620 - Never end with prose and never use FINAL(\"...\").\n\
621 - For pattern/grep queries, you MUST run grep/head/tail first. Never guess line numbers or matches.\n\
622 - The JSON inside FINAL(...) MUST match one of these shapes:\n\
623 1) {{\"kind\":\"grep\",\"file\":\"<path>\",\"pattern\":\"<regex>\",\"matches\":[{{\"line\":123,\"text\":\"...\"}}]}}\n\
624 2) {{\"kind\":\"ast\",\"file\":\"<path>\",\"query\":\"<tree-sitter query>\",\"results\":[{{\"name\":\"...\",\"args\":[],\"return_type\":null,\"span\":[1,2]}}]}}\n\
625 3) {{\"kind\":\"semantic\",\"file\":\"<path>\",\"answer\":\"...\"}} (only if deterministic payload is impossible)\n\
626 - For grep/list/find/count style queries, emit kind=grep with exact line-numbered matches.\n\n\
627 Available commands:\n\
628 - head(n), tail(n): See first/last n lines\n\
629 - grep(\"pattern\"): Search for patterns\n\
630 - ast_query(\"s-expr\"): Tree-sitter AST query (e.g., '(function_item name: (identifier) @name)')\n\
631 - llm_query(\"question\"): Ask a focused sub-question\n\
632 - FINAL({{\"kind\":\"...\", ...}}): Return your final structured payload (REQUIRED)\n\n\
633 The context has {} chars across {} lines. A preview follows:\n\n\
634 {}\n\n\
635 Example final response:\n\
636 FINAL({{\"kind\":\"grep\",\"file\":\"src/rlm/repl.rs\",\"pattern\":\"async fn\",\"matches\":[{{\"line\":570,\"text\":\"pub async fn analyze(...)\"}}]}})\n\n\
637 Now analyze the context. Use 1-2 commands if needed, then call FINAL() with valid JSON payload.",
638 self.repl.context().len(),
639 self.repl.lines().len(),
640 truncate_with_ellipsis(&self.repl.head(25).join("\n"), 8_000)
641 );
642
643 let mut messages = vec![
644 Message {
645 role: Role::System,
646 content: vec![ContentPart::Text {
647 text: system_prompt,
648 }],
649 },
650 Message {
651 role: Role::User,
652 content: vec![ContentPart::Text {
653 text: format!("Analyze and answer: {}", query),
654 }],
655 },
656 ];
657 let initial_system = messages[0]
658 .content
659 .iter()
660 .filter_map(|p| match p {
661 ContentPart::Text { text } => Some(text.as_str()),
662 _ => None,
663 })
664 .collect::<Vec<_>>()
665 .join("");
666 self.context_trace.log_event(ContextEvent::SystemPrompt {
667 content: "rlm_system_prompt".to_string(),
668 tokens: ContextTrace::estimate_tokens(&initial_system),
669 });
670 self.context_trace.log_event(ContextEvent::ToolCall {
671 name: "initial_query".to_string(),
672 arguments_preview: truncate_with_ellipsis(query, 160),
673 tokens: ContextTrace::estimate_tokens(query),
674 });
675
676 let llm_timeout_secs = std::env::var("CODETETHER_RLM_LLM_TIMEOUT_SECS")
677 .ok()
678 .and_then(|v| v.parse::<u64>().ok())
679 .filter(|v| *v > 0)
680 .unwrap_or(60);
681 let requires_pattern_evidence =
682 GrepOracle::classify_query(query) == QueryType::PatternMatch;
683
684 let mut final_answer = None;
685
686 while iterations < self.max_iterations {
687 iterations += 1;
688 tracing::info!("RLM iteration {}", iterations);
689
690 tracing::debug!("Sending LLM request...");
692 let response = match tokio::time::timeout(
693 std::time::Duration::from_secs(llm_timeout_secs),
694 self.provider.complete(CompletionRequest {
695 messages: messages.clone(),
696 tools: tools.clone(),
697 model: self.model.clone(),
698 temperature: Some(self.analysis_temperature),
699 top_p: None,
700 max_tokens: Some(2000),
701 stop: vec![],
702 }),
703 )
704 .await
705 {
706 Ok(Ok(r)) => {
707 tracing::debug!("LLM response received");
708 r
709 }
710 Ok(Err(e)) => return Err(e),
711 Err(_) => {
712 return Err(anyhow::anyhow!(
713 "LLM request timed out after {} seconds",
714 llm_timeout_secs
715 ));
716 }
717 };
718
719 let response = if let Some(ref router) = self.tool_router {
723 router.maybe_reformat(response, &tools, true).await
727 } else {
728 response
729 };
730
731 total_input_tokens += response.usage.prompt_tokens;
732 total_output_tokens += response.usage.completion_tokens;
733 let assistant_text = response
734 .message
735 .content
736 .iter()
737 .filter_map(|p| match p {
738 ContentPart::Text { text } => Some(text.as_str()),
739 _ => None,
740 })
741 .collect::<Vec<_>>()
742 .join("");
743 if !assistant_text.is_empty() {
744 self.context_trace.log_event(ContextEvent::AssistantCode {
745 code: truncate_with_ellipsis(&assistant_text, 500),
746 tokens: ContextTrace::estimate_tokens(&assistant_text),
747 });
748 }
749
750 let tool_calls: Vec<(String, String, String)> = response
754 .message
755 .content
756 .iter()
757 .filter_map(|p| match p {
758 ContentPart::ToolCall {
759 id,
760 name,
761 arguments,
762 ..
763 } => Some((id.clone(), name.clone(), arguments.clone())),
764 _ => None,
765 })
766 .collect();
767
768 if !tool_calls.is_empty() {
769 tracing::info!(
770 count = tool_calls.len(),
771 "RLM: dispatching structured tool calls"
772 );
773
774 messages.push(Message {
776 role: Role::Assistant,
777 content: response.message.content.clone(),
778 });
779
780 let mut tool_results: Vec<ContentPart> = Vec::new();
781
782 for (call_id, name, arguments) in &tool_calls {
783 self.context_trace.log_event(ContextEvent::ToolCall {
784 name: name.clone(),
785 arguments_preview: truncate_with_ellipsis(arguments, 200),
786 tokens: ContextTrace::estimate_tokens(arguments),
787 });
788 match dispatch_tool_call(name, arguments, &mut self.repl) {
789 Some(RlmToolResult::Final(answer)) => {
790 if requires_pattern_evidence && !self.has_pattern_evidence() {
791 let rejection = "FINAL rejected: pattern query requires grep evidence. Call rlm_grep first, then FINAL with exact line-numbered matches.";
792 self.trace_steps.push(TraceStep {
793 iteration: iterations,
794 action: "reject_final(no_grep_evidence)".to_string(),
795 output: rejection.to_string(),
796 });
797 self.context_trace.log_event(ContextEvent::ToolResult {
798 tool_call_id: call_id.clone(),
799 result_preview: rejection.to_string(),
800 tokens: ContextTrace::estimate_tokens(rejection),
801 });
802 tool_results.push(ContentPart::ToolResult {
803 tool_call_id: call_id.clone(),
804 content: rejection.to_string(),
805 });
806 continue;
807 }
808 if self.verbose {
809 println!("[RLM] Final answer received via tool call");
810 }
811 final_answer = Some(answer.clone());
812 self.trace_steps.push(TraceStep {
813 iteration: iterations,
814 action: format!(
815 "{name}({})",
816 truncate_with_ellipsis(arguments, 120)
817 ),
818 output: format!("FINAL: {}", truncate_with_ellipsis(&answer, 240)),
819 });
820 self.context_trace.log_event(ContextEvent::Final {
821 answer: truncate_with_ellipsis(&answer, 400),
822 tokens: ContextTrace::estimate_tokens(&answer),
823 });
824 tool_results.push(ContentPart::ToolResult {
825 tool_call_id: call_id.clone(),
826 content: format!("FINAL: {answer}"),
827 });
828 break;
829 }
830 Some(RlmToolResult::Output(output)) => {
831 if let Ok(sentinel) = serde_json::from_str::<serde_json::Value>(&output)
833 && sentinel
834 .get("__rlm_llm_query")
835 .and_then(|v| v.as_bool())
836 .unwrap_or(false)
837 {
838 let q =
839 sentinel.get("query").and_then(|v| v.as_str()).unwrap_or("");
840 let ctx_slice = sentinel
841 .get("context_slice")
842 .and_then(|v| v.as_str())
843 .map(|s| s.to_string());
844 let llm_result = self.handle_llm_query_direct(q, ctx_slice).await?;
845 self.trace_steps.push(TraceStep {
846 iteration: iterations,
847 action: format!(
848 "llm_query({})",
849 truncate_with_ellipsis(q, 120)
850 ),
851 output: truncate_with_ellipsis(&llm_result, 240),
852 });
853 self.context_trace.log_event(ContextEvent::ToolResult {
854 tool_call_id: call_id.clone(),
855 result_preview: truncate_with_ellipsis(&llm_result, 300),
856 tokens: ContextTrace::estimate_tokens(&llm_result),
857 });
858 tool_results.push(ContentPart::ToolResult {
859 tool_call_id: call_id.clone(),
860 content: llm_result,
861 });
862 continue;
863 }
864 if self.verbose {
866 let preview = truncate_with_ellipsis(&output, 200);
867 println!("[RLM] Tool {name} → {}", preview);
868 }
869 self.trace_steps.push(TraceStep {
870 iteration: iterations,
871 action: format!(
872 "{name}({})",
873 truncate_with_ellipsis(arguments, 120)
874 ),
875 output: Self::trace_output_for_storage(&output),
876 });
877 self.context_trace.log_event(ContextEvent::ToolResult {
878 tool_call_id: call_id.clone(),
879 result_preview: truncate_with_ellipsis(&output, 300),
880 tokens: ContextTrace::estimate_tokens(&output),
881 });
882 tool_results.push(ContentPart::ToolResult {
883 tool_call_id: call_id.clone(),
884 content: output,
885 });
886 }
887 None => {
888 let unknown = format!("Unknown tool: {name}");
889 self.trace_steps.push(TraceStep {
890 iteration: iterations,
891 action: format!(
892 "{name}({})",
893 truncate_with_ellipsis(arguments, 120)
894 ),
895 output: unknown.clone(),
896 });
897 tool_results.push(ContentPart::ToolResult {
898 tool_call_id: call_id.clone(),
899 content: unknown,
900 });
901 }
902 }
903 }
904
905 if !tool_results.is_empty() {
907 messages.push(Message {
908 role: Role::Tool,
909 content: tool_results,
910 });
911 }
912
913 if final_answer.is_some() {
914 break;
915 }
916 continue;
917 }
918
919 let assistant_text = response
923 .message
924 .content
925 .iter()
926 .filter_map(|p| match p {
927 ContentPart::Text { text } => Some(text.as_str()),
928 _ => None,
929 })
930 .collect::<Vec<_>>()
931 .join("");
932
933 messages.push(Message {
935 role: Role::Assistant,
936 content: vec![ContentPart::Text {
937 text: assistant_text.clone(),
938 }],
939 });
940
941 let code = self.extract_code(&assistant_text);
943 self.trace_steps.push(TraceStep {
944 iteration: iterations,
945 action: format!("execute_code({})", truncate_with_ellipsis(&code, 160)),
946 output: String::new(),
947 });
948
949 if self.verbose {
951 println!("[RLM] Iteration {}: Executing code:\n{}", iterations, code);
952 }
953
954 let execution_result = self.execute_with_llm_query(&code).await?;
955
956 if self.verbose {
958 if let Some(ref answer) = execution_result.final_answer {
959 println!("[RLM] Final answer received: {}", answer);
960 } else if !execution_result.stdout.is_empty() {
961 let preview = truncate_with_ellipsis(&execution_result.stdout, 200);
962 println!("[RLM] Execution output:\n{}", preview);
963 }
964 }
965
966 if let Some(answer) = &execution_result.final_answer {
968 let stdout_has_pattern_evidence = code.contains("grep(")
969 || execution_result.stdout.contains("(no matches)")
970 || !Self::parse_line_numbered_output(&execution_result.stdout).is_empty();
971 if requires_pattern_evidence
972 && !self.has_pattern_evidence()
973 && !stdout_has_pattern_evidence
974 {
975 let rejection = "FINAL rejected: pattern query requires grep evidence. Run grep/head/tail before FINAL and include exact lines.";
976 self.trace_steps.push(TraceStep {
977 iteration: iterations,
978 action: "reject_final(no_grep_evidence)".to_string(),
979 output: rejection.to_string(),
980 });
981 self.context_trace.log_event(ContextEvent::ExecutionOutput {
982 output: rejection.to_string(),
983 tokens: ContextTrace::estimate_tokens(rejection),
984 });
985 messages.push(Message {
986 role: Role::User,
987 content: vec![ContentPart::Text {
988 text: rejection.to_string(),
989 }],
990 });
991 continue;
992 }
993 self.context_trace.log_event(ContextEvent::Final {
994 answer: truncate_with_ellipsis(answer, 400),
995 tokens: ContextTrace::estimate_tokens(answer),
996 });
997 final_answer = Some(answer.clone());
998 break;
999 }
1000
1001 self.context_trace.log_event(ContextEvent::ExecutionOutput {
1002 output: truncate_with_ellipsis(&execution_result.stdout, 400),
1003 tokens: ContextTrace::estimate_tokens(&execution_result.stdout),
1004 });
1005 if let Some(step) = self.trace_steps.last_mut()
1006 && step.iteration == iterations
1007 && step.output.is_empty()
1008 {
1009 step.output = Self::trace_output_for_storage(&execution_result.stdout);
1010 }
1011
1012 let result_text = if execution_result.stdout.is_empty() {
1014 "[No output]".to_string()
1015 } else {
1016 format!("Execution result:\n{}", execution_result.stdout)
1017 };
1018
1019 messages.push(Message {
1020 role: Role::User,
1021 content: vec![ContentPart::Text { text: result_text }],
1022 });
1023 }
1024
1025 let elapsed = start.elapsed();
1026
1027 let raw_final_text = final_answer.unwrap_or_else(|| "Analysis incomplete".to_string());
1028 let final_text = self.ensure_structured_final_payload(query, raw_final_text);
1029 if !matches!(
1030 self.context_trace.events().back(),
1031 Some(ContextEvent::Final { .. })
1032 ) {
1033 self.context_trace.log_event(ContextEvent::Final {
1034 answer: truncate_with_ellipsis(&final_text, 400),
1035 tokens: ContextTrace::estimate_tokens(&final_text),
1036 });
1037 }
1038
1039 Ok(RlmAnalysisResult {
1040 answer: final_text,
1041 iterations,
1042 sub_queries: self.sub_queries.clone(),
1043 stats: super::RlmStats {
1044 input_tokens: total_input_tokens,
1045 output_tokens: total_output_tokens,
1046 iterations,
1047 subcalls: self.sub_queries.len(),
1048 elapsed_ms: elapsed.as_millis() as u64,
1049 compression_ratio: 1.0,
1050 },
1051 })
1052 }
1053
1054 fn ensure_structured_final_payload(&mut self, query: &str, raw_final_text: String) -> String {
1055 let parsed = FinalPayload::parse(&raw_final_text);
1056
1057 if let Some(canonical) = self.coerce_grep_payload_from_trace(query) {
1058 let canonical_payload = FinalPayload::parse(&canonical);
1059 match &parsed {
1060 FinalPayload::Grep(_) => {
1061 if canonical_payload != parsed {
1062 let iteration = self.trace_steps.last().map(|s| s.iteration).unwrap_or(1);
1063 self.trace_steps.push(TraceStep {
1064 iteration,
1065 action: "normalize_final_payload(grep_trace)".to_string(),
1066 output: truncate_with_ellipsis(&canonical, 240),
1067 });
1068 self.context_trace.log_event(ContextEvent::Final {
1069 answer: truncate_with_ellipsis(&canonical, 400),
1070 tokens: ContextTrace::estimate_tokens(&canonical),
1071 });
1072 tracing::info!(
1073 "RLM normalized FINAL(JSON) grep payload using trace evidence"
1074 );
1075 return canonical;
1076 }
1077 return raw_final_text;
1078 }
1079 FinalPayload::Malformed { .. } => {
1080 let iteration = self.trace_steps.last().map(|s| s.iteration).unwrap_or(1);
1081 self.trace_steps.push(TraceStep {
1082 iteration,
1083 action: "coerce_final_payload(grep_trace)".to_string(),
1084 output: truncate_with_ellipsis(&canonical, 240),
1085 });
1086 self.context_trace.log_event(ContextEvent::Final {
1087 answer: truncate_with_ellipsis(&canonical, 400),
1088 tokens: ContextTrace::estimate_tokens(&canonical),
1089 });
1090 tracing::info!(
1091 "RLM coerced malformed/prose final answer into FINAL(JSON) grep payload"
1092 );
1093 return canonical;
1094 }
1095 _ => {
1096 let iteration = self.trace_steps.last().map(|s| s.iteration).unwrap_or(1);
1097 self.trace_steps.push(TraceStep {
1098 iteration,
1099 action: "coerce_final_payload(grep_trace)".to_string(),
1100 output: truncate_with_ellipsis(&canonical, 240),
1101 });
1102 self.context_trace.log_event(ContextEvent::Final {
1103 answer: truncate_with_ellipsis(&canonical, 400),
1104 tokens: ContextTrace::estimate_tokens(&canonical),
1105 });
1106 tracing::info!(
1107 "RLM coerced non-grep FINAL payload into canonical grep payload using trace evidence"
1108 );
1109 return canonical;
1110 }
1111 }
1112 }
1113
1114 raw_final_text
1115 }
1116
1117 fn coerce_grep_payload_from_trace(&self, query: &str) -> Option<String> {
1118 if GrepOracle::classify_query(query) != QueryType::PatternMatch {
1119 return None;
1120 }
1121
1122 let pattern = GrepOracle::infer_pattern(query)?;
1123 let matches = self.extract_latest_grep_matches()?;
1124 let file = Self::infer_file_from_query(query).unwrap_or_else(|| "unknown".to_string());
1125
1126 let payload = FinalPayload::Grep(GrepPayload {
1127 file,
1128 pattern,
1129 matches: matches
1130 .into_iter()
1131 .map(|(line, text)| GrepMatch { line, text })
1132 .collect(),
1133 });
1134
1135 serde_json::to_string(&payload).ok()
1136 }
1137
1138 fn has_pattern_evidence(&self) -> bool {
1139 self.trace_steps.iter().any(|step| {
1140 step.action.contains("grep(")
1141 || step.action.contains("rlm_grep(")
1142 || step.output.contains("(no matches)")
1143 || !Self::parse_line_numbered_output(&step.output).is_empty()
1144 })
1145 }
1146
1147 fn extract_latest_grep_matches(&self) -> Option<Vec<(usize, String)>> {
1148 for step in self.trace_steps.iter().rev() {
1149 if !step.action.contains("grep(") && !step.action.contains("rlm_grep(") {
1150 continue;
1151 }
1152 if step.output.trim() == "(no matches)" {
1153 return Some(Vec::new());
1154 }
1155 let parsed = Self::parse_line_numbered_output(&step.output);
1156 if !parsed.is_empty() {
1157 return Some(parsed);
1158 }
1159 }
1160
1161 for step in self.trace_steps.iter().rev() {
1162 let parsed = Self::parse_line_numbered_output(&step.output);
1163 if !parsed.is_empty() {
1164 return Some(parsed);
1165 }
1166 }
1167
1168 None
1169 }
1170
1171 fn parse_line_numbered_output(output: &str) -> Vec<(usize, String)> {
1172 output
1173 .lines()
1174 .filter_map(|line| {
1175 let trimmed = line.trim();
1176 let (line_no, text) = trimmed.split_once(':')?;
1177 let number = line_no
1178 .trim()
1179 .trim_start_matches('L')
1180 .parse::<usize>()
1181 .ok()?;
1182 Some((number, text.trim_end_matches('\r').to_string()))
1183 })
1184 .collect()
1185 }
1186
1187 fn infer_file_from_query(query: &str) -> Option<String> {
1188 let lower = query.to_lowercase();
1189 let idx = lower.rfind(" in ")?;
1190 let candidate = query[idx + 4..]
1191 .split_whitespace()
1192 .next()
1193 .unwrap_or_default()
1194 .trim_matches(|c: char| c == '"' || c == '\'' || c == '`' || c == '.' || c == ',');
1195 if candidate.is_empty() {
1196 None
1197 } else {
1198 Some(candidate.to_string())
1199 }
1200 }
1201
1202 fn trace_output_for_storage(output: &str) -> String {
1203 let trimmed = output.trim();
1204 if trimmed == "(no matches)" || !Self::parse_line_numbered_output(output).is_empty() {
1205 output.to_string()
1206 } else {
1207 truncate_with_ellipsis(output, 240)
1208 }
1209 }
1210
1211 fn extract_code(&self, text: &str) -> String {
1213 let mut code_lines = Vec::new();
1215 let mut in_code_block = false;
1216
1217 for line in text.lines() {
1218 if line.starts_with("```") {
1219 in_code_block = !in_code_block;
1220 continue;
1221 }
1222 if in_code_block {
1223 code_lines.push(line);
1224 }
1225 }
1226
1227 if !code_lines.is_empty() {
1228 return code_lines.join("\n");
1229 }
1230
1231 text.lines()
1233 .filter(|line| {
1234 let l = line.trim();
1235 l.starts_with("head(")
1236 || l.starts_with("tail(")
1237 || l.starts_with("grep(")
1238 || l.starts_with("count(")
1239 || l.starts_with("llm_query(")
1240 || l.starts_with("ast_query(")
1241 || l.starts_with("FINAL(")
1242 || l.starts_with("let ")
1243 || l.starts_with("const ")
1244 || l.starts_with("print")
1245 || l.starts_with("console.")
1246 })
1247 .collect::<Vec<_>>()
1248 .join("\n")
1249 }
1250
1251 async fn execute_with_llm_query(&mut self, code: &str) -> Result<ReplResult> {
1253 let mut stdout = Vec::new();
1254 let mut final_answer = None;
1255
1256 for line in code.lines() {
1257 let line = line.trim();
1258 if line.is_empty() || line.starts_with("//") || line.starts_with('#') {
1259 continue;
1260 }
1261
1262 if line.starts_with("llm_query(") || line.contains("= llm_query(") {
1264 let result = self.handle_llm_query(line).await?;
1265 stdout.push(result);
1266 continue;
1267 }
1268
1269 if let Some(result) = self.repl.execute_dsl_line(line) {
1271 match result {
1272 DslResult::Output(s) => stdout.push(s),
1273 DslResult::Final(s) => {
1274 final_answer = Some(s);
1275 break;
1276 }
1277 DslResult::Error(s) => stdout.push(format!("Error: {}", s)),
1278 }
1279 }
1280 }
1281
1282 Ok(ReplResult {
1283 stdout: stdout.join("\n"),
1284 stderr: String::new(),
1285 final_answer,
1286 })
1287 }
1288
1289 async fn handle_llm_query(&mut self, line: &str) -> Result<String> {
1291 let (query, context_slice) = self.parse_llm_query(line);
1293 self.context_trace.log_event(ContextEvent::LlmQueryResult {
1294 query: query.clone(),
1295 response_preview: "[pending]".to_string(),
1296 tokens: ContextTrace::estimate_tokens(&query),
1297 });
1298
1299 let context_to_analyze = context_slice
1301 .clone()
1302 .unwrap_or_else(|| self.repl.context().to_string());
1303
1304 let context_chars = context_to_analyze.chars().count();
1306 let truncated_context = if context_chars > 8000 {
1307 format!(
1308 "{}\n[truncated, {} chars total]",
1309 truncate_with_ellipsis(&context_to_analyze, 7500),
1310 context_chars
1311 )
1312 } else {
1313 context_to_analyze.clone()
1314 };
1315
1316 let messages = vec![
1318 Message {
1319 role: Role::System,
1320 content: vec![ContentPart::Text {
1321 text: "You are a focused analysis assistant. Answer the question based on the provided context. Be concise.".to_string(),
1322 }],
1323 },
1324 Message {
1325 role: Role::User,
1326 content: vec![ContentPart::Text {
1327 text: format!("Context:\n{}\n\nQuestion: {}", truncated_context, query),
1328 }],
1329 },
1330 ];
1331
1332 let response = self
1333 .provider
1334 .complete(CompletionRequest {
1335 messages,
1336 tools: vec![],
1337 model: self.model.clone(),
1338 temperature: Some(0.3),
1339 top_p: None,
1340 max_tokens: Some(500),
1341 stop: vec![],
1342 })
1343 .await?;
1344
1345 let answer = response
1346 .message
1347 .content
1348 .iter()
1349 .filter_map(|p| match p {
1350 ContentPart::Text { text } => Some(text.as_str()),
1351 _ => None,
1352 })
1353 .collect::<Vec<_>>()
1354 .join("");
1355
1356 self.sub_queries.push(SubQuery {
1358 query: query.clone(),
1359 context_slice,
1360 response: answer.clone(),
1361 tokens_used: response.usage.total_tokens,
1362 });
1363 self.trace_steps.push(TraceStep {
1364 iteration: self.sub_queries.len(),
1365 action: format!("llm_query({})", truncate_with_ellipsis(&query, 120)),
1366 output: truncate_with_ellipsis(&answer, 240),
1367 });
1368 self.context_trace.log_event(ContextEvent::LlmQueryResult {
1369 query,
1370 response_preview: truncate_with_ellipsis(&answer, 240),
1371 tokens: response.usage.total_tokens,
1372 });
1373
1374 Ok(format!("llm_query result: {}", answer))
1375 }
1376
1377 async fn handle_llm_query_direct(
1382 &mut self,
1383 query: &str,
1384 context_slice: Option<String>,
1385 ) -> Result<String> {
1386 self.context_trace.log_event(ContextEvent::LlmQueryResult {
1387 query: query.to_string(),
1388 response_preview: "[pending]".to_string(),
1389 tokens: ContextTrace::estimate_tokens(query),
1390 });
1391 let context_to_analyze = context_slice
1392 .clone()
1393 .unwrap_or_else(|| self.repl.context().to_string());
1394
1395 let context_chars = context_to_analyze.chars().count();
1396 let truncated_context = if context_chars > 8000 {
1397 format!(
1398 "{}\n[truncated, {} chars total]",
1399 truncate_with_ellipsis(&context_to_analyze, 7500),
1400 context_chars
1401 )
1402 } else {
1403 context_to_analyze.clone()
1404 };
1405
1406 let messages = vec![
1407 Message {
1408 role: Role::System,
1409 content: vec![ContentPart::Text {
1410 text: "You are a focused analysis assistant. Answer the question based on the provided context. Be concise.".to_string(),
1411 }],
1412 },
1413 Message {
1414 role: Role::User,
1415 content: vec![ContentPart::Text {
1416 text: format!("Context:\n{}\n\nQuestion: {}", truncated_context, query),
1417 }],
1418 },
1419 ];
1420
1421 let response = self
1422 .provider
1423 .complete(CompletionRequest {
1424 messages,
1425 tools: vec![],
1426 model: self.model.clone(),
1427 temperature: Some(0.3),
1428 top_p: None,
1429 max_tokens: Some(500),
1430 stop: vec![],
1431 })
1432 .await?;
1433
1434 let answer = response
1435 .message
1436 .content
1437 .iter()
1438 .filter_map(|p| match p {
1439 ContentPart::Text { text } => Some(text.as_str()),
1440 _ => None,
1441 })
1442 .collect::<Vec<_>>()
1443 .join("");
1444
1445 self.sub_queries.push(SubQuery {
1446 query: query.to_string(),
1447 context_slice,
1448 response: answer.clone(),
1449 tokens_used: response.usage.total_tokens,
1450 });
1451 self.context_trace.log_event(ContextEvent::LlmQueryResult {
1452 query: query.to_string(),
1453 response_preview: truncate_with_ellipsis(&answer, 240),
1454 tokens: response.usage.total_tokens,
1455 });
1456
1457 Ok(format!("llm_query result: {}", answer))
1458 }
1459
1460 fn parse_llm_query(&mut self, line: &str) -> (String, Option<String>) {
1462 let start = line.find('(').unwrap_or(0) + 1;
1464 let end = line.rfind(')').unwrap_or(line.len());
1465 let args = &line[start..end];
1466
1467 let mut query = String::new();
1469 let mut context = None;
1470 let mut in_quotes = false;
1471 let mut current = String::new();
1472 let mut parts = Vec::new();
1473
1474 for c in args.chars() {
1475 if c == '"' || c == '\'' {
1476 in_quotes = !in_quotes;
1477 } else if c == ',' && !in_quotes {
1478 parts.push(current.trim().to_string());
1479 current = String::new();
1480 continue;
1481 }
1482 current.push(c);
1483 }
1484 if !current.is_empty() {
1485 parts.push(current.trim().to_string());
1486 }
1487
1488 if let Some(q) = parts.first() {
1490 query = q.trim_matches(|c| c == '"' || c == '\'').to_string();
1491 }
1492
1493 if let Some(ctx_expr) = parts.get(1) {
1495 let ctx = self.repl.evaluate_expression(ctx_expr);
1497 if !ctx.is_empty() && !ctx.starts_with('[') {
1498 context = Some(ctx);
1499 }
1500 }
1501
1502 (query, context)
1503 }
1504}
1505
1506pub struct ExternalRepl {
1508 child: Child,
1509 #[allow(dead_code)]
1510 runtime: ReplRuntime,
1511}
1512
1513impl ExternalRepl {
1514 pub async fn spawn_bun(context: &str) -> Result<Self> {
1516 let init_script = Self::generate_bun_init(context);
1517
1518 let temp_dir = std::env::temp_dir().join("rlm-repl");
1520 tokio::fs::create_dir_all(&temp_dir).await?;
1521 let script_path = temp_dir.join(format!("init_{}.js", std::process::id()));
1522 tokio::fs::write(&script_path, init_script).await?;
1523
1524 let runtime = if Self::is_bun_available().await {
1526 "bun"
1527 } else {
1528 "node"
1529 };
1530
1531 let child = Command::new(runtime)
1532 .arg(&script_path)
1533 .stdin(Stdio::piped())
1534 .stdout(Stdio::piped())
1535 .stderr(Stdio::piped())
1536 .spawn()?;
1537
1538 Ok(Self {
1539 child,
1540 runtime: ReplRuntime::Bun,
1541 })
1542 }
1543
1544 async fn is_bun_available() -> bool {
1545 Command::new("bun")
1546 .arg("--version")
1547 .output()
1548 .await
1549 .map(|o| o.status.success())
1550 .unwrap_or(false)
1551 }
1552
1553 fn generate_bun_init(context: &str) -> String {
1554 let escaped = context
1555 .replace('\\', "\\\\")
1556 .replace('"', "\\\"")
1557 .replace('\n', "\\n");
1558
1559 format!(
1560 r#"
1561const readline = require('readline');
1562const rl = readline.createInterface({{ input: process.stdin, output: process.stdout, terminal: false }});
1563
1564const context = "{escaped}";
1565
1566function lines() {{ return context.split("\n"); }}
1567function head(n = 10) {{ return lines().slice(0, n).join("\n"); }}
1568function tail(n = 10) {{ return lines().slice(-n).join("\n"); }}
1569function grep(pattern) {{
1570 const re = pattern instanceof RegExp ? pattern : new RegExp(pattern, 'gi');
1571 return lines().filter(l => re.test(l));
1572}}
1573function count(pattern) {{
1574 const re = pattern instanceof RegExp ? pattern : new RegExp(pattern, 'gi');
1575 return (context.match(re) || []).length;
1576}}
1577function FINAL(answer) {{
1578 console.log("__FINAL__" + String(answer) + "__FINAL_END__");
1579}}
1580
1581console.log("READY");
1582
1583rl.on('line', async (line) => {{
1584 try {{
1585 const result = eval(line);
1586 if (result !== undefined) console.log(result);
1587 }} catch (e) {{
1588 console.error("Error:", e.message);
1589 }}
1590 console.log("__DONE__");
1591}});
1592"#
1593 )
1594 }
1595
1596 pub async fn execute(&mut self, code: &str) -> Result<ReplResult> {
1598 let stdin = self
1599 .child
1600 .stdin
1601 .as_mut()
1602 .ok_or_else(|| anyhow::anyhow!("No stdin"))?;
1603 let stdout = self
1604 .child
1605 .stdout
1606 .as_mut()
1607 .ok_or_else(|| anyhow::anyhow!("No stdout"))?;
1608
1609 stdin.write_all(code.as_bytes()).await?;
1610 stdin.write_all(b"\n").await?;
1611 stdin.flush().await?;
1612
1613 let mut reader = BufReader::new(stdout);
1614 let mut output = Vec::new();
1615 let mut final_answer = None;
1616
1617 loop {
1618 let mut line = String::new();
1619 match timeout(Duration::from_secs(30), reader.read_line(&mut line)).await {
1620 Ok(Ok(0)) | Err(_) => break, Ok(Ok(_)) => {
1622 let line = line.trim();
1623 if line == "__DONE__" {
1624 break;
1625 }
1626 if let Some(answer) = Self::extract_final(line) {
1627 final_answer = Some(answer);
1628 break;
1629 }
1630 output.push(line.to_string());
1631 }
1632 Ok(Err(e)) => return Err(anyhow::anyhow!("Read error: {}", e)),
1633 }
1634 }
1635
1636 Ok(ReplResult {
1637 stdout: output.join("\n"),
1638 stderr: String::new(),
1639 final_answer,
1640 })
1641 }
1642
1643 fn extract_final(line: &str) -> Option<String> {
1644 if line.contains("__FINAL__") {
1645 let start = line.find("__FINAL__")? + 9;
1646 let end = line.find("__FINAL_END__")?;
1647 return Some(line[start..end].to_string());
1648 }
1649 None
1650 }
1651
1652 pub async fn destroy(&mut self) -> Result<()> {
1654 tracing::debug!(runtime = ?self.runtime, "Destroying external REPL");
1655 self.child.kill().await?;
1656 Ok(())
1657 }
1658
1659 pub fn runtime(&self) -> ReplRuntime {
1661 self.runtime
1662 }
1663}
1664
1665fn char_index_to_byte_index(value: &str, char_index: usize) -> usize {
1666 if char_index == 0 {
1667 return 0;
1668 }
1669
1670 value
1671 .char_indices()
1672 .nth(char_index)
1673 .map(|(idx, _)| idx)
1674 .unwrap_or(value.len())
1675}
1676
1677fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
1678 if max_chars == 0 {
1679 return String::new();
1680 }
1681
1682 let mut chars = value.chars();
1683 let mut output = String::new();
1684 for _ in 0..max_chars {
1685 if let Some(ch) = chars.next() {
1686 output.push(ch);
1687 } else {
1688 return value.to_string();
1689 }
1690 }
1691
1692 if chars.next().is_some() {
1693 format!("{output}...")
1694 } else {
1695 output
1696 }
1697}
1698
1699#[cfg(test)]
1700mod tests {
1701 use super::*;
1702
1703 #[test]
1704 fn test_repl_head_tail() {
1705 let context = (1..=100)
1706 .map(|i| format!("line {}", i))
1707 .collect::<Vec<_>>()
1708 .join("\n");
1709 let repl = RlmRepl::new(context, ReplRuntime::Rust);
1710
1711 let head = repl.head(5);
1712 assert_eq!(head.len(), 5);
1713 assert_eq!(head[0], "line 1");
1714
1715 let tail = repl.tail(5);
1716 assert_eq!(tail.len(), 5);
1717 assert_eq!(tail[4], "line 100");
1718 }
1719
1720 #[test]
1721 fn test_repl_grep() {
1722 let context = "error: something failed\ninfo: all good\nerror: another failure".to_string();
1723 let repl = RlmRepl::new(context, ReplRuntime::Rust);
1724
1725 let matches = repl.grep("error");
1726 assert_eq!(matches.len(), 2);
1727 }
1728
1729 #[test]
1730 fn test_repl_execute_final() {
1731 let context = "test content".to_string();
1732 let mut repl = RlmRepl::new(context, ReplRuntime::Rust);
1733
1734 let result = repl.execute(r#"FINAL("This is the answer")"#);
1735 assert_eq!(result.final_answer, Some("This is the answer".to_string()));
1736 }
1737
1738 #[test]
1739 fn test_parse_line_numbered_output() {
1740 let parsed =
1741 RlmExecutor::parse_line_numbered_output("570:async fn analyze\nL1038:async fn x");
1742 assert_eq!(parsed.len(), 2);
1743 assert_eq!(parsed[0], (570, "async fn analyze".to_string()));
1744 assert_eq!(parsed[1], (1038, "async fn x".to_string()));
1745 }
1746
1747 #[test]
1748 fn test_infer_file_from_query() {
1749 let file = RlmExecutor::infer_file_from_query(
1750 "Find all occurrences of 'async fn' in src/rlm/repl.rs",
1751 );
1752 assert_eq!(file.as_deref(), Some("src/rlm/repl.rs"));
1753 }
1754
1755 #[test]
1756 fn test_repl_chunks() {
1757 let context = (1..=100)
1758 .map(|i| format!("line {}", i))
1759 .collect::<Vec<_>>()
1760 .join("\n");
1761 let repl = RlmRepl::new(context, ReplRuntime::Rust);
1762
1763 let chunks = repl.chunks(5);
1764 assert_eq!(chunks.len(), 5);
1765 }
1766}