1pub mod file_tools;
2pub mod gh_tools;
3pub mod lsp_tools;
4pub mod memory_tools;
5pub mod meta_tools;
6pub mod search_tools;
7pub mod shell_tools;
8pub mod symbol_tools;
9
10use crate::config::Config;
11use crate::errors::{RalphError, Result};
12use crate::guardrails::GuardrailChecker;
13use crate::lsp_client::LspClient;
14use crate::memory::MemoryStore;
15use crate::output::{ConfirmResult, Printer};
16use crate::providers::ToolDef;
17use crate::symbol_index::SymbolIndex;
18use serde_json::{json, Value};
19use std::path::{Path, PathBuf};
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::sync::{Arc, Mutex};
22
23#[derive(Debug, Clone)]
26pub struct ToolCall {
27 pub id: String,
28 pub name: String,
29 pub arguments: Value,
30}
31
32#[derive(Debug, Clone)]
33pub struct ToolResult {
34 pub call_id: String,
35 pub tool_name: String,
36 pub output: String,
37 pub is_error: bool,
38}
39
40pub struct ToolRegistry {
43 workspace: PathBuf,
44 config: Config,
45 guardrails: GuardrailChecker,
46 printer: Arc<Printer>,
47 pub modified_files: Vec<PathBuf>,
49 command_confirmed_once: bool,
51 no_confirm: bool,
52 symbol_index: Option<SymbolIndex>,
54 memory: Arc<Mutex<MemoryStore>>,
56 lsp_clients: Vec<LspClient>,
58 diff_preview: Arc<AtomicBool>,
60}
61
62impl ToolRegistry {
63 pub fn new(
64 workspace: PathBuf,
65 config: Config,
66 printer: Arc<Printer>,
67 no_confirm: bool,
68 memory: Arc<Mutex<MemoryStore>>,
69 lsp_languages: Vec<String>,
70 diff_preview: Arc<AtomicBool>,
71 ) -> Self {
72 let guardrails = GuardrailChecker::new(workspace.clone(), config.guardrails.clone());
73 let lsp_clients = lsp_languages
74 .iter()
75 .map(|lang| LspClient::new(lang, &workspace))
76 .collect();
77 Self {
78 workspace,
79 config,
80 guardrails,
81 printer,
82 modified_files: Vec::new(),
83 command_confirmed_once: false,
84 no_confirm,
85 symbol_index: None,
86 memory,
87 lsp_clients,
88 diff_preview,
89 }
90 }
91
92 fn ensure_symbol_index(&mut self) {
94 if self.symbol_index.is_none() {
95 self.printer
96 .print(crate::output::Phase::Observe, "Building symbol index...");
97 let idx = SymbolIndex::build(&self.workspace);
98 self.printer.print(
99 crate::output::Phase::Observe,
100 &format!("Symbol index ready ({} symbols).", idx.symbol_count()),
101 );
102 self.symbol_index = Some(idx);
103 }
104 }
105
106 pub async fn execute(&mut self, call: &ToolCall) -> Result<ToolResult> {
107 self.guardrails
109 .check_tool_call(&call.name, &call.arguments)?;
110
111 match call.name.as_str() {
112 "read_file" => self.exec_read_file(call),
113 "read_file_outline" => self.exec_read_file_outline(call),
114 "load_files" => self.exec_load_files(call),
115 "explain_code" => self.exec_explain_code(call),
116 "list_dir" => self.exec_list_dir(call),
117 "glob" => self.exec_glob(call),
118 "write_file" => self.exec_write_file(call).await,
119 "edit_file" => self.exec_edit_file(call).await,
120 "edit_file_multi" => self.exec_edit_file_multi(call).await,
121 "delete_file" => self.exec_delete_file(call).await,
122 "view_diff" => self.exec_view_diff(call).await,
123 "run_command" => self.exec_run_command(call).await,
124 "run_test" => self.exec_run_test(call).await,
125 "run_build" => self.exec_run_build(call).await,
126 "search_web" => self.exec_search_web(call).await,
127 "search_codebase" => self.exec_search_codebase(call),
128 "search_in_file" => self.exec_search_in_file(call),
129 "find_symbol" => self.exec_find_symbol(call),
130 "read_symbol" => self.exec_read_symbol(call),
131 "go_to_definition" => self.exec_go_to_definition(call).await,
132 "find_references" => self.exec_find_references(call).await,
133 "hover" => self.exec_hover(call).await,
134 "remember" => self.exec_remember(call),
135 "recall" => self.exec_recall(call),
136 "create_pr" => self.exec_create_pr(call).await,
137 "get_ci_status" => self.exec_get_ci_status(call).await,
138 "ask_user" => self.exec_ask_user(call),
139 "declare_done" => self.exec_declare_done(call),
140 "declare_failed" => self.exec_declare_failed(call),
141 unknown => Err(RalphError::ToolFailed {
142 tool: unknown.to_string(),
143 message: "Unknown tool".to_string(),
144 }),
145 }
146 }
147
148 fn exec_read_file(&self, call: &ToolCall) -> Result<ToolResult> {
151 let path = str_arg(&call.arguments, "path")?;
152 let full = self.resolve_path(&path)?;
153 let offset = call.arguments["offset"].as_u64().map(|n| n as usize);
154 let limit = call.arguments["limit"].as_u64().map(|n| n as usize);
155 let content = file_tools::read_file_ranged(&full, offset, limit)?;
156 Ok(ok_result(call, content))
157 }
158
159 fn exec_load_files(&self, call: &ToolCall) -> Result<ToolResult> {
160 let pattern = str_arg(&call.arguments, "pattern")?;
161 let root = str_arg(&call.arguments, "path")
162 .ok()
163 .map(|p| self.workspace.join(p))
164 .unwrap_or_else(|| self.workspace.clone());
165 let content = file_tools::load_files(&pattern, &root)?;
166 Ok(ok_result(call, content))
167 }
168
169 fn exec_explain_code(&self, call: &ToolCall) -> Result<ToolResult> {
170 let root = str_arg(&call.arguments, "path")
171 .ok()
172 .map(|p| self.resolve_path(&p))
173 .transpose()?
174 .unwrap_or_else(|| self.workspace.clone());
175 let report = file_tools::explain_code(&root)?;
176 Ok(ok_result(call, report))
177 }
178
179 fn exec_list_dir(&self, call: &ToolCall) -> Result<ToolResult> {
180 let path = str_arg(&call.arguments, "path").unwrap_or_else(|_| ".".to_string());
181 let full = self.resolve_path(&path)?;
182 let listing = file_tools::list_dir(&full)?;
183 Ok(ok_result(call, listing))
184 }
185
186 fn exec_read_file_outline(&self, call: &ToolCall) -> Result<ToolResult> {
187 let path = str_arg(&call.arguments, "path")?;
188 let full = self.resolve_path(&path)?;
189 let outline = file_tools::read_file_outline(&full)?;
190 Ok(ok_result(call, outline))
191 }
192
193 fn exec_glob(&self, call: &ToolCall) -> Result<ToolResult> {
194 let pattern = str_arg(&call.arguments, "pattern")?;
195 let root = str_arg(&call.arguments, "path")
196 .ok()
197 .map(|p| self.workspace.join(p))
198 .unwrap_or_else(|| self.workspace.clone());
199 let result = search_tools::glob_files(&pattern, &root)?;
200 Ok(ok_result(call, result))
201 }
202
203 fn exec_search_in_file(&self, call: &ToolCall) -> Result<ToolResult> {
204 let pattern = str_arg(&call.arguments, "pattern")?;
205 let path = str_arg(&call.arguments, "path")?;
206 let context = call.arguments["context"].as_u64().unwrap_or(3) as usize;
207 let full = self.resolve_path(&path)?;
208 let result = search_tools::search_in_file(&pattern, &full, context)?;
209 Ok(ok_result(call, result))
210 }
211
212 async fn exec_view_diff(&self, call: &ToolCall) -> Result<ToolResult> {
213 let file_filter = call.arguments["path"].as_str().map(|p| p.to_string());
214 let result = view_git_diff(&self.workspace, file_filter.as_deref()).await;
215 Ok(ok_result(call, result))
216 }
217
218 async fn exec_write_file(&mut self, call: &ToolCall) -> Result<ToolResult> {
219 let path = str_arg(&call.arguments, "path")?;
220 let content = str_arg(&call.arguments, "content")?;
221 let full = self.resolve_path(&path)?;
222
223 if self.diff_preview.load(Ordering::Relaxed) && !self.no_confirm {
224 let existing = std::fs::read_to_string(&full).unwrap_or_default();
226 self.printer.print_diff(&path, &existing, &content);
227 let result = self.printer.confirm("Proceed with write?", false, false);
228 if result != ConfirmResult::Yes {
229 return Err(RalphError::UserAborted);
230 }
231 } else if full.exists() && !self.no_confirm {
232 let auto_cp = self.config.checkpoints.auto_checkpoint_before_destructive;
234 let result = self.printer.confirm(
235 &format!("write_file({:?}) will overwrite an existing file.", path),
236 true,
237 auto_cp,
238 );
239 match result {
240 ConfirmResult::No => return Err(RalphError::UserAborted),
241 ConfirmResult::ShowDiff => {
242 let existing = std::fs::read_to_string(&full).unwrap_or_default();
243 self.printer.print_diff(&path, &existing, &content);
244 let result2 = self.printer.confirm("Proceed with write?", false, false);
245 if result2 != ConfirmResult::Yes {
246 return Err(RalphError::UserAborted);
247 }
248 }
249 ConfirmResult::CheckpointAndProceed => {}
250 ConfirmResult::Yes => {}
251 }
252 }
253
254 self.guardrails.check_content_for_secrets(&content)?;
256
257 file_tools::write_file(&full, &content)?;
258 self.modified_files.push(full);
259 Ok(ok_result(call, format!("Written: {}", path)))
260 }
261
262 async fn exec_edit_file(&mut self, call: &ToolCall) -> Result<ToolResult> {
263 let path = str_arg(&call.arguments, "path")?;
264 let old_string = str_arg(&call.arguments, "old_string")?;
265 let new_string = str_arg(&call.arguments, "new_string")?;
266 let full = self.resolve_path(&path)?;
267 match file_tools::edit_file(&full, &old_string, &new_string) {
268 Ok(()) => {
269 self.modified_files.push(full);
270 Ok(ok_result(call, format!("Edited: {}", path)))
271 }
272 Err(RalphError::EditNotFound { .. }) => {
273 let content = std::fs::read_to_string(&full).unwrap_or_default();
274 let hint = file_tools::find_closest_match_hint(&content, &old_string);
275 let hint_section = if hint.is_empty() {
276 String::new()
277 } else {
278 format!("\n\n{}", hint)
279 };
280 Ok(ToolResult {
281 call_id: call.id.clone(),
282 tool_name: call.name.clone(),
283 output: format!(
284 "edit_file failed: old_string not found in {}.\n\
285 The text must match the file byte-for-byte (check whitespace/indentation).\n\
286 Use `search_in_file` or `read_file` with offset/limit to see the exact text.{}",
287 path, hint_section
288 ),
289 is_error: true,
290 })
291 }
292 Err(e) => Err(e),
293 }
294 }
295
296 async fn exec_edit_file_multi(&mut self, call: &ToolCall) -> Result<ToolResult> {
297 let path = str_arg(&call.arguments, "path")?;
298 let full = self.resolve_path(&path)?;
299 let edits_val = call.arguments["edits"].as_array().ok_or_else(|| {
300 RalphError::MalformedToolCall("edit_file_multi: 'edits' must be an array".to_string())
301 })?;
302
303 let edits: Vec<(String, String)> = edits_val
304 .iter()
305 .enumerate()
306 .map(|(i, e)| {
307 let old = e["old_string"]
308 .as_str()
309 .ok_or_else(|| {
310 RalphError::MalformedToolCall(format!("edit[{}]: missing old_string", i))
311 })?
312 .to_string();
313 let new = e["new_string"]
314 .as_str()
315 .ok_or_else(|| {
316 RalphError::MalformedToolCall(format!("edit[{}]: missing new_string", i))
317 })?
318 .to_string();
319 Ok::<_, RalphError>((old, new))
320 })
321 .collect::<Result<Vec<_>>>()?;
322
323 match file_tools::edit_file_multi(&full, &edits) {
324 Ok(applied) => {
325 self.modified_files.push(full);
326 Ok(ok_result(
327 call,
328 format!("Edited {}: {}", path, applied.join(", ")),
329 ))
330 }
331 Err(e) => Ok(ToolResult {
332 call_id: call.id.clone(),
333 tool_name: call.name.clone(),
334 output: format!("edit_file_multi failed: {}", e),
335 is_error: true,
336 }),
337 }
338 }
339
340 async fn exec_delete_file(&mut self, call: &ToolCall) -> Result<ToolResult> {
341 let path = str_arg(&call.arguments, "path")?;
342 let full = self.resolve_path(&path)?;
343
344 if !self.no_confirm {
345 let result = self.printer.confirm(
346 &format!("delete_file({:?}). This cannot be undone.", path),
347 false,
348 self.config.checkpoints.auto_checkpoint_before_destructive,
349 );
350 if result == ConfirmResult::No {
351 return Err(RalphError::UserAborted);
352 }
353 }
354
355 file_tools::delete_file(&full)?;
356 self.modified_files.push(full);
357 Ok(ok_result(call, format!("Deleted: {}", path)))
358 }
359
360 async fn exec_run_command(&mut self, call: &ToolCall) -> Result<ToolResult> {
363 let cmd = str_arg(&call.arguments, "cmd")?;
364 let cwd = str_arg(&call.arguments, "cwd")
365 .ok()
366 .map(|c| self.workspace.join(c))
367 .unwrap_or_else(|| self.workspace.clone());
368
369 if !self.command_confirmed_once && !self.no_confirm {
370 let result = self
371 .printer
372 .confirm(&format!("run_command: `{}`", cmd), false, false);
373 if result == ConfirmResult::No {
374 return Err(RalphError::UserAborted);
375 }
376 self.command_confirmed_once = true;
377 }
378
379 let output = shell_tools::run_command(&cmd, &cwd).await?;
380 Ok(ok_result(call, output))
381 }
382
383 async fn exec_run_test(&self, call: &ToolCall) -> Result<ToolResult> {
384 let cmd = str_arg(&call.arguments, "cmd")?;
385 let output = shell_tools::run_command(&cmd, &self.workspace).await?;
386 Ok(ok_result(call, output))
387 }
388
389 async fn exec_run_build(&self, call: &ToolCall) -> Result<ToolResult> {
390 let cmd = str_arg(&call.arguments, "cmd")?;
391 let output = shell_tools::run_command(&cmd, &self.workspace).await?;
392 Ok(ok_result(call, output))
393 }
394
395 async fn exec_search_web(&self, call: &ToolCall) -> Result<ToolResult> {
398 let query = str_arg(&call.arguments, "query")?;
399 let brave_key = std::env::var(&self.config.search.brave_api_key_env).ok();
400 let serp_key = std::env::var(&self.config.search.serp_api_key_env).ok();
401 let results =
402 search_tools::search_web(&query, brave_key.as_deref(), serp_key.as_deref()).await?;
403 Ok(ok_result(call, results))
404 }
405
406 fn exec_search_codebase(&self, call: &ToolCall) -> Result<ToolResult> {
407 let pattern = str_arg(&call.arguments, "pattern")?;
408 let path = str_arg(&call.arguments, "path")
409 .ok()
410 .map(|p| self.workspace.join(p))
411 .unwrap_or_else(|| self.workspace.clone());
412 let glob = call.arguments["glob"].as_str();
413 let context = call.arguments["context"].as_u64().unwrap_or(0) as usize;
414 let results = search_tools::search_codebase_filtered(&pattern, &path, glob, context)?;
415 Ok(ok_result(call, results))
416 }
417
418 fn exec_find_symbol(&mut self, call: &ToolCall) -> Result<ToolResult> {
421 let query = str_arg(&call.arguments, "query")?;
422 self.ensure_symbol_index();
423 let output = symbol_tools::find_symbol(self.symbol_index.as_ref().unwrap(), &query);
424 Ok(ok_result(call, output))
425 }
426
427 fn exec_read_symbol(&mut self, call: &ToolCall) -> Result<ToolResult> {
428 let name = str_arg(&call.arguments, "name")?;
429 self.ensure_symbol_index();
430 let output = symbol_tools::read_symbol(self.symbol_index.as_ref().unwrap(), &name);
431 Ok(ok_result(call, output))
432 }
433
434 async fn exec_go_to_definition(&mut self, call: &ToolCall) -> Result<ToolResult> {
437 let file = str_arg(&call.arguments, "file")?;
438 let line = call.arguments["line"].as_u64().unwrap_or(1) as u32;
439 let col = call.arguments["col"].as_u64().unwrap_or(1) as u32;
440 let file_path = Path::new(&file);
441 let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
442
443 for client in &mut self.lsp_clients {
444 if client.handles_extension(ext) {
445 let out = client.go_to_definition(file_path, line, col).await?;
446 return Ok(ok_result(call, format!("[LSP] {}", out)));
447 }
448 }
449 let out = lsp_tools::go_to_definition_grep(&self.workspace, file_path, line, col);
450 Ok(ok_result(call, out))
451 }
452
453 async fn exec_find_references(&mut self, call: &ToolCall) -> Result<ToolResult> {
454 let file = str_arg(&call.arguments, "file")?;
455 let line = call.arguments["line"].as_u64().unwrap_or(1) as u32;
456 let col = call.arguments["col"].as_u64().unwrap_or(1) as u32;
457 let file_path = Path::new(&file);
458 let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
459
460 for client in &mut self.lsp_clients {
461 if client.handles_extension(ext) {
462 let out = client.find_references(file_path, line, col).await?;
463 return Ok(ok_result(call, format!("[LSP] {}", out)));
464 }
465 }
466 let out = lsp_tools::find_references_grep(&self.workspace, file_path, line, col);
467 Ok(ok_result(call, out))
468 }
469
470 async fn exec_hover(&mut self, call: &ToolCall) -> Result<ToolResult> {
471 let file = str_arg(&call.arguments, "file")?;
472 let line = call.arguments["line"].as_u64().unwrap_or(1) as u32;
473 let col = call.arguments["col"].as_u64().unwrap_or(1) as u32;
474 let file_path = Path::new(&file);
475 let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
476
477 for client in &mut self.lsp_clients {
478 if client.handles_extension(ext) {
479 let out = client.hover(file_path, line, col).await?;
480 return Ok(ok_result(call, format!("[LSP] {}", out)));
481 }
482 }
483 let out = lsp_tools::hover_grep(&self.workspace, file_path, line, col);
484 Ok(ok_result(call, out))
485 }
486
487 fn exec_remember(&self, call: &ToolCall) -> Result<ToolResult> {
490 let key = str_arg(&call.arguments, "key")?;
491 let value = str_arg(&call.arguments, "value")?;
492 let output = memory_tools::remember(
493 &mut self.memory.lock().unwrap_or_else(|e| e.into_inner()),
494 &key,
495 &value,
496 );
497 Ok(ok_result(call, output))
498 }
499
500 fn exec_recall(&self, call: &ToolCall) -> Result<ToolResult> {
501 let query = str_arg(&call.arguments, "query").unwrap_or_default();
502 let output = memory_tools::recall(
503 &self.memory.lock().unwrap_or_else(|e| e.into_inner()),
504 &query,
505 );
506 Ok(ok_result(call, output))
507 }
508
509 async fn exec_create_pr(&self, call: &ToolCall) -> Result<ToolResult> {
512 let title = str_arg(&call.arguments, "title")?;
513 let body = str_arg(&call.arguments, "body").unwrap_or_default();
514 let draft = call.arguments["draft"].as_bool().unwrap_or(false);
515 let base = call.arguments["base"].as_str().map(|s| s.to_string());
516 let url =
517 gh_tools::create_pr(&title, &body, draft, base.as_deref(), &self.workspace).await?;
518 Ok(ok_result(call, format!("PR created: {}", url)))
519 }
520
521 async fn exec_get_ci_status(&self, call: &ToolCall) -> Result<ToolResult> {
522 let branch = call.arguments["branch"].as_str().map(|s| s.to_string());
523 let status = gh_tools::get_ci_status(branch.as_deref(), &self.workspace).await?;
524 Ok(ok_result(call, status))
525 }
526
527 fn exec_ask_user(&self, call: &ToolCall) -> Result<ToolResult> {
530 let question = str_arg(&call.arguments, "question")?;
531 let answer = meta_tools::ask_user(&question);
532 Ok(ok_result(call, answer))
533 }
534
535 fn exec_declare_done(&self, call: &ToolCall) -> Result<ToolResult> {
536 let summary = str_arg(&call.arguments, "summary").unwrap_or_default();
537 Ok(ToolResult {
538 call_id: call.id.clone(),
539 tool_name: call.name.clone(),
540 output: format!("DONE: {}", summary),
541 is_error: false,
542 })
543 }
544
545 fn exec_declare_failed(&self, call: &ToolCall) -> Result<ToolResult> {
546 let reason = str_arg(&call.arguments, "reason").unwrap_or_default();
547 Ok(ToolResult {
548 call_id: call.id.clone(),
549 tool_name: call.name.clone(),
550 output: format!("FAILED: {}", reason),
551 is_error: true,
552 })
553 }
554
555 fn resolve_path(&self, path: &str) -> Result<PathBuf> {
558 let joined = if std::path::Path::new(path).is_absolute() {
559 PathBuf::from(path)
560 } else {
561 self.workspace.join(path)
562 };
563 let canonical = joined.canonicalize().unwrap_or_else(|_| joined.clone());
564 let ws_canonical = self
565 .workspace
566 .canonicalize()
567 .unwrap_or_else(|_| self.workspace.clone());
568 if !canonical.starts_with(&ws_canonical) {
569 return Err(RalphError::PathEscape(path.to_string()));
570 }
571 Ok(canonical)
572 }
573}
574
575pub async fn view_git_diff(workspace: &std::path::Path, file_filter: Option<&str>) -> String {
577 let mut cmd = tokio::process::Command::new("git");
578 cmd.arg("diff").arg("HEAD");
579 if let Some(f) = file_filter {
580 cmd.arg("--").arg(f);
581 }
582 cmd.current_dir(workspace);
583
584 match cmd.output().await {
585 Ok(out) => {
586 let diff = String::from_utf8_lossy(&out.stdout).to_string();
587 if diff.trim().is_empty() {
588 let fallback = tokio::process::Command::new("git")
590 .args(["diff"])
591 .current_dir(workspace)
592 .output()
593 .await
594 .ok()
595 .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
596 .unwrap_or_default();
597 if fallback.trim().is_empty() {
598 "(no changes from last commit)".to_string()
599 } else {
600 fallback
601 }
602 } else {
603 diff
604 }
605 }
606 Err(e) => format!("(git not available: {})", e),
607 }
608}
609
610fn str_arg(args: &Value, key: &str) -> Result<String> {
611 args.get(key)
612 .and_then(|v| v.as_str())
613 .map(|s| s.to_string())
614 .ok_or_else(|| RalphError::MalformedToolCall(format!("missing argument: {}", key)))
615}
616
617fn ok_result(call: &ToolCall, output: String) -> ToolResult {
618 ToolResult {
619 call_id: call.id.clone(),
620 tool_name: call.name.clone(),
621 output,
622 is_error: false,
623 }
624}
625
626pub fn is_read_only(name: &str) -> bool {
631 matches!(
632 name,
633 "read_file"
634 | "read_file_outline"
635 | "load_files"
636 | "list_dir"
637 | "glob"
638 | "explain_code"
639 | "search_codebase"
640 | "search_in_file"
641 | "search_web"
642 | "find_symbol"
643 | "read_symbol"
644 | "go_to_definition"
645 | "find_references"
646 | "hover"
647 | "recall"
648 )
649}
650
651pub struct ReadOnlyContext {
653 pub workspace: PathBuf,
654 pub config: crate::config::Config,
655 pub printer: Arc<Printer>,
656 pub symbol_index: Arc<tokio::sync::Mutex<Option<SymbolIndex>>>,
658 pub memory: Arc<Mutex<MemoryStore>>,
659}
660
661pub async fn execute_read_only(
665 ctx: Arc<ReadOnlyContext>,
666 call: ToolCall,
667) -> crate::errors::Result<ToolResult> {
668 let resolve = |path: &str| -> crate::errors::Result<PathBuf> {
670 let joined = if Path::new(path).is_absolute() {
671 PathBuf::from(path)
672 } else {
673 ctx.workspace.join(path)
674 };
675 let canonical = joined.canonicalize().unwrap_or_else(|_| joined.clone());
676 let ws = ctx
677 .workspace
678 .canonicalize()
679 .unwrap_or_else(|_| ctx.workspace.clone());
680 if !canonical.starts_with(&ws) {
681 return Err(crate::errors::RalphError::PathEscape(path.to_string()));
682 }
683 Ok(joined)
684 };
685
686 let output: String = match call.name.as_str() {
687 "read_file" => {
688 let path = str_arg(&call.arguments, "path")?;
689 let offset = call.arguments["offset"].as_u64().map(|n| n as usize);
690 let limit = call.arguments["limit"].as_u64().map(|n| n as usize);
691 file_tools::read_file_ranged(&resolve(&path)?, offset, limit)?
692 }
693 "read_file_outline" => {
694 let path = str_arg(&call.arguments, "path")?;
695 file_tools::read_file_outline(&resolve(&path)?)?
696 }
697 "load_files" => {
698 let pattern = str_arg(&call.arguments, "pattern")?;
699 let root = str_arg(&call.arguments, "path")
700 .ok()
701 .map(|p| ctx.workspace.join(p))
702 .unwrap_or_else(|| ctx.workspace.clone());
703 file_tools::load_files(&pattern, &root)?
704 }
705 "list_dir" => {
706 let path = str_arg(&call.arguments, "path").unwrap_or_else(|_| ".".to_string());
707 file_tools::list_dir(&resolve(&path)?)?
708 }
709 "glob" => {
710 let pattern = str_arg(&call.arguments, "pattern")?;
711 let root = str_arg(&call.arguments, "path")
712 .ok()
713 .map(|p| ctx.workspace.join(p))
714 .unwrap_or_else(|| ctx.workspace.clone());
715 search_tools::glob_files(&pattern, &root)?
716 }
717 "explain_code" => {
718 let root = str_arg(&call.arguments, "path")
719 .ok()
720 .map(|p| ctx.workspace.join(p))
721 .unwrap_or_else(|| ctx.workspace.clone());
722 file_tools::explain_code(&root)?
723 }
724 "search_codebase" => {
725 let pattern = str_arg(&call.arguments, "pattern")?;
726 let path = str_arg(&call.arguments, "path")
727 .ok()
728 .map(|p| ctx.workspace.join(p))
729 .unwrap_or_else(|| ctx.workspace.clone());
730 let glob = call.arguments["glob"].as_str();
731 let context = call.arguments["context"].as_u64().unwrap_or(0) as usize;
732 search_tools::search_codebase_filtered(&pattern, &path, glob, context)?
733 }
734 "search_in_file" => {
735 let pattern = str_arg(&call.arguments, "pattern")?;
736 let path = str_arg(&call.arguments, "path")?;
737 let context = call.arguments["context"].as_u64().unwrap_or(3) as usize;
738 search_tools::search_in_file(&pattern, &resolve(&path)?, context)?
739 }
740 "search_web" => {
741 let query = str_arg(&call.arguments, "query")?;
742 let brave = std::env::var(&ctx.config.search.brave_api_key_env).ok();
743 let serp = std::env::var(&ctx.config.search.serp_api_key_env).ok();
744 search_tools::search_web(&query, brave.as_deref(), serp.as_deref()).await?
745 }
746 "find_symbol" => {
747 let query = str_arg(&call.arguments, "query")?;
748 let mut guard = ctx.symbol_index.lock().await;
749 if guard.is_none() {
750 *guard = Some(SymbolIndex::build(&ctx.workspace));
751 }
752 symbol_tools::find_symbol(guard.as_ref().unwrap(), &query)
753 }
754 "read_symbol" => {
755 let name = str_arg(&call.arguments, "name")?;
756 let mut guard = ctx.symbol_index.lock().await;
757 if guard.is_none() {
758 *guard = Some(SymbolIndex::build(&ctx.workspace));
759 }
760 symbol_tools::read_symbol(guard.as_ref().unwrap(), &name)
761 }
762 "go_to_definition" => {
763 let file = str_arg(&call.arguments, "file")?;
764 let line = call.arguments["line"].as_u64().unwrap_or(1) as u32;
765 let col = call.arguments["col"].as_u64().unwrap_or(1) as u32;
766 lsp_tools::go_to_definition_grep(&ctx.workspace, Path::new(&file), line, col)
767 }
768 "find_references" => {
769 let file = str_arg(&call.arguments, "file")?;
770 let line = call.arguments["line"].as_u64().unwrap_or(1) as u32;
771 let col = call.arguments["col"].as_u64().unwrap_or(1) as u32;
772 lsp_tools::find_references_grep(&ctx.workspace, Path::new(&file), line, col)
773 }
774 "hover" => {
775 let file = str_arg(&call.arguments, "file")?;
776 let line = call.arguments["line"].as_u64().unwrap_or(1) as u32;
777 let col = call.arguments["col"].as_u64().unwrap_or(1) as u32;
778 lsp_tools::hover_grep(&ctx.workspace, Path::new(&file), line, col)
779 }
780 "recall" => {
781 let query = str_arg(&call.arguments, "query").unwrap_or_default();
782 memory_tools::recall(
783 &ctx.memory.lock().unwrap_or_else(|e| e.into_inner()),
784 &query,
785 )
786 }
787 other => {
788 return Err(crate::errors::RalphError::ToolFailed {
789 tool: other.to_string(),
790 message: "Not a read-only tool".to_string(),
791 })
792 }
793 };
794
795 Ok(ToolResult {
796 call_id: call.id,
797 tool_name: call.name,
798 output,
799 is_error: false,
800 })
801}
802
803pub fn tool_defs(search_enabled: bool, pr_enabled: bool) -> Vec<ToolDef> {
809 let mut defs = vec![
810 ToolDef {
811 name: "read_file".to_string(),
812 description: "Read a file in the workspace. Use offset/limit to page through large files.".to_string(),
813 parameters: json!({
814 "type": "object",
815 "properties": {
816 "path": { "type": "string", "description": "Relative path to the file." },
817 "offset": { "type": "integer", "description": "0-based line index to start reading from (default: 0)." },
818 "limit": { "type": "integer", "description": "Maximum number of lines to return (default: 150)." }
819 },
820 "required": ["path"]
821 }),
822 },
823 ToolDef {
824 name: "list_dir".to_string(),
825 description: "List the contents of a directory, respecting .gitignore.".to_string(),
826 parameters: json!({
827 "type": "object",
828 "properties": {
829 "path": { "type": "string", "description": "Relative path to directory. Defaults to workspace root." }
830 },
831 "required": []
832 }),
833 },
834 ToolDef {
835 name: "write_file".to_string(),
836 description: "Create or overwrite a file in the workspace.".to_string(),
837 parameters: json!({
838 "type": "object",
839 "properties": {
840 "path": { "type": "string", "description": "Relative path for the file." },
841 "content": { "type": "string", "description": "Full content to write." }
842 },
843 "required": ["path", "content"]
844 }),
845 },
846 ToolDef {
847 name: "edit_file".to_string(),
848 description: "Replace an exact string in a file. Fails if old_string is not found.".to_string(),
849 parameters: json!({
850 "type": "object",
851 "properties": {
852 "path": { "type": "string" },
853 "old_string": { "type": "string", "description": "Exact string to find (must be unique)." },
854 "new_string": { "type": "string", "description": "Replacement string." }
855 },
856 "required": ["path", "old_string", "new_string"]
857 }),
858 },
859 ToolDef {
860 name: "delete_file".to_string(),
861 description: "Delete a file from the workspace.".to_string(),
862 parameters: json!({
863 "type": "object",
864 "properties": {
865 "path": { "type": "string" }
866 },
867 "required": ["path"]
868 }),
869 },
870 ToolDef {
871 name: "run_command".to_string(),
872 description: "Execute a shell command. Requires user confirmation on first use.".to_string(),
873 parameters: json!({
874 "type": "object",
875 "properties": {
876 "cmd": { "type": "string", "description": "Shell command to run." },
877 "cwd": { "type": "string", "description": "Working directory relative to workspace (optional)." }
878 },
879 "required": ["cmd"]
880 }),
881 },
882 ToolDef {
883 name: "run_test".to_string(),
884 description: "Run the project test suite (whitelisted, no confirmation needed).".to_string(),
885 parameters: json!({
886 "type": "object",
887 "properties": {
888 "cmd": { "type": "string", "description": "Test command (e.g. 'cargo test')." }
889 },
890 "required": ["cmd"]
891 }),
892 },
893 ToolDef {
894 name: "run_build".to_string(),
895 description: "Run the project build (whitelisted, no confirmation needed).".to_string(),
896 parameters: json!({
897 "type": "object",
898 "properties": {
899 "cmd": { "type": "string", "description": "Build command (e.g. 'cargo build')." }
900 },
901 "required": ["cmd"]
902 }),
903 },
904 ToolDef {
905 name: "load_files".to_string(),
906 description: "Load all files matching a glob pattern. Returns each file with its path as a header.".to_string(),
907 parameters: json!({
908 "type": "object",
909 "properties": {
910 "pattern": { "type": "string", "description": "Glob pattern relative to root, e.g. 'src/**/*.rs' or '**/*.md'." },
911 "path": { "type": "string", "description": "Sub-path to restrict the search root (optional)." }
912 },
913 "required": ["pattern"]
914 }),
915 },
916 ToolDef {
917 name: "explain_code".to_string(),
918 description: "Analyze code structure at a path and return project type, directory tree, and entry points.".to_string(),
919 parameters: json!({
920 "type": "object",
921 "properties": {
922 "path": { "type": "string", "description": "Sub-path to analyze (optional, defaults to workspace root)." }
923 },
924 "required": []
925 }),
926 },
927 ToolDef {
928 name: "search_codebase".to_string(),
929 description: "Search the workspace codebase using a regex pattern. Supports file-glob filter and context lines.".to_string(),
930 parameters: json!({
931 "type": "object",
932 "properties": {
933 "pattern": { "type": "string", "description": "Regex pattern to search for." },
934 "path": { "type": "string", "description": "Sub-path to restrict search (optional)." },
935 "glob": { "type": "string", "description": "File glob filter, e.g. '*.py' or 'src/**/*.rs' (optional)." },
936 "context": { "type": "integer", "description": "Lines of context around each match (default: 0)." }
937 },
938 "required": ["pattern"]
939 }),
940 },
941 ToolDef {
942 name: "search_in_file".to_string(),
943 description: "Search a single file for a regex pattern, returning matching lines with surrounding context. More precise than search_codebase when you know which file to look in.".to_string(),
944 parameters: json!({
945 "type": "object",
946 "properties": {
947 "path": { "type": "string", "description": "Relative file path." },
948 "pattern": { "type": "string", "description": "Regex pattern to search for." },
949 "context": { "type": "integer", "description": "Lines of context before and after each match (default: 3)." }
950 },
951 "required": ["path", "pattern"]
952 }),
953 },
954 ToolDef {
955 name: "glob".to_string(),
956 description: "List files matching a glob pattern (e.g. '**/*.py', 'src/**/*.rs'). Returns sorted file paths. Use before load_files to verify which files will be loaded.".to_string(),
957 parameters: json!({
958 "type": "object",
959 "properties": {
960 "pattern": { "type": "string", "description": "Glob pattern, e.g. '**/*.py' or 'tests/test_*.py'." },
961 "path": { "type": "string", "description": "Sub-directory to restrict to (optional)." }
962 },
963 "required": ["pattern"]
964 }),
965 },
966 ToolDef {
967 name: "read_file_outline".to_string(),
968 description: "Get a structural outline of a file — function/class/struct signatures with line numbers, without bodies. Use this on large files to find which section to read instead of loading the whole file.".to_string(),
969 parameters: json!({
970 "type": "object",
971 "properties": {
972 "path": { "type": "string", "description": "Relative file path." }
973 },
974 "required": ["path"]
975 }),
976 },
977 ToolDef {
978 name: "edit_file_multi".to_string(),
979 description: "Apply multiple find-and-replace edits to a single file in one atomic call. All edits are validated before any are applied. Use this instead of multiple edit_file calls on the same file.".to_string(),
980 parameters: json!({
981 "type": "object",
982 "properties": {
983 "path": { "type": "string", "description": "Relative file path." },
984 "edits": {
985 "type": "array",
986 "description": "Ordered list of replacements.",
987 "items": {
988 "type": "object",
989 "properties": {
990 "old_string": { "type": "string", "description": "Exact text to find (must be unique in file)." },
991 "new_string": { "type": "string", "description": "Replacement text." }
992 },
993 "required": ["old_string", "new_string"]
994 }
995 }
996 },
997 "required": ["path", "edits"]
998 }),
999 },
1000 ToolDef {
1001 name: "view_diff".to_string(),
1002 description: "Show the current git diff (changes since last commit). Use this to review all your changes before calling declare_done, or to understand what has been modified so far.".to_string(),
1003 parameters: json!({
1004 "type": "object",
1005 "properties": {
1006 "path": { "type": "string", "description": "Restrict diff to a specific file or directory (optional)." }
1007 },
1008 "required": []
1009 }),
1010 },
1011 ToolDef {
1012 name: "ask_user".to_string(),
1013 description: "Pause and ask the user a clarifying question.".to_string(),
1014 parameters: json!({
1015 "type": "object",
1016 "properties": {
1017 "question": { "type": "string" }
1018 },
1019 "required": ["question"]
1020 }),
1021 },
1022 ToolDef {
1023 name: "declare_done".to_string(),
1024 description: "Signal that the task is complete.".to_string(),
1025 parameters: json!({
1026 "type": "object",
1027 "properties": {
1028 "summary": { "type": "string", "description": "Brief summary of what was accomplished." }
1029 },
1030 "required": ["summary"]
1031 }),
1032 },
1033 ToolDef {
1034 name: "declare_failed".to_string(),
1035 description: "Signal that the task cannot be completed, with a reason.".to_string(),
1036 parameters: json!({
1037 "type": "object",
1038 "properties": {
1039 "reason": { "type": "string" }
1040 },
1041 "required": ["reason"]
1042 }),
1043 },
1044 ToolDef {
1045 name: "go_to_definition".to_string(),
1046 description: "Jump to the definition of a symbol at a file position. Uses LSP when available.".to_string(),
1047 parameters: json!({
1048 "type": "object",
1049 "properties": {
1050 "file": { "type": "string", "description": "Relative file path." },
1051 "line": { "type": "integer", "description": "1-based line number." },
1052 "col": { "type": "integer", "description": "1-based column number." }
1053 },
1054 "required": ["file", "line", "col"]
1055 }),
1056 },
1057 ToolDef {
1058 name: "find_references".to_string(),
1059 description: "Find all usages of a symbol at a file position. Uses LSP when available.".to_string(),
1060 parameters: json!({
1061 "type": "object",
1062 "properties": {
1063 "file": { "type": "string", "description": "Relative file path." },
1064 "line": { "type": "integer", "description": "1-based line number." },
1065 "col": { "type": "integer", "description": "1-based column number." }
1066 },
1067 "required": ["file", "line", "col"]
1068 }),
1069 },
1070 ToolDef {
1071 name: "hover".to_string(),
1072 description: "Get type info and docs for a symbol at a file position. Uses LSP when available.".to_string(),
1073 parameters: json!({
1074 "type": "object",
1075 "properties": {
1076 "file": { "type": "string", "description": "Relative file path." },
1077 "line": { "type": "integer", "description": "1-based line number." },
1078 "col": { "type": "integer", "description": "1-based column number." }
1079 },
1080 "required": ["file", "line", "col"]
1081 }),
1082 },
1083 ToolDef {
1084 name: "find_symbol".to_string(),
1085 description: "Search the symbol index by name. Returns matching functions, structs, classes with file and line.".to_string(),
1086 parameters: json!({
1087 "type": "object",
1088 "properties": {
1089 "query": { "type": "string", "description": "Partial or full symbol name to search for." }
1090 },
1091 "required": ["query"]
1092 }),
1093 },
1094 ToolDef {
1095 name: "read_symbol".to_string(),
1096 description: "Read the full source body of a named symbol (exact name, case-insensitive). Use find_symbol first if unsure of the exact name.".to_string(),
1097 parameters: json!({
1098 "type": "object",
1099 "properties": {
1100 "name": { "type": "string", "description": "Exact symbol name." }
1101 },
1102 "required": ["name"]
1103 }),
1104 },
1105 ToolDef {
1106 name: "remember".to_string(),
1107 description: "Store a persistent key/value fact about this project, included in future sessions.".to_string(),
1108 parameters: json!({
1109 "type": "object",
1110 "properties": {
1111 "key": { "type": "string", "description": "Short descriptive key." },
1112 "value": { "type": "string", "description": "The fact to remember." }
1113 },
1114 "required": ["key", "value"]
1115 }),
1116 },
1117 ToolDef {
1118 name: "recall".to_string(),
1119 description: "Look up stored project facts. Leave query empty to see all.".to_string(),
1120 parameters: json!({
1121 "type": "object",
1122 "properties": {
1123 "query": { "type": "string", "description": "Search term (empty = return all facts)." }
1124 },
1125 "required": []
1126 }),
1127 },
1128 ];
1129
1130 if search_enabled {
1131 defs.push(ToolDef {
1132 name: "search_web".to_string(),
1133 description: "Search the web. Use this before making any assumptions about APIs, library versions, or recent changes.".to_string(),
1134 parameters: json!({
1135 "type": "object",
1136 "properties": {
1137 "query": { "type": "string", "description": "Search query." }
1138 },
1139 "required": ["query"]
1140 }),
1141 });
1142 }
1143
1144 if pr_enabled {
1145 defs.push(ToolDef {
1146 name: "create_pr".to_string(),
1147 description: "Create a GitHub pull request for the current branch using the gh CLI.".to_string(),
1148 parameters: json!({
1149 "type": "object",
1150 "properties": {
1151 "title": { "type": "string", "description": "PR title." },
1152 "body": { "type": "string", "description": "PR description (markdown)." },
1153 "draft": { "type": "boolean", "description": "Create as draft PR (default false)." },
1154 "base": { "type": "string", "description": "Target branch (default: repo default)." }
1155 },
1156 "required": ["title", "body"]
1157 }),
1158 });
1159 defs.push(ToolDef {
1160 name: "get_ci_status".to_string(),
1161 description: "Get the status of recent CI runs for the current (or specified) branch.".to_string(),
1162 parameters: json!({
1163 "type": "object",
1164 "properties": {
1165 "branch": { "type": "string", "description": "Branch name (default: current branch)." }
1166 },
1167 "required": []
1168 }),
1169 });
1170 }
1171
1172 defs
1173}