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