Skip to main content

mlrs_core/
rlm.rs

1use anyhow::Result;
2use std::sync::Arc;
3use tracing::{debug, warn};
4
5use crate::{
6    env::{build_engine, execute_script},
7    error::RlmError,
8    protocol::{Notebook, StepResult},
9};
10
11/// The LLM provider abstraction — implemented in `mlrs-providers`.
12#[async_trait::async_trait]
13pub trait LlmProvider: Send + Sync {
14    async fn complete(&self, messages: Vec<Message>) -> Result<String>;
15    fn model_id(&self) -> &str;
16}
17
18#[derive(Debug, Clone)]
19pub struct Message {
20    pub role: Role,
21    pub content: String,
22}
23
24#[derive(Debug, Clone)]
25pub enum Role {
26    System,
27    User,
28    Assistant,
29}
30
31impl std::fmt::Display for Role {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            Role::System => write!(f, "system"),
35            Role::User => write!(f, "user"),
36            Role::Assistant => write!(f, "assistant"),
37        }
38    }
39}
40
41const SYSTEM_PROMPT: &str = r#"You are an RLM (Recursive Language Model) agent. You never see the
42full context directly. Instead, you write Rhai scripts that interact with the context via
43registered functions, and the results are returned to you as cell outputs.
44
45Available Rhai functions:
46- ctx_len() -> int           — byte length of the full context
47- ctx_slice(start, end) -> String  — byte-range slice of context
48- ctx_grep(pattern) -> String      — regex search; returns matching lines
49- print_cell(msg)            — append a message to this cell's output log
50- done(answer)               — signal that you have the final answer; exits the loop
51
52Rules:
53- Write ONE Rhai script per response. No prose before or after the script.
54- To exit, call done("your answer here") in the script.
55- If a script errors, you will see the error as cell output — self-correct and try again.
56- You may call rlm_call(query, ctx_fragment) to recursively process a sub-context.
57"#;
58
59/// Core RLM engine.
60pub struct Rlm {
61    provider: Arc<dyn LlmProvider>,
62    pub depth: usize,
63    pub max_depth: usize,
64    pub max_iterations: usize,
65    pub max_retries_per_cell: usize,
66    pub verbose: bool,
67}
68
69impl Rlm {
70    pub fn new(provider: Arc<dyn LlmProvider>) -> Self {
71        Self {
72            provider,
73            depth: 0,
74            max_depth: 5,
75            max_iterations: 20,
76            max_retries_per_cell: 3,
77            verbose: false,
78        }
79    }
80
81    pub fn with_depth(mut self, depth: usize) -> Self {
82        self.depth = depth;
83        self
84    }
85
86    pub fn with_max_depth(mut self, max_depth: usize) -> Self {
87        self.max_depth = max_depth;
88        self
89    }
90
91    pub fn with_verbose(mut self, verbose: bool) -> Self {
92        self.verbose = verbose;
93        self
94    }
95
96    /// Run the RLM loop: execute until `done()` is called or limits are hit.
97    pub async fn run(&self, query: &str, context: &str) -> Result<String, RlmError> {
98        if self.depth >= self.max_depth {
99            return Err(RlmError::MaxDepthExceeded(self.depth));
100        }
101
102        let (engine, mut scope) = build_engine(context.to_string());
103        let mut notebook = Notebook::default();
104        let mut iterations = 0;
105
106        loop {
107            if iterations >= self.max_iterations {
108                return Err(RlmError::MaxIterationsExceeded(self.max_iterations));
109            }
110            iterations += 1;
111
112            let messages = self.build_messages(query, &notebook);
113            let script = self
114                .provider
115                .complete(messages)
116                .await
117                .map_err(|e| RlmError::ProviderError(e))?;
118
119            let script = extract_script(&script);
120
121            if self.verbose {
122                eprintln!("[depth={}] >> {}", self.depth, script);
123            }
124
125            debug!(
126                depth = self.depth,
127                iteration = iterations,
128                "rlm: executing cell"
129            );
130
131            let mut retries: usize = 0;
132            let step = loop {
133                match execute_script(&engine, &mut scope, &script)
134                    .map_err(|e| RlmError::ScriptError(e.to_string()))?
135                {
136                    StepResult::Continue(out)
137                        if out.starts_with("script error:")
138                            && retries < self.max_retries_per_cell =>
139                    {
140                        retries += 1;
141                        warn!(depth = self.depth, retry = retries, error = %out, "rlm: script error, retrying");
142                        break StepResult::Continue(out);
143                    }
144                    step => break step,
145                }
146            };
147
148            if self.verbose {
149                eprintln!(
150                    "[depth={}] << {}",
151                    self.depth,
152                    match &step {
153                        StepResult::Continue(o) => o.as_str(),
154                        StepResult::Final(a) => a.as_str(),
155                    }
156                );
157            }
158
159            match step {
160                StepResult::Final(answer) => {
161                    debug!(depth = self.depth, "rlm: final answer received");
162                    return Ok(answer);
163                }
164                StepResult::Continue(output) => {
165                    notebook.push(script.to_string(), output);
166                }
167            }
168        }
169    }
170
171    fn build_messages(&self, query: &str, notebook: &Notebook) -> Vec<Message> {
172        let mut messages = vec![
173            Message {
174                role: Role::System,
175                content: SYSTEM_PROMPT.to_string(),
176            },
177            Message {
178                role: Role::User,
179                content: format!("Query: {query}"),
180            },
181        ];
182
183        if !notebook.cells.is_empty() {
184            messages.push(Message {
185                role: Role::Assistant,
186                content: notebook.as_history(),
187            });
188            messages.push(Message {
189                role: Role::User,
190                content:
191                    "Continue. Write the next Rhai script or call done() if you have the answer."
192                        .to_string(),
193            });
194        }
195
196        messages
197    }
198}
199
200/// Strip markdown code fences if the model wraps the script.
201fn extract_script(raw: &str) -> &str {
202    let trimmed = raw.trim();
203    if let Some(inner) = trimmed.strip_prefix("```rhai") {
204        inner.trim_end_matches("```").trim()
205    } else if let Some(inner) = trimmed.strip_prefix("```") {
206        inner.trim_end_matches("```").trim()
207    } else {
208        trimmed
209    }
210}