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                router.maybe_reformat(response, &tools).await
607            } else {
608                response
609            };
610
611            total_input_tokens += response.usage.prompt_tokens;
612            total_output_tokens += response.usage.completion_tokens;
613
614            // ── Structured tool-call path ────────────────────────────────
615            // If the response (or FunctionGemma rewrite) contains ToolCall
616            // entries, dispatch them directly against the REPL.
617            let tool_calls: Vec<(String, String, String)> = response
618                .message
619                .content
620                .iter()
621                .filter_map(|p| match p {
622                    ContentPart::ToolCall {
623                        id,
624                        name,
625                        arguments,
626                    } => Some((id.clone(), name.clone(), arguments.clone())),
627                    _ => None,
628                })
629                .collect();
630
631            if !tool_calls.is_empty() {
632                tracing::info!(
633                    count = tool_calls.len(),
634                    "RLM: dispatching structured tool calls"
635                );
636
637                // Keep the assistant message (including tool call parts) in history
638                messages.push(Message {
639                    role: Role::Assistant,
640                    content: response.message.content.clone(),
641                });
642
643                let mut tool_results: Vec<ContentPart> = Vec::new();
644
645                for (call_id, name, arguments) in &tool_calls {
646                    match dispatch_tool_call(name, arguments, &mut self.repl) {
647                        Some(RlmToolResult::Final(answer)) => {
648                            if self.verbose {
649                                println!("[RLM] Final answer received via tool call");
650                            }
651                            final_answer = Some(answer.clone());
652                            tool_results.push(ContentPart::ToolResult {
653                                tool_call_id: call_id.clone(),
654                                content: format!("FINAL: {answer}"),
655                            });
656                            break;
657                        }
658                        Some(RlmToolResult::Output(output)) => {
659                            // Check for the llm_query sentinel
660                            if let Ok(sentinel) = serde_json::from_str::<serde_json::Value>(&output)
661                            {
662                                if sentinel
663                                    .get("__rlm_llm_query")
664                                    .and_then(|v| v.as_bool())
665                                    .unwrap_or(false)
666                                {
667                                    let q = sentinel
668                                        .get("query")
669                                        .and_then(|v| v.as_str())
670                                        .unwrap_or("");
671                                    let ctx_slice = sentinel
672                                        .get("context_slice")
673                                        .and_then(|v| v.as_str())
674                                        .map(|s| s.to_string());
675                                    let llm_result =
676                                        self.handle_llm_query_direct(q, ctx_slice).await?;
677                                    tool_results.push(ContentPart::ToolResult {
678                                        tool_call_id: call_id.clone(),
679                                        content: llm_result,
680                                    });
681                                    continue;
682                                }
683                            }
684                            // Normal REPL output
685                            if self.verbose {
686                                let preview = truncate_with_ellipsis(&output, 200);
687                                println!("[RLM] Tool {name} → {}", preview);
688                            }
689                            tool_results.push(ContentPart::ToolResult {
690                                tool_call_id: call_id.clone(),
691                                content: output,
692                            });
693                        }
694                        None => {
695                            tool_results.push(ContentPart::ToolResult {
696                                tool_call_id: call_id.clone(),
697                                content: format!("Unknown tool: {name}"),
698                            });
699                        }
700                    }
701                }
702
703                // Send tool results back as a Role::Tool message
704                if !tool_results.is_empty() {
705                    messages.push(Message {
706                        role: Role::Tool,
707                        content: tool_results,
708                    });
709                }
710
711                if final_answer.is_some() {
712                    break;
713                }
714                continue;
715            }
716
717            // ── Legacy DSL path (fallback) ───────────────────────────────
718            // If no structured tool calls were produced, fall back to the
719            // original regex-parsed code-block execution.
720            let assistant_text = response
721                .message
722                .content
723                .iter()
724                .filter_map(|p| match p {
725                    ContentPart::Text { text } => Some(text.as_str()),
726                    _ => None,
727                })
728                .collect::<Vec<_>>()
729                .join("");
730
731            // Add assistant message
732            messages.push(Message {
733                role: Role::Assistant,
734                content: vec![ContentPart::Text {
735                    text: assistant_text.clone(),
736                }],
737            });
738
739            // Extract and execute code blocks
740            let code = self.extract_code(&assistant_text);
741
742            // Display execution details in verbose mode
743            if self.verbose {
744                println!("[RLM] Iteration {}: Executing code:\n{}", iterations, code);
745            }
746
747            let execution_result = self.execute_with_llm_query(&code).await?;
748
749            // Display execution results in verbose mode
750            if self.verbose {
751                if let Some(ref answer) = execution_result.final_answer {
752                    println!("[RLM] Final answer received: {}", answer);
753                } else if !execution_result.stdout.is_empty() {
754                    let preview = truncate_with_ellipsis(&execution_result.stdout, 200);
755                    println!("[RLM] Execution output:\n{}", preview);
756                }
757            }
758
759            // Check for final answer
760            if let Some(answer) = &execution_result.final_answer {
761                final_answer = Some(answer.clone());
762                break;
763            }
764
765            // Add execution result as user message for next iteration
766            let result_text = if execution_result.stdout.is_empty() {
767                "[No output]".to_string()
768            } else {
769                format!("Execution result:\n{}", execution_result.stdout)
770            };
771
772            messages.push(Message {
773                role: Role::User,
774                content: vec![ContentPart::Text { text: result_text }],
775            });
776        }
777
778        let elapsed = start.elapsed();
779
780        Ok(RlmAnalysisResult {
781            answer: final_answer.unwrap_or_else(|| "Analysis incomplete".to_string()),
782            iterations,
783            sub_queries: self.sub_queries.clone(),
784            stats: super::RlmStats {
785                input_tokens: total_input_tokens,
786                output_tokens: total_output_tokens,
787                iterations,
788                subcalls: self.sub_queries.len(),
789                elapsed_ms: elapsed.as_millis() as u64,
790                compression_ratio: 1.0,
791            },
792        })
793    }
794
795    /// Extract code from LLM response
796    fn extract_code(&self, text: &str) -> String {
797        // Look for fenced code blocks
798        let mut code_lines = Vec::new();
799        let mut in_code_block = false;
800
801        for line in text.lines() {
802            if line.starts_with("```") {
803                in_code_block = !in_code_block;
804                continue;
805            }
806            if in_code_block {
807                code_lines.push(line);
808            }
809        }
810
811        if !code_lines.is_empty() {
812            return code_lines.join("\n");
813        }
814
815        // If no code blocks, look for lines that look like code
816        text.lines()
817            .filter(|line| {
818                let l = line.trim();
819                l.starts_with("head(")
820                    || l.starts_with("tail(")
821                    || l.starts_with("grep(")
822                    || l.starts_with("count(")
823                    || l.starts_with("llm_query(")
824                    || l.starts_with("FINAL(")
825                    || l.starts_with("let ")
826                    || l.starts_with("const ")
827                    || l.starts_with("print")
828                    || l.starts_with("console.")
829            })
830            .collect::<Vec<_>>()
831            .join("\n")
832    }
833
834    /// Execute code with llm_query() support
835    async fn execute_with_llm_query(&mut self, code: &str) -> Result<ReplResult> {
836        let mut stdout = Vec::new();
837        let mut final_answer = None;
838
839        for line in code.lines() {
840            let line = line.trim();
841            if line.is_empty() || line.starts_with("//") || line.starts_with('#') {
842                continue;
843            }
844
845            // Handle llm_query calls specially
846            if line.starts_with("llm_query(") || line.contains("= llm_query(") {
847                let result = self.handle_llm_query(line).await?;
848                stdout.push(result);
849                continue;
850            }
851
852            // Handle regular REPL commands
853            if let Some(result) = self.repl.execute_dsl_line(line) {
854                match result {
855                    DslResult::Output(s) => stdout.push(s),
856                    DslResult::Final(s) => {
857                        final_answer = Some(s);
858                        break;
859                    }
860                    DslResult::Error(s) => stdout.push(format!("Error: {}", s)),
861                }
862            }
863        }
864
865        Ok(ReplResult {
866            stdout: stdout.join("\n"),
867            stderr: String::new(),
868            final_answer,
869        })
870    }
871
872    /// Handle llm_query() calls
873    async fn handle_llm_query(&mut self, line: &str) -> Result<String> {
874        // Extract query and optional context slice
875        let (query, context_slice) = self.parse_llm_query(line);
876
877        // Get the context to send
878        let context_to_analyze = context_slice
879            .clone()
880            .unwrap_or_else(|| self.repl.context().to_string());
881
882        // Truncate context for sub-query to avoid overwhelming the LLM
883        let context_chars = context_to_analyze.chars().count();
884        let truncated_context = if context_chars > 8000 {
885            format!(
886                "{}\n[truncated, {} chars total]",
887                truncate_with_ellipsis(&context_to_analyze, 7500),
888                context_chars
889            )
890        } else {
891            context_to_analyze.clone()
892        };
893
894        // Make sub-LM call
895        let messages = vec![
896            Message {
897                role: Role::System,
898                content: vec![ContentPart::Text {
899                    text: "You are a focused analysis assistant. Answer the question based on the provided context. Be concise.".to_string(),
900                }],
901            },
902            Message {
903                role: Role::User,
904                content: vec![ContentPart::Text {
905                    text: format!("Context:\n{}\n\nQuestion: {}", truncated_context, query),
906                }],
907            },
908        ];
909
910        let response = self
911            .provider
912            .complete(CompletionRequest {
913                messages,
914                tools: vec![],
915                model: self.model.clone(),
916                temperature: Some(0.3),
917                top_p: None,
918                max_tokens: Some(500),
919                stop: vec![],
920            })
921            .await?;
922
923        let answer = response
924            .message
925            .content
926            .iter()
927            .filter_map(|p| match p {
928                ContentPart::Text { text } => Some(text.as_str()),
929                _ => None,
930            })
931            .collect::<Vec<_>>()
932            .join("");
933
934        // Record the sub-query
935        self.sub_queries.push(SubQuery {
936            query: query.clone(),
937            context_slice,
938            response: answer.clone(),
939            tokens_used: response.usage.total_tokens,
940        });
941
942        Ok(format!("llm_query result: {}", answer))
943    }
944
945    /// Handle an `rlm_llm_query` tool call from the structured path.
946    ///
947    /// This is the equivalent of `handle_llm_query` but takes pre-parsed
948    /// parameters instead of a raw DSL line.
949    async fn handle_llm_query_direct(
950        &mut self,
951        query: &str,
952        context_slice: Option<String>,
953    ) -> Result<String> {
954        let context_to_analyze = context_slice
955            .clone()
956            .unwrap_or_else(|| self.repl.context().to_string());
957
958        let context_chars = context_to_analyze.chars().count();
959        let truncated_context = if context_chars > 8000 {
960            format!(
961                "{}\n[truncated, {} chars total]",
962                truncate_with_ellipsis(&context_to_analyze, 7500),
963                context_chars
964            )
965        } else {
966            context_to_analyze.clone()
967        };
968
969        let messages = vec![
970            Message {
971                role: Role::System,
972                content: vec![ContentPart::Text {
973                    text: "You are a focused analysis assistant. Answer the question based on the provided context. Be concise.".to_string(),
974                }],
975            },
976            Message {
977                role: Role::User,
978                content: vec![ContentPart::Text {
979                    text: format!("Context:\n{}\n\nQuestion: {}", truncated_context, query),
980                }],
981            },
982        ];
983
984        let response = self
985            .provider
986            .complete(CompletionRequest {
987                messages,
988                tools: vec![],
989                model: self.model.clone(),
990                temperature: Some(0.3),
991                top_p: None,
992                max_tokens: Some(500),
993                stop: vec![],
994            })
995            .await?;
996
997        let answer = response
998            .message
999            .content
1000            .iter()
1001            .filter_map(|p| match p {
1002                ContentPart::Text { text } => Some(text.as_str()),
1003                _ => None,
1004            })
1005            .collect::<Vec<_>>()
1006            .join("");
1007
1008        self.sub_queries.push(SubQuery {
1009            query: query.to_string(),
1010            context_slice,
1011            response: answer.clone(),
1012            tokens_used: response.usage.total_tokens,
1013        });
1014
1015        Ok(format!("llm_query result: {}", answer))
1016    }
1017
1018    /// Parse llm_query("question", context?) call
1019    fn parse_llm_query(&mut self, line: &str) -> (String, Option<String>) {
1020        // Find the query string
1021        let start = line.find('(').unwrap_or(0) + 1;
1022        let end = line.rfind(')').unwrap_or(line.len());
1023        let args = &line[start..end];
1024
1025        // Split by comma, but respect quotes
1026        let mut query = String::new();
1027        let mut context = None;
1028        let mut in_quotes = false;
1029        let mut current = String::new();
1030        let mut parts = Vec::new();
1031
1032        for c in args.chars() {
1033            if c == '"' || c == '\'' {
1034                in_quotes = !in_quotes;
1035            } else if c == ',' && !in_quotes {
1036                parts.push(current.trim().to_string());
1037                current = String::new();
1038                continue;
1039            }
1040            current.push(c);
1041        }
1042        if !current.is_empty() {
1043            parts.push(current.trim().to_string());
1044        }
1045
1046        // First part is the query
1047        if let Some(q) = parts.first() {
1048            query = q.trim_matches(|c| c == '"' || c == '\'').to_string();
1049        }
1050
1051        // Second part (if present) is context expression
1052        if let Some(ctx_expr) = parts.get(1) {
1053            // Evaluate the context expression
1054            let ctx = self.repl.evaluate_expression(ctx_expr);
1055            if !ctx.is_empty() && !ctx.starts_with('[') {
1056                context = Some(ctx);
1057            }
1058        }
1059
1060        (query, context)
1061    }
1062}
1063
1064/// Result of RLM analysis
1065#[derive(Debug, Clone, Serialize, Deserialize)]
1066pub struct RlmAnalysisResult {
1067    pub answer: String,
1068    pub iterations: usize,
1069    pub sub_queries: Vec<SubQuery>,
1070    pub stats: super::RlmStats,
1071}
1072
1073/// Spawn an external REPL process for Python or Bun
1074pub struct ExternalRepl {
1075    child: Child,
1076    #[allow(dead_code)]
1077    runtime: ReplRuntime,
1078}
1079
1080impl ExternalRepl {
1081    /// Create a Bun/Node.js REPL
1082    pub async fn spawn_bun(context: &str) -> Result<Self> {
1083        let init_script = Self::generate_bun_init(context);
1084
1085        // Write init script to temp file
1086        let temp_dir = std::env::temp_dir().join("rlm-repl");
1087        tokio::fs::create_dir_all(&temp_dir).await?;
1088        let script_path = temp_dir.join(format!("init_{}.js", std::process::id()));
1089        tokio::fs::write(&script_path, init_script).await?;
1090
1091        // Try bun first, fall back to node
1092        let runtime = if Self::is_bun_available().await {
1093            "bun"
1094        } else {
1095            "node"
1096        };
1097
1098        let child = Command::new(runtime)
1099            .arg(&script_path)
1100            .stdin(Stdio::piped())
1101            .stdout(Stdio::piped())
1102            .stderr(Stdio::piped())
1103            .spawn()?;
1104
1105        Ok(Self {
1106            child,
1107            runtime: ReplRuntime::Bun,
1108        })
1109    }
1110
1111    async fn is_bun_available() -> bool {
1112        Command::new("bun")
1113            .arg("--version")
1114            .output()
1115            .await
1116            .map(|o| o.status.success())
1117            .unwrap_or(false)
1118    }
1119
1120    fn generate_bun_init(context: &str) -> String {
1121        let escaped = context
1122            .replace('\\', "\\\\")
1123            .replace('"', "\\\"")
1124            .replace('\n', "\\n");
1125
1126        format!(
1127            r#"
1128const readline = require('readline');
1129const rl = readline.createInterface({{ input: process.stdin, output: process.stdout, terminal: false }});
1130
1131const context = "{escaped}";
1132
1133function lines() {{ return context.split("\n"); }}
1134function head(n = 10) {{ return lines().slice(0, n).join("\n"); }}
1135function tail(n = 10) {{ return lines().slice(-n).join("\n"); }}
1136function grep(pattern) {{
1137    const re = pattern instanceof RegExp ? pattern : new RegExp(pattern, 'gi');
1138    return lines().filter(l => re.test(l));
1139}}
1140function count(pattern) {{
1141    const re = pattern instanceof RegExp ? pattern : new RegExp(pattern, 'gi');
1142    return (context.match(re) || []).length;
1143}}
1144function FINAL(answer) {{
1145    console.log("__FINAL__" + String(answer) + "__FINAL_END__");
1146}}
1147
1148console.log("READY");
1149
1150rl.on('line', async (line) => {{
1151    try {{
1152        const result = eval(line);
1153        if (result !== undefined) console.log(result);
1154    }} catch (e) {{
1155        console.error("Error:", e.message);
1156    }}
1157    console.log("__DONE__");
1158}});
1159"#
1160        )
1161    }
1162
1163    /// Execute code and get result
1164    pub async fn execute(&mut self, code: &str) -> Result<ReplResult> {
1165        let stdin = self
1166            .child
1167            .stdin
1168            .as_mut()
1169            .ok_or_else(|| anyhow::anyhow!("No stdin"))?;
1170        let stdout = self
1171            .child
1172            .stdout
1173            .as_mut()
1174            .ok_or_else(|| anyhow::anyhow!("No stdout"))?;
1175
1176        stdin.write_all(code.as_bytes()).await?;
1177        stdin.write_all(b"\n").await?;
1178        stdin.flush().await?;
1179
1180        let mut reader = BufReader::new(stdout);
1181        let mut output = Vec::new();
1182        let mut final_answer = None;
1183
1184        loop {
1185            let mut line = String::new();
1186            match timeout(Duration::from_secs(30), reader.read_line(&mut line)).await {
1187                Ok(Ok(0)) | Err(_) => break, // EOF or timeout
1188                Ok(Ok(_)) => {
1189                    let line = line.trim();
1190                    if line == "__DONE__" {
1191                        break;
1192                    }
1193                    if let Some(answer) = Self::extract_final(line) {
1194                        final_answer = Some(answer);
1195                        break;
1196                    }
1197                    output.push(line.to_string());
1198                }
1199                Ok(Err(e)) => return Err(anyhow::anyhow!("Read error: {}", e)),
1200            }
1201        }
1202
1203        Ok(ReplResult {
1204            stdout: output.join("\n"),
1205            stderr: String::new(),
1206            final_answer,
1207        })
1208    }
1209
1210    fn extract_final(line: &str) -> Option<String> {
1211        if line.contains("__FINAL__") {
1212            let start = line.find("__FINAL__")? + 9;
1213            let end = line.find("__FINAL_END__")?;
1214            return Some(line[start..end].to_string());
1215        }
1216        None
1217    }
1218
1219    /// Kill the REPL process
1220    pub async fn destroy(&mut self) -> Result<()> {
1221        tracing::debug!(runtime = ?self.runtime, "Destroying external REPL");
1222        self.child.kill().await?;
1223        Ok(())
1224    }
1225
1226    /// Get the runtime type used by this REPL
1227    pub fn runtime(&self) -> ReplRuntime {
1228        self.runtime
1229    }
1230}
1231
1232fn char_index_to_byte_index(value: &str, char_index: usize) -> usize {
1233    if char_index == 0 {
1234        return 0;
1235    }
1236
1237    value
1238        .char_indices()
1239        .nth(char_index)
1240        .map(|(idx, _)| idx)
1241        .unwrap_or(value.len())
1242}
1243
1244fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
1245    if max_chars == 0 {
1246        return String::new();
1247    }
1248
1249    let mut chars = value.chars();
1250    let mut output = String::new();
1251    for _ in 0..max_chars {
1252        if let Some(ch) = chars.next() {
1253            output.push(ch);
1254        } else {
1255            return value.to_string();
1256        }
1257    }
1258
1259    if chars.next().is_some() {
1260        format!("{output}...")
1261    } else {
1262        output
1263    }
1264}
1265
1266#[cfg(test)]
1267mod tests {
1268    use super::*;
1269
1270    #[test]
1271    fn test_repl_head_tail() {
1272        let context = (1..=100)
1273            .map(|i| format!("line {}", i))
1274            .collect::<Vec<_>>()
1275            .join("\n");
1276        let repl = RlmRepl::new(context, ReplRuntime::Rust);
1277
1278        let head = repl.head(5);
1279        assert_eq!(head.len(), 5);
1280        assert_eq!(head[0], "line 1");
1281
1282        let tail = repl.tail(5);
1283        assert_eq!(tail.len(), 5);
1284        assert_eq!(tail[4], "line 100");
1285    }
1286
1287    #[test]
1288    fn test_repl_grep() {
1289        let context = "error: something failed\ninfo: all good\nerror: another failure".to_string();
1290        let repl = RlmRepl::new(context, ReplRuntime::Rust);
1291
1292        let matches = repl.grep("error");
1293        assert_eq!(matches.len(), 2);
1294    }
1295
1296    #[test]
1297    fn test_repl_execute_final() {
1298        let context = "test content".to_string();
1299        let mut repl = RlmRepl::new(context, ReplRuntime::Rust);
1300
1301        let result = repl.execute(r#"FINAL("This is the answer")"#);
1302        assert_eq!(result.final_answer, Some("This is the answer".to_string()));
1303    }
1304
1305    #[test]
1306    fn test_repl_chunks() {
1307        let context = (1..=100)
1308            .map(|i| format!("line {}", i))
1309            .collect::<Vec<_>>()
1310            .join("\n");
1311        let repl = RlmRepl::new(context, ReplRuntime::Rust);
1312
1313        let chunks = repl.chunks(5);
1314        assert_eq!(chunks.len(), 5);
1315    }
1316}