Skip to main content

codetether_agent/rlm/
tools.rs

1//! RLM REPL operations expressed as tool definitions.
2//!
3//! When FunctionGemma is active the RLM loop sends these definitions alongside
4//! the analysis prompt.  The primary LLM (or FunctionGemma after reformatting)
5//! returns structured `ContentPart::ToolCall` entries that are dispatched here
6//! instead of being regex-parsed from code blocks.
7//!
8//! Each tool mirrors a command in the existing DSL REPL (`head`, `tail`,
9//! `grep`, `count`, `llm_query`, `FINAL`).
10
11use crate::provider::ToolDefinition;
12
13/// All RLM REPL operations as tool definitions.
14pub fn rlm_tool_definitions() -> Vec<ToolDefinition> {
15    vec![
16        ToolDefinition {
17            name: "rlm_head".to_string(),
18            description: "Return the first N lines of the loaded context.".to_string(),
19            parameters: serde_json::json!({
20                "type": "object",
21                "properties": {
22                    "n": {
23                        "type": "integer",
24                        "description": "Number of lines from the start (default: 10)"
25                    }
26                },
27                "required": []
28            }),
29        },
30        ToolDefinition {
31            name: "rlm_tail".to_string(),
32            description: "Return the last N lines of the loaded context.".to_string(),
33            parameters: serde_json::json!({
34                "type": "object",
35                "properties": {
36                    "n": {
37                        "type": "integer",
38                        "description": "Number of lines from the end (default: 10)"
39                    }
40                },
41                "required": []
42            }),
43        },
44        ToolDefinition {
45            name: "rlm_grep".to_string(),
46            description: "Search the loaded context for lines matching a regex pattern. Returns matching lines with line numbers.".to_string(),
47            parameters: serde_json::json!({
48                "type": "object",
49                "properties": {
50                    "pattern": {
51                        "type": "string",
52                        "description": "Regex pattern to search for"
53                    }
54                },
55                "required": ["pattern"]
56            }),
57        },
58        ToolDefinition {
59            name: "rlm_count".to_string(),
60            description: "Count occurrences of a regex pattern in the loaded context.".to_string(),
61            parameters: serde_json::json!({
62                "type": "object",
63                "properties": {
64                    "pattern": {
65                        "type": "string",
66                        "description": "Regex pattern to count"
67                    }
68                },
69                "required": ["pattern"]
70            }),
71        },
72        ToolDefinition {
73            name: "rlm_slice".to_string(),
74            description: "Return a slice of the context by line range.".to_string(),
75            parameters: serde_json::json!({
76                "type": "object",
77                "properties": {
78                    "start": {
79                        "type": "integer",
80                        "description": "Start line number (0-indexed)"
81                    },
82                    "end": {
83                        "type": "integer",
84                        "description": "End line number (exclusive)"
85                    }
86                },
87                "required": ["start", "end"]
88            }),
89        },
90        ToolDefinition {
91            name: "rlm_llm_query".to_string(),
92            description: "Ask a focused sub-question about a portion of the context. Use this for semantic understanding of specific sections.".to_string(),
93            parameters: serde_json::json!({
94                "type": "object",
95                "properties": {
96                    "query": {
97                        "type": "string",
98                        "description": "The question to answer about the context"
99                    },
100                    "context_slice": {
101                        "type": "string",
102                        "description": "Optional: specific text slice to analyze (if omitted, uses full context)"
103                    }
104                },
105                "required": ["query"]
106            }),
107        },
108        ToolDefinition {
109            name: "rlm_final".to_string(),
110            description: "Return the final answer to the analysis query. Call this when you have gathered enough information to answer.".to_string(),
111            parameters: serde_json::json!({
112                "type": "object",
113                "properties": {
114                    "answer": {
115                        "type": "string",
116                        "description": "The complete, detailed answer to the original query"
117                    }
118                },
119                "required": ["answer"]
120            }),
121        },
122        ToolDefinition {
123            name: "rlm_ast_query".to_string(),
124            description: "Execute a tree-sitter AST query on the loaded context. Use this for structural code analysis (function signatures, struct fields, impl blocks).".to_string(),
125            parameters: serde_json::json!({
126                "type": "object",
127                "properties": {
128                    "query": {
129                        "type": "string",
130                        "description": "Tree-sitter S-expression query (e.g., '(function_item name: (identifier) @name)')"
131                    }
132                },
133                "required": ["query"]
134            }),
135        },
136    ]
137}
138
139/// Result of dispatching an RLM tool call.
140pub enum RlmToolResult {
141    /// Normal output to feed back to the LLM.
142    Output(String),
143    /// The final answer — terminates the RLM loop.
144    Final(String),
145}
146
147/// Dispatch a structured tool call against the REPL.
148///
149/// Returns `None` if the tool name is not an `rlm_*` tool (pass-through for
150/// any other tool calls the model may have produced).
151pub fn dispatch_tool_call(
152    name: &str,
153    arguments: &str,
154    repl: &mut super::repl::RlmRepl,
155) -> Option<RlmToolResult> {
156    let args: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default();
157
158    match name {
159        "rlm_head" => {
160            let n = args.get("n").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
161            let output = repl.head(n).join("\n");
162            Some(RlmToolResult::Output(output))
163        }
164        "rlm_tail" => {
165            let n = args.get("n").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
166            let output = repl.tail(n).join("\n");
167            Some(RlmToolResult::Output(output))
168        }
169        "rlm_grep" => {
170            let pattern = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
171            let matches = repl.grep(pattern);
172            let output = matches
173                .iter()
174                .map(|(i, line)| format!("{}:{}", i, line))
175                .collect::<Vec<_>>()
176                .join("\n");
177            if output.is_empty() {
178                Some(RlmToolResult::Output("(no matches)".to_string()))
179            } else {
180                Some(RlmToolResult::Output(output))
181            }
182        }
183        "rlm_count" => {
184            let pattern = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
185            let count = repl.count(pattern);
186            Some(RlmToolResult::Output(count.to_string()))
187        }
188        "rlm_slice" => {
189            let start = args.get("start").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
190            let end = args.get("end").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
191            let output = repl.slice(start, end).to_string();
192            Some(RlmToolResult::Output(output))
193        }
194        "rlm_llm_query" => {
195            // The llm_query tool requires async provider calls — return a
196            // sentinel so the caller knows to handle it specially.
197            let query = args
198                .get("query")
199                .and_then(|v| v.as_str())
200                .unwrap_or("")
201                .to_string();
202            let context_slice = args
203                .get("context_slice")
204                .and_then(|v| v.as_str())
205                .map(|s| s.to_string());
206            // Encode the query + optional slice as JSON so the caller can
207            // destructure it.
208            let payload = serde_json::json!({
209                "__rlm_llm_query": true,
210                "query": query,
211                "context_slice": context_slice,
212            });
213            Some(RlmToolResult::Output(payload.to_string()))
214        }
215        "rlm_final" => {
216            let answer = args
217                .get("answer")
218                .and_then(|v| v.as_str())
219                .unwrap_or("")
220                .to_string();
221            Some(RlmToolResult::Final(answer))
222        }
223        "rlm_ast_query" => {
224            let query = args
225                .get("query")
226                .and_then(|v| v.as_str())
227                .unwrap_or("");
228            
229            // Create a tree-sitter oracle and execute the query
230            let mut oracle = super::oracle::TreeSitterOracle::new(repl.context().to_string());
231            match oracle.query(query) {
232                Ok(result) => {
233                    // Format the result as JSON
234                    let matches: Vec<serde_json::Value> = result.matches.iter().map(|m| {
235                        serde_json::json!({
236                            "line": m.line,
237                            "column": m.column,
238                            "captures": m.captures,
239                            "text": m.text
240                        })
241                    }).collect();
242                    
243                    let output = serde_json::json!({
244                        "query": query,
245                        "match_count": matches.len(),
246                        "matches": matches
247                    });
248                    
249                    Some(RlmToolResult::Output(output.to_string()))
250                }
251                Err(e) => {
252                    Some(RlmToolResult::Output(format!("AST query error: {}", e)))
253                }
254            }
255        }
256        _ => None, // Not an RLM tool
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::rlm::repl::{ReplRuntime, RlmRepl};
264
265    #[test]
266    fn tool_definitions_are_complete() {
267        let defs = rlm_tool_definitions();
268        assert_eq!(defs.len(), 8);
269        let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
270        assert!(names.contains(&"rlm_head"));
271        assert!(names.contains(&"rlm_tail"));
272        assert!(names.contains(&"rlm_grep"));
273        assert!(names.contains(&"rlm_count"));
274        assert!(names.contains(&"rlm_slice"));
275        assert!(names.contains(&"rlm_llm_query"));
276        assert!(names.contains(&"rlm_final"));
277        assert!(names.contains(&"rlm_ast_query"));
278    }
279
280    #[test]
281    fn dispatch_head() {
282        let ctx = "line 1\nline 2\nline 3\nline 4\nline 5".to_string();
283        let mut repl = RlmRepl::new(ctx, ReplRuntime::Rust);
284        let result = dispatch_tool_call("rlm_head", r#"{"n": 2}"#, &mut repl);
285        match result {
286            Some(RlmToolResult::Output(s)) => assert_eq!(s, "line 1\nline 2"),
287            _ => panic!("expected Output"),
288        }
289    }
290
291    #[test]
292    fn dispatch_tail() {
293        let ctx = "line 1\nline 2\nline 3\nline 4\nline 5".to_string();
294        let mut repl = RlmRepl::new(ctx, ReplRuntime::Rust);
295        let result = dispatch_tool_call("rlm_tail", r#"{"n": 2}"#, &mut repl);
296        match result {
297            Some(RlmToolResult::Output(s)) => assert_eq!(s, "line 4\nline 5"),
298            _ => panic!("expected Output"),
299        }
300    }
301
302    #[test]
303    fn dispatch_grep() {
304        let ctx = "error: fail\ninfo: ok\nerror: boom".to_string();
305        let mut repl = RlmRepl::new(ctx, ReplRuntime::Rust);
306        let result = dispatch_tool_call("rlm_grep", r#"{"pattern": "error"}"#, &mut repl);
307        match result {
308            Some(RlmToolResult::Output(s)) => {
309                assert!(s.contains("error: fail"));
310                assert!(s.contains("error: boom"));
311            }
312            _ => panic!("expected Output"),
313        }
314    }
315
316    #[test]
317    fn dispatch_final() {
318        let ctx = "whatever".to_string();
319        let mut repl = RlmRepl::new(ctx, ReplRuntime::Rust);
320        let result =
321            dispatch_tool_call("rlm_final", r#"{"answer": "The answer is 42"}"#, &mut repl);
322        match result {
323            Some(RlmToolResult::Final(s)) => assert_eq!(s, "The answer is 42"),
324            _ => panic!("expected Final"),
325        }
326    }
327
328    #[test]
329    fn dispatch_unknown_returns_none() {
330        let ctx = "data".to_string();
331        let mut repl = RlmRepl::new(ctx, ReplRuntime::Rust);
332        assert!(dispatch_tool_call("unknown_tool", "{}", &mut repl).is_none());
333    }
334}