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