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