Skip to main content

codetether_agent/rlm/
repl.rs

1//! RLM REPL - Execution environment for RLM processing
2//!
3//! Provides a REPL-like environment where context is loaded as a variable
4//! and the LLM can execute code to analyze it.
5//!
6//! Key feature: llm_query() function for recursive sub-LM calls.
7//!
8//! When the `functiongemma` feature is active the executor sends RLM tool
9//! definitions alongside the analysis prompt.  Responses containing structured
10//! `ContentPart::ToolCall` entries are dispatched directly instead of being
11//! regex-parsed from code blocks (the legacy DSL path is kept as a fallback).
12
13use 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
25#[cfg(feature = "functiongemma")]
26use crate::cognition::tool_router::{ToolCallRouter, ToolRouterConfig};
27
28use super::tools::{RlmToolResult, dispatch_tool_call, rlm_tool_definitions};
29
30/// REPL runtime options
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
32#[serde(rename_all = "lowercase")]
33pub enum ReplRuntime {
34    /// Native Rust REPL (fastest, uses rhai scripting)
35    #[default]
36    Rust,
37    /// Bun/Node.js JavaScript REPL
38    Bun,
39    /// Python REPL
40    Python,
41}
42
43/// REPL instance for RLM processing
44pub struct RlmRepl {
45    runtime: ReplRuntime,
46    context: String,
47    context_lines: Vec<String>,
48    variables: HashMap<String, String>,
49}
50
51/// Result of REPL execution
52#[derive(Debug, Clone)]
53pub struct ReplResult {
54    pub stdout: String,
55    pub stderr: String,
56    pub final_answer: Option<String>,
57}
58
59impl RlmRepl {
60    /// Create a new REPL with the given context
61    pub fn new(context: String, runtime: ReplRuntime) -> Self {
62        let context_lines = context.lines().map(|s| s.to_string()).collect();
63        Self {
64            runtime,
65            context,
66            context_lines,
67            variables: HashMap::new(),
68        }
69    }
70
71    /// Get the context
72    pub fn context(&self) -> &str {
73        &self.context
74    }
75
76    /// Get context as lines
77    pub fn lines(&self) -> &[String] {
78        &self.context_lines
79    }
80
81    /// Get first n lines
82    pub fn head(&self, n: usize) -> Vec<&str> {
83        self.context_lines
84            .iter()
85            .take(n)
86            .map(|s| s.as_str())
87            .collect()
88    }
89
90    /// Get last n lines
91    pub fn tail(&self, n: usize) -> Vec<&str> {
92        let start = self.context_lines.len().saturating_sub(n);
93        self.context_lines
94            .iter()
95            .skip(start)
96            .map(|s| s.as_str())
97            .collect()
98    }
99
100    /// Search for lines matching a pattern
101    pub fn grep(&self, pattern: &str) -> Vec<(usize, &str)> {
102        let re = match regex::Regex::new(pattern) {
103            Ok(r) => r,
104            Err(_) => {
105                // Fall back to simple contains
106                return self
107                    .context_lines
108                    .iter()
109                    .enumerate()
110                    .filter(|(_, line)| line.contains(pattern))
111                    .map(|(i, line)| (i + 1, line.as_str()))
112                    .collect();
113            }
114        };
115
116        self.context_lines
117            .iter()
118            .enumerate()
119            .filter(|(_, line)| re.is_match(line))
120            .map(|(i, line)| (i + 1, line.as_str()))
121            .collect()
122    }
123
124    /// Count occurrences of a pattern
125    pub fn count(&self, pattern: &str) -> usize {
126        let re = match regex::Regex::new(pattern) {
127            Ok(r) => r,
128            Err(_) => return self.context.matches(pattern).count(),
129        };
130        re.find_iter(&self.context).count()
131    }
132
133    /// Slice context by character positions
134    pub fn slice(&self, start: usize, end: usize) -> &str {
135        let total_chars = self.context.chars().count();
136        let end = end.min(total_chars);
137        let start = start.min(end);
138        let start_byte = char_index_to_byte_index(&self.context, start);
139        let end_byte = char_index_to_byte_index(&self.context, end);
140        &self.context[start_byte..end_byte]
141    }
142
143    /// Split context into n chunks
144    pub fn chunks(&self, n: usize) -> Vec<String> {
145        if n == 0 {
146            return vec![self.context.clone()];
147        }
148
149        let chunk_size = self.context_lines.len().div_ceil(n);
150        self.context_lines
151            .chunks(chunk_size)
152            .map(|chunk| chunk.join("\n"))
153            .collect()
154    }
155
156    /// Set a variable
157    pub fn set_var(&mut self, name: &str, value: String) {
158        self.variables.insert(name.to_string(), value);
159    }
160
161    /// Get a variable
162    pub fn get_var(&self, name: &str) -> Option<&str> {
163        self.variables.get(name).map(|s| s.as_str())
164    }
165
166    /// Execute analysis code (interpreted based on runtime)
167    ///
168    /// For Rust runtime, this uses a simple DSL:
169    /// - head(n) - first n lines
170    /// - tail(n) - last n lines
171    /// - grep("pattern") - search for pattern
172    /// - count("pattern") - count matches
173    /// - slice(start, end) - slice by chars
174    /// - chunks(n) - split into n chunks
175    /// - FINAL("answer") - return final answer
176    pub fn execute(&mut self, code: &str) -> ReplResult {
177        match self.runtime {
178            ReplRuntime::Rust => self.execute_rust_dsl(code),
179            ReplRuntime::Bun | ReplRuntime::Python => {
180                // For external runtimes, we'd spawn processes
181                // For now, fall back to DSL
182                self.execute_rust_dsl(code)
183            }
184        }
185    }
186
187    fn execute_rust_dsl(&mut self, code: &str) -> ReplResult {
188        let mut stdout = Vec::new();
189        let mut final_answer = None;
190
191        for line in code.lines() {
192            let line = line.trim();
193            if line.is_empty() || line.starts_with("//") || line.starts_with('#') {
194                continue;
195            }
196
197            // Parse and execute commands
198            if let Some(result) = self.execute_dsl_line(line) {
199                match result {
200                    DslResult::Output(s) => stdout.push(s),
201                    DslResult::Final(s) => {
202                        final_answer = Some(s);
203                        break;
204                    }
205                    DslResult::Error(s) => stdout.push(format!("Error: {}", s)),
206                }
207            }
208        }
209
210        ReplResult {
211            stdout: stdout.join("\n"),
212            stderr: String::new(),
213            final_answer,
214        }
215    }
216
217    pub fn execute_dsl_line(&mut self, line: &str) -> Option<DslResult> {
218        // Check for FINAL
219        if line.starts_with("FINAL(") || line.starts_with("FINAL!(") {
220            let start = line.find('(').unwrap() + 1;
221            let end = line.rfind(')').unwrap_or(line.len());
222            let answer = line[start..end]
223                .trim()
224                .trim_matches(|c| c == '"' || c == '\'' || c == '`');
225            return Some(DslResult::Final(answer.to_string()));
226        }
227
228        // Check for print/console.log
229        if line.starts_with("print(")
230            || line.starts_with("println!(")
231            || line.starts_with("console.log(")
232        {
233            let start = line.find('(').unwrap() + 1;
234            let end = line.rfind(')').unwrap_or(line.len());
235            let content = line[start..end]
236                .trim()
237                .trim_matches(|c| c == '"' || c == '\'' || c == '`');
238
239            // Expand variables
240            let expanded = self.expand_expression(content);
241            return Some(DslResult::Output(expanded));
242        }
243
244        // Check for variable assignment
245        if let Some(eq_pos) = line.find('=') {
246            if !line.contains("==") && !line.starts_with("if ") {
247                let var_name = line[..eq_pos]
248                    .trim()
249                    .trim_start_matches("let ")
250                    .trim_start_matches("const ")
251                    .trim_start_matches("var ")
252                    .trim();
253                let expr = line[eq_pos + 1..].trim().trim_end_matches(';');
254
255                let value = self.evaluate_expression(expr);
256                self.set_var(var_name, value);
257                return None;
258            }
259        }
260
261        // Check for function calls that should output
262        if line.starts_with("head(")
263            || line.starts_with("tail(")
264            || line.starts_with("grep(")
265            || line.starts_with("count(")
266            || line.starts_with("lines()")
267            || line.starts_with("slice(")
268            || line.starts_with("chunks(")
269            || line.starts_with("context")
270        {
271            let result = self.evaluate_expression(line);
272            return Some(DslResult::Output(result));
273        }
274
275        None
276    }
277
278    fn expand_expression(&self, expr: &str) -> String {
279        // Simple variable expansion
280        let mut result = expr.to_string();
281
282        for (name, value) in &self.variables {
283            let patterns = [
284                format!("${{{}}}", name),
285                format!("${}", name),
286                format!("{{{}}}", name),
287            ];
288            for p in patterns {
289                result = result.replace(&p, value);
290            }
291        }
292
293        // Evaluate embedded expressions
294        if result.contains("context.len()") || result.contains("context.length") {
295            result = result
296                .replace("context.len()", &self.context.len().to_string())
297                .replace("context.length", &self.context.len().to_string());
298        }
299
300        if result.contains("lines().len()") || result.contains("lines().length") {
301            result = result
302                .replace("lines().len()", &self.context_lines.len().to_string())
303                .replace("lines().length", &self.context_lines.len().to_string());
304        }
305
306        result
307    }
308
309    pub fn evaluate_expression(&mut self, expr: &str) -> String {
310        let expr = expr.trim().trim_end_matches(';');
311
312        // head(n)
313        if expr.starts_with("head(") {
314            let n = self.extract_number(expr).unwrap_or(10);
315            return self.head(n).join("\n");
316        }
317
318        // tail(n)
319        if expr.starts_with("tail(") {
320            let n = self.extract_number(expr).unwrap_or(10);
321            return self.tail(n).join("\n");
322        }
323
324        // grep("pattern")
325        if expr.starts_with("grep(") {
326            let pattern = self.extract_string(expr).unwrap_or_default();
327            let matches = self.grep(&pattern);
328            return matches
329                .iter()
330                .map(|(i, line)| format!("{}:{}", i, line))
331                .collect::<Vec<_>>()
332                .join("\n");
333        }
334
335        // count("pattern")
336        if expr.starts_with("count(") {
337            let pattern = self.extract_string(expr).unwrap_or_default();
338            return self.count(&pattern).to_string();
339        }
340
341        // lines()
342        if expr == "lines()" || expr == "lines" {
343            return format!("Lines: {}", self.context_lines.len());
344        }
345
346        // slice(start, end)
347        if expr.starts_with("slice(") {
348            let nums = self.extract_numbers(expr);
349            if nums.len() >= 2 {
350                return self.slice(nums[0], nums[1]).to_string();
351            }
352        }
353
354        // chunks(n)
355        if expr.starts_with("chunks(") || expr.starts_with("chunk(") {
356            let n = self.extract_number(expr).unwrap_or(5);
357            let chunks = self.chunks(n);
358            return format!(
359                "[{} chunks of {} lines each]",
360                chunks.len(),
361                chunks.first().map(|c| c.lines().count()).unwrap_or(0)
362            );
363        }
364
365        // context
366        if expr == "context" || expr.starts_with("context.slice") || expr.starts_with("context[") {
367            return format!(
368                "[Context: {} chars, {} lines]",
369                self.context.len(),
370                self.context_lines.len()
371            );
372        }
373
374        // Variable reference
375        if let Some(val) = self.get_var(expr) {
376            return val.to_string();
377        }
378
379        // String literal
380        if (expr.starts_with('"') && expr.ends_with('"'))
381            || (expr.starts_with('\'') && expr.ends_with('\''))
382        {
383            let mut chars = expr.chars();
384            let _ = chars.next();
385            let _ = chars.next_back();
386            return chars.collect();
387        }
388
389        expr.to_string()
390    }
391
392    fn extract_number(&self, expr: &str) -> Option<usize> {
393        let start = expr.find('(')?;
394        let end = expr.find(')')?;
395        let inner = expr[start + 1..end].trim();
396        inner.parse().ok()
397    }
398
399    fn extract_numbers(&self, expr: &str) -> Vec<usize> {
400        let start = expr.find('(').unwrap_or(0);
401        let end = expr.find(')').unwrap_or(expr.len());
402        let inner = &expr[start + 1..end];
403
404        inner
405            .split(',')
406            .filter_map(|s| s.trim().parse().ok())
407            .collect()
408    }
409
410    fn extract_string(&self, expr: &str) -> Option<String> {
411        let start = expr.find('(')?;
412        let end = expr.rfind(')')?;
413        let inner = expr[start + 1..end].trim();
414
415        // Remove quotes
416        let unquoted = inner
417            .trim_start_matches(['"', '\'', '`', '/'])
418            .trim_end_matches(['"', '\'', '`', '/']);
419
420        Some(unquoted.to_string())
421    }
422}
423
424pub enum DslResult {
425    Output(String),
426    Final(String),
427    #[allow(dead_code)]
428    Error(String),
429}
430
431/// LLM-powered RLM executor
432///
433/// This is the main entry point for RLM processing. It:
434/// 1. Loads context into a REPL environment
435/// 2. Lets the LLM write analysis code
436/// 3. Executes the code and provides llm_query() for semantic sub-calls
437/// 4. Iterates until the LLM returns a FINAL answer
438///
439/// When FunctionGemma is enabled, the executor passes RLM tool definitions to
440/// the provider and dispatches structured tool calls instead of regex-parsing
441/// DSL from code blocks.
442pub struct RlmExecutor {
443    repl: RlmRepl,
444    provider: Arc<dyn Provider>,
445    model: String,
446    max_iterations: usize,
447    sub_queries: Vec<SubQuery>,
448    verbose: bool,
449    #[cfg(feature = "functiongemma")]
450    tool_router: Option<ToolCallRouter>,
451}
452
453/// Record of a sub-LM call
454#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct SubQuery {
456    pub query: String,
457    pub context_slice: Option<String>,
458    pub response: String,
459    pub tokens_used: usize,
460}
461
462impl RlmExecutor {
463    /// Create a new RLM executor
464    pub fn new(context: String, provider: Arc<dyn Provider>, model: String) -> Self {
465        #[cfg(feature = "functiongemma")]
466        let tool_router = {
467            let cfg = ToolRouterConfig::from_env();
468            ToolCallRouter::from_config(&cfg)
469                .inspect_err(|e| {
470                    tracing::debug!(error = %e, "FunctionGemma router unavailable for RLM");
471                })
472                .ok()
473                .flatten()
474        };
475
476        Self {
477            repl: RlmRepl::new(context, ReplRuntime::Rust),
478            provider,
479            model,
480            max_iterations: 5, // Keep iterations limited for speed
481            sub_queries: Vec::new(),
482            verbose: false,
483            #[cfg(feature = "functiongemma")]
484            tool_router,
485        }
486    }
487
488    /// Set maximum iterations
489    pub fn with_max_iterations(mut self, max: usize) -> Self {
490        self.max_iterations = max;
491        self
492    }
493
494    /// Enable or disable verbose mode
495    ///
496    /// When verbose is true, the context summary will be displayed
497    /// at the start of analysis to help users understand what's being analyzed.
498    pub fn with_verbose(mut self, verbose: bool) -> Self {
499        self.verbose = verbose;
500        self
501    }
502
503    /// Execute RLM analysis with the given query
504    pub async fn analyze(&mut self, query: &str) -> Result<RlmAnalysisResult> {
505        let start = std::time::Instant::now();
506        let mut iterations = 0;
507        let mut total_input_tokens = 0;
508        let mut total_output_tokens = 0;
509
510        // Prepare RLM tool definitions for structured dispatch
511        let tools = rlm_tool_definitions();
512
513        // Build and optionally display context summary
514        let context_summary = format!(
515            "=== CONTEXT LOADED ===\n\
516             Total: {} chars, {} lines\n\
517             Available functions:\n\
518             - head(n) - first n lines\n\
519             - tail(n) - last n lines\n\
520             - grep(\"pattern\") - find lines matching regex\n\
521             - count(\"pattern\") - count regex matches\n\
522             - slice(start, end) - slice by char position\n\
523             - chunks(n) - split into n chunks\n\
524             - llm_query(\"question\", context?) - ask sub-LM a question\n\
525             - FINAL(\"answer\") - return final answer\n\
526             === END CONTEXT INFO ===",
527            self.repl.context().len(),
528            self.repl.lines().len()
529        );
530
531        // Display context summary at the start in verbose mode
532        if self.verbose {
533            tracing::info!("RLM Context Summary:\n{}", context_summary);
534            println!(
535                "[RLM] Context loaded: {} chars, {} lines",
536                self.repl.context().len(),
537                self.repl.lines().len()
538            );
539        }
540
541        let system_prompt = format!(
542            "You are a code analysis assistant. Answer questions by examining the provided context.\n\n\
543             IMPORTANT: You MUST end your response with FINAL(\"your answer\") in 1-3 iterations.\n\n\
544             Available commands:\n\
545             - head(n), tail(n): See first/last n lines\n\
546             - grep(\"pattern\"): Search for patterns\n\
547             - llm_query(\"question\"): Ask a focused sub-question\n\
548             - FINAL(\"answer\"): Return your final answer (REQUIRED)\n\n\
549             The context has {} chars across {} lines. A preview follows:\n\n\
550             {}\n\n\
551             Now analyze the context. Use 1-2 commands if needed, then call FINAL() with your answer.",
552            self.repl.context().len(),
553            self.repl.lines().len(),
554            self.repl.head(25).join("\n")
555        );
556
557        let mut messages = vec![
558            Message {
559                role: Role::System,
560                content: vec![ContentPart::Text {
561                    text: system_prompt,
562                }],
563            },
564            Message {
565                role: Role::User,
566                content: vec![ContentPart::Text {
567                    text: format!("Analyze and answer: {}", query),
568                }],
569            },
570        ];
571
572        let mut final_answer = None;
573
574        while iterations < self.max_iterations {
575            iterations += 1;
576            tracing::info!("RLM iteration {}", iterations);
577
578            // Get LLM response with code to execute (with timeout)
579            tracing::debug!("Sending LLM request...");
580            let response = match tokio::time::timeout(
581                std::time::Duration::from_secs(60),
582                self.provider.complete(CompletionRequest {
583                    messages: messages.clone(),
584                    tools: tools.clone(),
585                    model: self.model.clone(),
586                    temperature: Some(0.3),
587                    top_p: None,
588                    max_tokens: Some(2000),
589                    stop: vec![],
590                }),
591            )
592            .await
593            {
594                Ok(Ok(r)) => {
595                    tracing::debug!("LLM response received");
596                    r
597                }
598                Ok(Err(e)) => return Err(e),
599                Err(_) => return Err(anyhow::anyhow!("LLM request timed out after 60 seconds")),
600            };
601
602            // Optionally run FunctionGemma to convert text-only responses into
603            // structured tool calls.
604            #[cfg(feature = "functiongemma")]
605            let response = if let Some(ref router) = self.tool_router {
606                // RLM executor currently uses the same provider as the main session,
607                // which always supports native tool calling.  Pass `true` so
608                // FunctionGemma is skipped — it would only waste CPU here.
609                router.maybe_reformat(response, &tools, true).await
610            } else {
611                response
612            };
613
614            total_input_tokens += response.usage.prompt_tokens;
615            total_output_tokens += response.usage.completion_tokens;
616
617            // ── Structured tool-call path ────────────────────────────────
618            // If the response (or FunctionGemma rewrite) contains ToolCall
619            // entries, dispatch them directly against the REPL.
620            let tool_calls: Vec<(String, String, String)> = response
621                .message
622                .content
623                .iter()
624                .filter_map(|p| match p {
625                    ContentPart::ToolCall {
626                        id,
627                        name,
628                        arguments,
629                    } => Some((id.clone(), name.clone(), arguments.clone())),
630                    _ => None,
631                })
632                .collect();
633
634            if !tool_calls.is_empty() {
635                tracing::info!(
636                    count = tool_calls.len(),
637                    "RLM: dispatching structured tool calls"
638                );
639
640                // Keep the assistant message (including tool call parts) in history
641                messages.push(Message {
642                    role: Role::Assistant,
643                    content: response.message.content.clone(),
644                });
645
646                let mut tool_results: Vec<ContentPart> = Vec::new();
647
648                for (call_id, name, arguments) in &tool_calls {
649                    match dispatch_tool_call(name, arguments, &mut self.repl) {
650                        Some(RlmToolResult::Final(answer)) => {
651                            if self.verbose {
652                                println!("[RLM] Final answer received via tool call");
653                            }
654                            final_answer = Some(answer.clone());
655                            tool_results.push(ContentPart::ToolResult {
656                                tool_call_id: call_id.clone(),
657                                content: format!("FINAL: {answer}"),
658                            });
659                            break;
660                        }
661                        Some(RlmToolResult::Output(output)) => {
662                            // Check for the llm_query sentinel
663                            if let Ok(sentinel) = serde_json::from_str::<serde_json::Value>(&output)
664                            {
665                                if sentinel
666                                    .get("__rlm_llm_query")
667                                    .and_then(|v| v.as_bool())
668                                    .unwrap_or(false)
669                                {
670                                    let q = sentinel
671                                        .get("query")
672                                        .and_then(|v| v.as_str())
673                                        .unwrap_or("");
674                                    let ctx_slice = sentinel
675                                        .get("context_slice")
676                                        .and_then(|v| v.as_str())
677                                        .map(|s| s.to_string());
678                                    let llm_result =
679                                        self.handle_llm_query_direct(q, ctx_slice).await?;
680                                    tool_results.push(ContentPart::ToolResult {
681                                        tool_call_id: call_id.clone(),
682                                        content: llm_result,
683                                    });
684                                    continue;
685                                }
686                            }
687                            // Normal REPL output
688                            if self.verbose {
689                                let preview = truncate_with_ellipsis(&output, 200);
690                                println!("[RLM] Tool {name} → {}", preview);
691                            }
692                            tool_results.push(ContentPart::ToolResult {
693                                tool_call_id: call_id.clone(),
694                                content: output,
695                            });
696                        }
697                        None => {
698                            tool_results.push(ContentPart::ToolResult {
699                                tool_call_id: call_id.clone(),
700                                content: format!("Unknown tool: {name}"),
701                            });
702                        }
703                    }
704                }
705
706                // Send tool results back as a Role::Tool message
707                if !tool_results.is_empty() {
708                    messages.push(Message {
709                        role: Role::Tool,
710                        content: tool_results,
711                    });
712                }
713
714                if final_answer.is_some() {
715                    break;
716                }
717                continue;
718            }
719
720            // ── Legacy DSL path (fallback) ───────────────────────────────
721            // If no structured tool calls were produced, fall back to the
722            // original regex-parsed code-block execution.
723            let assistant_text = response
724                .message
725                .content
726                .iter()
727                .filter_map(|p| match p {
728                    ContentPart::Text { text } => Some(text.as_str()),
729                    _ => None,
730                })
731                .collect::<Vec<_>>()
732                .join("");
733
734            // Add assistant message
735            messages.push(Message {
736                role: Role::Assistant,
737                content: vec![ContentPart::Text {
738                    text: assistant_text.clone(),
739                }],
740            });
741
742            // Extract and execute code blocks
743            let code = self.extract_code(&assistant_text);
744
745            // Display execution details in verbose mode
746            if self.verbose {
747                println!("[RLM] Iteration {}: Executing code:\n{}", iterations, code);
748            }
749
750            let execution_result = self.execute_with_llm_query(&code).await?;
751
752            // Display execution results in verbose mode
753            if self.verbose {
754                if let Some(ref answer) = execution_result.final_answer {
755                    println!("[RLM] Final answer received: {}", answer);
756                } else if !execution_result.stdout.is_empty() {
757                    let preview = truncate_with_ellipsis(&execution_result.stdout, 200);
758                    println!("[RLM] Execution output:\n{}", preview);
759                }
760            }
761
762            // Check for final answer
763            if let Some(answer) = &execution_result.final_answer {
764                final_answer = Some(answer.clone());
765                break;
766            }
767
768            // Add execution result as user message for next iteration
769            let result_text = if execution_result.stdout.is_empty() {
770                "[No output]".to_string()
771            } else {
772                format!("Execution result:\n{}", execution_result.stdout)
773            };
774
775            messages.push(Message {
776                role: Role::User,
777                content: vec![ContentPart::Text { text: result_text }],
778            });
779        }
780
781        let elapsed = start.elapsed();
782
783        Ok(RlmAnalysisResult {
784            answer: final_answer.unwrap_or_else(|| "Analysis incomplete".to_string()),
785            iterations,
786            sub_queries: self.sub_queries.clone(),
787            stats: super::RlmStats {
788                input_tokens: total_input_tokens,
789                output_tokens: total_output_tokens,
790                iterations,
791                subcalls: self.sub_queries.len(),
792                elapsed_ms: elapsed.as_millis() as u64,
793                compression_ratio: 1.0,
794            },
795        })
796    }
797
798    /// Extract code from LLM response
799    fn extract_code(&self, text: &str) -> String {
800        // Look for fenced code blocks
801        let mut code_lines = Vec::new();
802        let mut in_code_block = false;
803
804        for line in text.lines() {
805            if line.starts_with("```") {
806                in_code_block = !in_code_block;
807                continue;
808            }
809            if in_code_block {
810                code_lines.push(line);
811            }
812        }
813
814        if !code_lines.is_empty() {
815            return code_lines.join("\n");
816        }
817
818        // If no code blocks, look for lines that look like code
819        text.lines()
820            .filter(|line| {
821                let l = line.trim();
822                l.starts_with("head(")
823                    || l.starts_with("tail(")
824                    || l.starts_with("grep(")
825                    || l.starts_with("count(")
826                    || l.starts_with("llm_query(")
827                    || l.starts_with("FINAL(")
828                    || l.starts_with("let ")
829                    || l.starts_with("const ")
830                    || l.starts_with("print")
831                    || l.starts_with("console.")
832            })
833            .collect::<Vec<_>>()
834            .join("\n")
835    }
836
837    /// Execute code with llm_query() support
838    async fn execute_with_llm_query(&mut self, code: &str) -> Result<ReplResult> {
839        let mut stdout = Vec::new();
840        let mut final_answer = None;
841
842        for line in code.lines() {
843            let line = line.trim();
844            if line.is_empty() || line.starts_with("//") || line.starts_with('#') {
845                continue;
846            }
847
848            // Handle llm_query calls specially
849            if line.starts_with("llm_query(") || line.contains("= llm_query(") {
850                let result = self.handle_llm_query(line).await?;
851                stdout.push(result);
852                continue;
853            }
854
855            // Handle regular REPL commands
856            if let Some(result) = self.repl.execute_dsl_line(line) {
857                match result {
858                    DslResult::Output(s) => stdout.push(s),
859                    DslResult::Final(s) => {
860                        final_answer = Some(s);
861                        break;
862                    }
863                    DslResult::Error(s) => stdout.push(format!("Error: {}", s)),
864                }
865            }
866        }
867
868        Ok(ReplResult {
869            stdout: stdout.join("\n"),
870            stderr: String::new(),
871            final_answer,
872        })
873    }
874
875    /// Handle llm_query() calls
876    async fn handle_llm_query(&mut self, line: &str) -> Result<String> {
877        // Extract query and optional context slice
878        let (query, context_slice) = self.parse_llm_query(line);
879
880        // Get the context to send
881        let context_to_analyze = context_slice
882            .clone()
883            .unwrap_or_else(|| self.repl.context().to_string());
884
885        // Truncate context for sub-query to avoid overwhelming the LLM
886        let context_chars = context_to_analyze.chars().count();
887        let truncated_context = if context_chars > 8000 {
888            format!(
889                "{}\n[truncated, {} chars total]",
890                truncate_with_ellipsis(&context_to_analyze, 7500),
891                context_chars
892            )
893        } else {
894            context_to_analyze.clone()
895        };
896
897        // Make sub-LM call
898        let messages = vec![
899            Message {
900                role: Role::System,
901                content: vec![ContentPart::Text {
902                    text: "You are a focused analysis assistant. Answer the question based on the provided context. Be concise.".to_string(),
903                }],
904            },
905            Message {
906                role: Role::User,
907                content: vec![ContentPart::Text {
908                    text: format!("Context:\n{}\n\nQuestion: {}", truncated_context, query),
909                }],
910            },
911        ];
912
913        let response = self
914            .provider
915            .complete(CompletionRequest {
916                messages,
917                tools: vec![],
918                model: self.model.clone(),
919                temperature: Some(0.3),
920                top_p: None,
921                max_tokens: Some(500),
922                stop: vec![],
923            })
924            .await?;
925
926        let answer = response
927            .message
928            .content
929            .iter()
930            .filter_map(|p| match p {
931                ContentPart::Text { text } => Some(text.as_str()),
932                _ => None,
933            })
934            .collect::<Vec<_>>()
935            .join("");
936
937        // Record the sub-query
938        self.sub_queries.push(SubQuery {
939            query: query.clone(),
940            context_slice,
941            response: answer.clone(),
942            tokens_used: response.usage.total_tokens,
943        });
944
945        Ok(format!("llm_query result: {}", answer))
946    }
947
948    /// Handle an `rlm_llm_query` tool call from the structured path.
949    ///
950    /// This is the equivalent of `handle_llm_query` but takes pre-parsed
951    /// parameters instead of a raw DSL line.
952    async fn handle_llm_query_direct(
953        &mut self,
954        query: &str,
955        context_slice: Option<String>,
956    ) -> Result<String> {
957        let context_to_analyze = context_slice
958            .clone()
959            .unwrap_or_else(|| self.repl.context().to_string());
960
961        let context_chars = context_to_analyze.chars().count();
962        let truncated_context = if context_chars > 8000 {
963            format!(
964                "{}\n[truncated, {} chars total]",
965                truncate_with_ellipsis(&context_to_analyze, 7500),
966                context_chars
967            )
968        } else {
969            context_to_analyze.clone()
970        };
971
972        let messages = vec![
973            Message {
974                role: Role::System,
975                content: vec![ContentPart::Text {
976                    text: "You are a focused analysis assistant. Answer the question based on the provided context. Be concise.".to_string(),
977                }],
978            },
979            Message {
980                role: Role::User,
981                content: vec![ContentPart::Text {
982                    text: format!("Context:\n{}\n\nQuestion: {}", truncated_context, query),
983                }],
984            },
985        ];
986
987        let response = self
988            .provider
989            .complete(CompletionRequest {
990                messages,
991                tools: vec![],
992                model: self.model.clone(),
993                temperature: Some(0.3),
994                top_p: None,
995                max_tokens: Some(500),
996                stop: vec![],
997            })
998            .await?;
999
1000        let answer = response
1001            .message
1002            .content
1003            .iter()
1004            .filter_map(|p| match p {
1005                ContentPart::Text { text } => Some(text.as_str()),
1006                _ => None,
1007            })
1008            .collect::<Vec<_>>()
1009            .join("");
1010
1011        self.sub_queries.push(SubQuery {
1012            query: query.to_string(),
1013            context_slice,
1014            response: answer.clone(),
1015            tokens_used: response.usage.total_tokens,
1016        });
1017
1018        Ok(format!("llm_query result: {}", answer))
1019    }
1020
1021    /// Parse llm_query("question", context?) call
1022    fn parse_llm_query(&mut self, line: &str) -> (String, Option<String>) {
1023        // Find the query string
1024        let start = line.find('(').unwrap_or(0) + 1;
1025        let end = line.rfind(')').unwrap_or(line.len());
1026        let args = &line[start..end];
1027
1028        // Split by comma, but respect quotes
1029        let mut query = String::new();
1030        let mut context = None;
1031        let mut in_quotes = false;
1032        let mut current = String::new();
1033        let mut parts = Vec::new();
1034
1035        for c in args.chars() {
1036            if c == '"' || c == '\'' {
1037                in_quotes = !in_quotes;
1038            } else if c == ',' && !in_quotes {
1039                parts.push(current.trim().to_string());
1040                current = String::new();
1041                continue;
1042            }
1043            current.push(c);
1044        }
1045        if !current.is_empty() {
1046            parts.push(current.trim().to_string());
1047        }
1048
1049        // First part is the query
1050        if let Some(q) = parts.first() {
1051            query = q.trim_matches(|c| c == '"' || c == '\'').to_string();
1052        }
1053
1054        // Second part (if present) is context expression
1055        if let Some(ctx_expr) = parts.get(1) {
1056            // Evaluate the context expression
1057            let ctx = self.repl.evaluate_expression(ctx_expr);
1058            if !ctx.is_empty() && !ctx.starts_with('[') {
1059                context = Some(ctx);
1060            }
1061        }
1062
1063        (query, context)
1064    }
1065}
1066
1067/// Result of RLM analysis
1068#[derive(Debug, Clone, Serialize, Deserialize)]
1069pub struct RlmAnalysisResult {
1070    pub answer: String,
1071    pub iterations: usize,
1072    pub sub_queries: Vec<SubQuery>,
1073    pub stats: super::RlmStats,
1074}
1075
1076/// Spawn an external REPL process for Python or Bun
1077pub struct ExternalRepl {
1078    child: Child,
1079    #[allow(dead_code)]
1080    runtime: ReplRuntime,
1081}
1082
1083impl ExternalRepl {
1084    /// Create a Bun/Node.js REPL
1085    pub async fn spawn_bun(context: &str) -> Result<Self> {
1086        let init_script = Self::generate_bun_init(context);
1087
1088        // Write init script to temp file
1089        let temp_dir = std::env::temp_dir().join("rlm-repl");
1090        tokio::fs::create_dir_all(&temp_dir).await?;
1091        let script_path = temp_dir.join(format!("init_{}.js", std::process::id()));
1092        tokio::fs::write(&script_path, init_script).await?;
1093
1094        // Try bun first, fall back to node
1095        let runtime = if Self::is_bun_available().await {
1096            "bun"
1097        } else {
1098            "node"
1099        };
1100
1101        let child = Command::new(runtime)
1102            .arg(&script_path)
1103            .stdin(Stdio::piped())
1104            .stdout(Stdio::piped())
1105            .stderr(Stdio::piped())
1106            .spawn()?;
1107
1108        Ok(Self {
1109            child,
1110            runtime: ReplRuntime::Bun,
1111        })
1112    }
1113
1114    async fn is_bun_available() -> bool {
1115        Command::new("bun")
1116            .arg("--version")
1117            .output()
1118            .await
1119            .map(|o| o.status.success())
1120            .unwrap_or(false)
1121    }
1122
1123    fn generate_bun_init(context: &str) -> String {
1124        let escaped = context
1125            .replace('\\', "\\\\")
1126            .replace('"', "\\\"")
1127            .replace('\n', "\\n");
1128
1129        format!(
1130            r#"
1131const readline = require('readline');
1132const rl = readline.createInterface({{ input: process.stdin, output: process.stdout, terminal: false }});
1133
1134const context = "{escaped}";
1135
1136function lines() {{ return context.split("\n"); }}
1137function head(n = 10) {{ return lines().slice(0, n).join("\n"); }}
1138function tail(n = 10) {{ return lines().slice(-n).join("\n"); }}
1139function grep(pattern) {{
1140    const re = pattern instanceof RegExp ? pattern : new RegExp(pattern, 'gi');
1141    return lines().filter(l => re.test(l));
1142}}
1143function count(pattern) {{
1144    const re = pattern instanceof RegExp ? pattern : new RegExp(pattern, 'gi');
1145    return (context.match(re) || []).length;
1146}}
1147function FINAL(answer) {{
1148    console.log("__FINAL__" + String(answer) + "__FINAL_END__");
1149}}
1150
1151console.log("READY");
1152
1153rl.on('line', async (line) => {{
1154    try {{
1155        const result = eval(line);
1156        if (result !== undefined) console.log(result);
1157    }} catch (e) {{
1158        console.error("Error:", e.message);
1159    }}
1160    console.log("__DONE__");
1161}});
1162"#
1163        )
1164    }
1165
1166    /// Execute code and get result
1167    pub async fn execute(&mut self, code: &str) -> Result<ReplResult> {
1168        let stdin = self
1169            .child
1170            .stdin
1171            .as_mut()
1172            .ok_or_else(|| anyhow::anyhow!("No stdin"))?;
1173        let stdout = self
1174            .child
1175            .stdout
1176            .as_mut()
1177            .ok_or_else(|| anyhow::anyhow!("No stdout"))?;
1178
1179        stdin.write_all(code.as_bytes()).await?;
1180        stdin.write_all(b"\n").await?;
1181        stdin.flush().await?;
1182
1183        let mut reader = BufReader::new(stdout);
1184        let mut output = Vec::new();
1185        let mut final_answer = None;
1186
1187        loop {
1188            let mut line = String::new();
1189            match timeout(Duration::from_secs(30), reader.read_line(&mut line)).await {
1190                Ok(Ok(0)) | Err(_) => break, // EOF or timeout
1191                Ok(Ok(_)) => {
1192                    let line = line.trim();
1193                    if line == "__DONE__" {
1194                        break;
1195                    }
1196                    if let Some(answer) = Self::extract_final(line) {
1197                        final_answer = Some(answer);
1198                        break;
1199                    }
1200                    output.push(line.to_string());
1201                }
1202                Ok(Err(e)) => return Err(anyhow::anyhow!("Read error: {}", e)),
1203            }
1204        }
1205
1206        Ok(ReplResult {
1207            stdout: output.join("\n"),
1208            stderr: String::new(),
1209            final_answer,
1210        })
1211    }
1212
1213    fn extract_final(line: &str) -> Option<String> {
1214        if line.contains("__FINAL__") {
1215            let start = line.find("__FINAL__")? + 9;
1216            let end = line.find("__FINAL_END__")?;
1217            return Some(line[start..end].to_string());
1218        }
1219        None
1220    }
1221
1222    /// Kill the REPL process
1223    pub async fn destroy(&mut self) -> Result<()> {
1224        tracing::debug!(runtime = ?self.runtime, "Destroying external REPL");
1225        self.child.kill().await?;
1226        Ok(())
1227    }
1228
1229    /// Get the runtime type used by this REPL
1230    pub fn runtime(&self) -> ReplRuntime {
1231        self.runtime
1232    }
1233}
1234
1235fn char_index_to_byte_index(value: &str, char_index: usize) -> usize {
1236    if char_index == 0 {
1237        return 0;
1238    }
1239
1240    value
1241        .char_indices()
1242        .nth(char_index)
1243        .map(|(idx, _)| idx)
1244        .unwrap_or(value.len())
1245}
1246
1247fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
1248    if max_chars == 0 {
1249        return String::new();
1250    }
1251
1252    let mut chars = value.chars();
1253    let mut output = String::new();
1254    for _ in 0..max_chars {
1255        if let Some(ch) = chars.next() {
1256            output.push(ch);
1257        } else {
1258            return value.to_string();
1259        }
1260    }
1261
1262    if chars.next().is_some() {
1263        format!("{output}...")
1264    } else {
1265        output
1266    }
1267}
1268
1269#[cfg(test)]
1270mod tests {
1271    use super::*;
1272
1273    #[test]
1274    fn test_repl_head_tail() {
1275        let context = (1..=100)
1276            .map(|i| format!("line {}", i))
1277            .collect::<Vec<_>>()
1278            .join("\n");
1279        let repl = RlmRepl::new(context, ReplRuntime::Rust);
1280
1281        let head = repl.head(5);
1282        assert_eq!(head.len(), 5);
1283        assert_eq!(head[0], "line 1");
1284
1285        let tail = repl.tail(5);
1286        assert_eq!(tail.len(), 5);
1287        assert_eq!(tail[4], "line 100");
1288    }
1289
1290    #[test]
1291    fn test_repl_grep() {
1292        let context = "error: something failed\ninfo: all good\nerror: another failure".to_string();
1293        let repl = RlmRepl::new(context, ReplRuntime::Rust);
1294
1295        let matches = repl.grep("error");
1296        assert_eq!(matches.len(), 2);
1297    }
1298
1299    #[test]
1300    fn test_repl_execute_final() {
1301        let context = "test content".to_string();
1302        let mut repl = RlmRepl::new(context, ReplRuntime::Rust);
1303
1304        let result = repl.execute(r#"FINAL("This is the answer")"#);
1305        assert_eq!(result.final_answer, Some("This is the answer".to_string()));
1306    }
1307
1308    #[test]
1309    fn test_repl_chunks() {
1310        let context = (1..=100)
1311            .map(|i| format!("line {}", i))
1312            .collect::<Vec<_>>()
1313            .join("\n");
1314        let repl = RlmRepl::new(context, ReplRuntime::Rust);
1315
1316        let chunks = repl.chunks(5);
1317        assert_eq!(chunks.len(), 5);
1318    }
1319}