pub mod file_tools;
pub mod gh_tools;
pub mod lsp_tools;
pub mod memory_tools;
pub mod meta_tools;
pub mod search_tools;
pub mod shell_tools;
pub mod symbol_tools;
use crate::config::Config;
use crate::errors::{RalphError, Result};
use crate::guardrails::GuardrailChecker;
use crate::lsp_client::LspClient;
use crate::memory::MemoryStore;
use crate::output::{ConfirmResult, Printer};
use crate::providers::ToolDef;
use crate::symbol_index::SymbolIndex;
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: Value,
}
#[derive(Debug, Clone)]
pub struct ToolResult {
pub call_id: String,
pub tool_name: String,
pub output: String,
pub is_error: bool,
}
pub struct ToolRegistry {
workspace: PathBuf,
config: Config,
guardrails: GuardrailChecker,
printer: Arc<Printer>,
pub modified_files: Vec<PathBuf>,
command_confirmed_once: bool,
no_confirm: bool,
symbol_index: Option<SymbolIndex>,
memory: Arc<Mutex<MemoryStore>>,
lsp_clients: Vec<LspClient>,
diff_preview: Arc<AtomicBool>,
}
impl ToolRegistry {
pub fn new(
workspace: PathBuf,
config: Config,
printer: Arc<Printer>,
no_confirm: bool,
memory: Arc<Mutex<MemoryStore>>,
lsp_languages: Vec<String>,
diff_preview: Arc<AtomicBool>,
) -> Self {
let guardrails = GuardrailChecker::new(workspace.clone(), config.guardrails.clone());
let lsp_clients = lsp_languages
.iter()
.map(|lang| LspClient::new(lang, &workspace))
.collect();
Self {
workspace,
config,
guardrails,
printer,
modified_files: Vec::new(),
command_confirmed_once: false,
no_confirm,
symbol_index: None,
memory,
lsp_clients,
diff_preview,
}
}
fn ensure_symbol_index(&mut self) {
if self.symbol_index.is_none() {
self.printer
.print(crate::output::Phase::Observe, "Building symbol index...");
let idx = SymbolIndex::build(&self.workspace);
self.printer.print(
crate::output::Phase::Observe,
&format!("Symbol index ready ({} symbols).", idx.symbol_count()),
);
self.symbol_index = Some(idx);
}
}
pub async fn execute(&mut self, call: &ToolCall) -> Result<ToolResult> {
self.guardrails
.check_tool_call(&call.name, &call.arguments)?;
match call.name.as_str() {
"read_file" => self.exec_read_file(call),
"read_file_outline" => self.exec_read_file_outline(call),
"load_files" => self.exec_load_files(call),
"explain_code" => self.exec_explain_code(call),
"list_dir" => self.exec_list_dir(call),
"glob" => self.exec_glob(call),
"write_file" => self.exec_write_file(call).await,
"edit_file" => self.exec_edit_file(call).await,
"edit_file_multi" => self.exec_edit_file_multi(call).await,
"delete_file" => self.exec_delete_file(call).await,
"view_diff" => self.exec_view_diff(call).await,
"run_command" => self.exec_run_command(call).await,
"run_test" => self.exec_run_test(call).await,
"run_build" => self.exec_run_build(call).await,
"search_web" => self.exec_search_web(call).await,
"search_codebase" => self.exec_search_codebase(call),
"search_in_file" => self.exec_search_in_file(call),
"find_symbol" => self.exec_find_symbol(call),
"read_symbol" => self.exec_read_symbol(call),
"go_to_definition" => self.exec_go_to_definition(call).await,
"find_references" => self.exec_find_references(call).await,
"hover" => self.exec_hover(call).await,
"remember" => self.exec_remember(call),
"recall" => self.exec_recall(call),
"create_pr" => self.exec_create_pr(call).await,
"get_ci_status" => self.exec_get_ci_status(call).await,
"ask_user" => self.exec_ask_user(call),
"declare_done" => self.exec_declare_done(call),
"declare_failed" => self.exec_declare_failed(call),
unknown => Err(RalphError::ToolFailed {
tool: unknown.to_string(),
message: "Unknown tool".to_string(),
}),
}
}
fn exec_read_file(&self, call: &ToolCall) -> Result<ToolResult> {
let path = str_arg(&call.arguments, "path")?;
let full = self.resolve_path(&path)?;
let offset = call.arguments["offset"].as_u64().map(|n| n as usize);
let limit = call.arguments["limit"].as_u64().map(|n| n as usize);
let content = file_tools::read_file_ranged(&full, offset, limit)?;
Ok(ok_result(call, content))
}
fn exec_load_files(&self, call: &ToolCall) -> Result<ToolResult> {
let pattern = str_arg(&call.arguments, "pattern")?;
let root = str_arg(&call.arguments, "path")
.ok()
.map(|p| self.workspace.join(p))
.unwrap_or_else(|| self.workspace.clone());
let content = file_tools::load_files(&pattern, &root)?;
Ok(ok_result(call, content))
}
fn exec_explain_code(&self, call: &ToolCall) -> Result<ToolResult> {
let root = str_arg(&call.arguments, "path")
.ok()
.map(|p| self.resolve_path(&p))
.transpose()?
.unwrap_or_else(|| self.workspace.clone());
let report = file_tools::explain_code(&root)?;
Ok(ok_result(call, report))
}
fn exec_list_dir(&self, call: &ToolCall) -> Result<ToolResult> {
let path = str_arg(&call.arguments, "path").unwrap_or_else(|_| ".".to_string());
let full = self.resolve_path(&path)?;
let listing = file_tools::list_dir(&full)?;
Ok(ok_result(call, listing))
}
fn exec_read_file_outline(&self, call: &ToolCall) -> Result<ToolResult> {
let path = str_arg(&call.arguments, "path")?;
let full = self.resolve_path(&path)?;
let outline = file_tools::read_file_outline(&full)?;
Ok(ok_result(call, outline))
}
fn exec_glob(&self, call: &ToolCall) -> Result<ToolResult> {
let pattern = str_arg(&call.arguments, "pattern")?;
let root = str_arg(&call.arguments, "path")
.ok()
.map(|p| self.workspace.join(p))
.unwrap_or_else(|| self.workspace.clone());
let result = search_tools::glob_files(&pattern, &root)?;
Ok(ok_result(call, result))
}
fn exec_search_in_file(&self, call: &ToolCall) -> Result<ToolResult> {
let pattern = str_arg(&call.arguments, "pattern")?;
let path = str_arg(&call.arguments, "path")?;
let context = call.arguments["context"].as_u64().unwrap_or(3) as usize;
let full = self.resolve_path(&path)?;
let result = search_tools::search_in_file(&pattern, &full, context)?;
Ok(ok_result(call, result))
}
async fn exec_view_diff(&self, call: &ToolCall) -> Result<ToolResult> {
let file_filter = call.arguments["path"].as_str().map(|p| p.to_string());
let result = view_git_diff(&self.workspace, file_filter.as_deref()).await;
Ok(ok_result(call, result))
}
async fn exec_write_file(&mut self, call: &ToolCall) -> Result<ToolResult> {
let path = str_arg(&call.arguments, "path")?;
let content = str_arg(&call.arguments, "content")?;
let full = self.resolve_path(&path)?;
if self.diff_preview.load(Ordering::Relaxed) && !self.no_confirm {
let existing = std::fs::read_to_string(&full).unwrap_or_default();
self.printer.print_diff(&path, &existing, &content);
let result = self.printer.confirm("Proceed with write?", false, false);
if result != ConfirmResult::Yes {
return Err(RalphError::UserAborted);
}
} else if full.exists() && !self.no_confirm {
let auto_cp = self.config.checkpoints.auto_checkpoint_before_destructive;
let result = self.printer.confirm(
&format!("write_file({:?}) will overwrite an existing file.", path),
true,
auto_cp,
);
match result {
ConfirmResult::No => return Err(RalphError::UserAborted),
ConfirmResult::ShowDiff => {
let existing = std::fs::read_to_string(&full).unwrap_or_default();
self.printer.print_diff(&path, &existing, &content);
let result2 = self.printer.confirm("Proceed with write?", false, false);
if result2 != ConfirmResult::Yes {
return Err(RalphError::UserAborted);
}
}
ConfirmResult::CheckpointAndProceed => {}
ConfirmResult::Yes => {}
}
}
self.guardrails.check_content_for_secrets(&content)?;
file_tools::write_file(&full, &content)?;
self.modified_files.push(full);
Ok(ok_result(call, format!("Written: {}", path)))
}
async fn exec_edit_file(&mut self, call: &ToolCall) -> Result<ToolResult> {
let path = str_arg(&call.arguments, "path")?;
let old_string = str_arg(&call.arguments, "old_string")?;
let new_string = str_arg(&call.arguments, "new_string")?;
let full = self.resolve_path(&path)?;
match file_tools::edit_file(&full, &old_string, &new_string) {
Ok(()) => {
self.modified_files.push(full);
Ok(ok_result(call, format!("Edited: {}", path)))
}
Err(RalphError::EditNotFound { .. }) => {
let content = std::fs::read_to_string(&full).unwrap_or_default();
let hint = file_tools::find_closest_match_hint(&content, &old_string);
let hint_section = if hint.is_empty() {
String::new()
} else {
format!("\n\n{}", hint)
};
Ok(ToolResult {
call_id: call.id.clone(),
tool_name: call.name.clone(),
output: format!(
"edit_file failed: old_string not found in {}.\n\
The text must match the file byte-for-byte (check whitespace/indentation).\n\
Use `search_in_file` or `read_file` with offset/limit to see the exact text.{}",
path, hint_section
),
is_error: true,
})
}
Err(e) => Err(e),
}
}
async fn exec_edit_file_multi(&mut self, call: &ToolCall) -> Result<ToolResult> {
let path = str_arg(&call.arguments, "path")?;
let full = self.resolve_path(&path)?;
let edits_val = call.arguments["edits"].as_array().ok_or_else(|| {
RalphError::MalformedToolCall("edit_file_multi: 'edits' must be an array".to_string())
})?;
let edits: Vec<(String, String)> = edits_val
.iter()
.enumerate()
.map(|(i, e)| {
let old = e["old_string"]
.as_str()
.ok_or_else(|| {
RalphError::MalformedToolCall(format!("edit[{}]: missing old_string", i))
})?
.to_string();
let new = e["new_string"]
.as_str()
.ok_or_else(|| {
RalphError::MalformedToolCall(format!("edit[{}]: missing new_string", i))
})?
.to_string();
Ok::<_, RalphError>((old, new))
})
.collect::<Result<Vec<_>>>()?;
match file_tools::edit_file_multi(&full, &edits) {
Ok(applied) => {
self.modified_files.push(full);
Ok(ok_result(
call,
format!("Edited {}: {}", path, applied.join(", ")),
))
}
Err(e) => Ok(ToolResult {
call_id: call.id.clone(),
tool_name: call.name.clone(),
output: format!("edit_file_multi failed: {}", e),
is_error: true,
}),
}
}
async fn exec_delete_file(&mut self, call: &ToolCall) -> Result<ToolResult> {
let path = str_arg(&call.arguments, "path")?;
let full = self.resolve_path(&path)?;
if !self.no_confirm {
let result = self.printer.confirm(
&format!("delete_file({:?}). This cannot be undone.", path),
false,
self.config.checkpoints.auto_checkpoint_before_destructive,
);
if result == ConfirmResult::No {
return Err(RalphError::UserAborted);
}
}
file_tools::delete_file(&full)?;
self.modified_files.push(full);
Ok(ok_result(call, format!("Deleted: {}", path)))
}
async fn exec_run_command(&mut self, call: &ToolCall) -> Result<ToolResult> {
let cmd = str_arg(&call.arguments, "cmd")?;
let cwd = str_arg(&call.arguments, "cwd")
.ok()
.map(|c| self.workspace.join(c))
.unwrap_or_else(|| self.workspace.clone());
if !self.command_confirmed_once && !self.no_confirm {
let result = self
.printer
.confirm(&format!("run_command: `{}`", cmd), false, false);
if result == ConfirmResult::No {
return Err(RalphError::UserAborted);
}
self.command_confirmed_once = true;
}
let output = shell_tools::run_command(&cmd, &cwd).await?;
Ok(ok_result(call, output))
}
async fn exec_run_test(&self, call: &ToolCall) -> Result<ToolResult> {
let cmd = str_arg(&call.arguments, "cmd")?;
let output = shell_tools::run_command(&cmd, &self.workspace).await?;
Ok(ok_result(call, output))
}
async fn exec_run_build(&self, call: &ToolCall) -> Result<ToolResult> {
let cmd = str_arg(&call.arguments, "cmd")?;
let output = shell_tools::run_command(&cmd, &self.workspace).await?;
Ok(ok_result(call, output))
}
async fn exec_search_web(&self, call: &ToolCall) -> Result<ToolResult> {
let query = str_arg(&call.arguments, "query")?;
let brave_key = std::env::var(&self.config.search.brave_api_key_env).ok();
let serp_key = std::env::var(&self.config.search.serp_api_key_env).ok();
let results =
search_tools::search_web(&query, brave_key.as_deref(), serp_key.as_deref()).await?;
Ok(ok_result(call, results))
}
fn exec_search_codebase(&self, call: &ToolCall) -> Result<ToolResult> {
let pattern = str_arg(&call.arguments, "pattern")?;
let path = str_arg(&call.arguments, "path")
.ok()
.map(|p| self.workspace.join(p))
.unwrap_or_else(|| self.workspace.clone());
let glob = call.arguments["glob"].as_str();
let context = call.arguments["context"].as_u64().unwrap_or(0) as usize;
let results = search_tools::search_codebase_filtered(&pattern, &path, glob, context)?;
Ok(ok_result(call, results))
}
fn exec_find_symbol(&mut self, call: &ToolCall) -> Result<ToolResult> {
let query = str_arg(&call.arguments, "query")?;
self.ensure_symbol_index();
let output = symbol_tools::find_symbol(self.symbol_index.as_ref().unwrap(), &query);
Ok(ok_result(call, output))
}
fn exec_read_symbol(&mut self, call: &ToolCall) -> Result<ToolResult> {
let name = str_arg(&call.arguments, "name")?;
self.ensure_symbol_index();
let output = symbol_tools::read_symbol(self.symbol_index.as_ref().unwrap(), &name);
Ok(ok_result(call, output))
}
async fn exec_go_to_definition(&mut self, call: &ToolCall) -> Result<ToolResult> {
let file = str_arg(&call.arguments, "file")?;
let line = call.arguments["line"].as_u64().unwrap_or(1) as u32;
let col = call.arguments["col"].as_u64().unwrap_or(1) as u32;
let file_path = Path::new(&file);
let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
for client in &mut self.lsp_clients {
if client.handles_extension(ext) {
let out = client.go_to_definition(file_path, line, col).await?;
return Ok(ok_result(call, format!("[LSP] {}", out)));
}
}
let out = lsp_tools::go_to_definition_grep(&self.workspace, file_path, line, col);
Ok(ok_result(call, out))
}
async fn exec_find_references(&mut self, call: &ToolCall) -> Result<ToolResult> {
let file = str_arg(&call.arguments, "file")?;
let line = call.arguments["line"].as_u64().unwrap_or(1) as u32;
let col = call.arguments["col"].as_u64().unwrap_or(1) as u32;
let file_path = Path::new(&file);
let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
for client in &mut self.lsp_clients {
if client.handles_extension(ext) {
let out = client.find_references(file_path, line, col).await?;
return Ok(ok_result(call, format!("[LSP] {}", out)));
}
}
let out = lsp_tools::find_references_grep(&self.workspace, file_path, line, col);
Ok(ok_result(call, out))
}
async fn exec_hover(&mut self, call: &ToolCall) -> Result<ToolResult> {
let file = str_arg(&call.arguments, "file")?;
let line = call.arguments["line"].as_u64().unwrap_or(1) as u32;
let col = call.arguments["col"].as_u64().unwrap_or(1) as u32;
let file_path = Path::new(&file);
let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
for client in &mut self.lsp_clients {
if client.handles_extension(ext) {
let out = client.hover(file_path, line, col).await?;
return Ok(ok_result(call, format!("[LSP] {}", out)));
}
}
let out = lsp_tools::hover_grep(&self.workspace, file_path, line, col);
Ok(ok_result(call, out))
}
fn exec_remember(&self, call: &ToolCall) -> Result<ToolResult> {
let key = str_arg(&call.arguments, "key")?;
let value = str_arg(&call.arguments, "value")?;
let output = memory_tools::remember(
&mut self.memory.lock().unwrap_or_else(|e| e.into_inner()),
&key,
&value,
);
Ok(ok_result(call, output))
}
fn exec_recall(&self, call: &ToolCall) -> Result<ToolResult> {
let query = str_arg(&call.arguments, "query").unwrap_or_default();
let output = memory_tools::recall(
&self.memory.lock().unwrap_or_else(|e| e.into_inner()),
&query,
);
Ok(ok_result(call, output))
}
async fn exec_create_pr(&self, call: &ToolCall) -> Result<ToolResult> {
let title = str_arg(&call.arguments, "title")?;
let body = str_arg(&call.arguments, "body").unwrap_or_default();
let draft = call.arguments["draft"].as_bool().unwrap_or(false);
let base = call.arguments["base"].as_str().map(|s| s.to_string());
let url =
gh_tools::create_pr(&title, &body, draft, base.as_deref(), &self.workspace).await?;
Ok(ok_result(call, format!("PR created: {}", url)))
}
async fn exec_get_ci_status(&self, call: &ToolCall) -> Result<ToolResult> {
let branch = call.arguments["branch"].as_str().map(|s| s.to_string());
let status = gh_tools::get_ci_status(branch.as_deref(), &self.workspace).await?;
Ok(ok_result(call, status))
}
fn exec_ask_user(&self, call: &ToolCall) -> Result<ToolResult> {
let question = str_arg(&call.arguments, "question")?;
let answer = meta_tools::ask_user(&question);
Ok(ok_result(call, answer))
}
fn exec_declare_done(&self, call: &ToolCall) -> Result<ToolResult> {
let summary = str_arg(&call.arguments, "summary").unwrap_or_default();
Ok(ToolResult {
call_id: call.id.clone(),
tool_name: call.name.clone(),
output: format!("DONE: {}", summary),
is_error: false,
})
}
fn exec_declare_failed(&self, call: &ToolCall) -> Result<ToolResult> {
let reason = str_arg(&call.arguments, "reason").unwrap_or_default();
Ok(ToolResult {
call_id: call.id.clone(),
tool_name: call.name.clone(),
output: format!("FAILED: {}", reason),
is_error: true,
})
}
fn resolve_path(&self, path: &str) -> Result<PathBuf> {
let joined = if std::path::Path::new(path).is_absolute() {
PathBuf::from(path)
} else {
self.workspace.join(path)
};
let canonical = joined.canonicalize().unwrap_or_else(|_| joined.clone());
let ws_canonical = self
.workspace
.canonicalize()
.unwrap_or_else(|_| self.workspace.clone());
if !canonical.starts_with(&ws_canonical) {
return Err(RalphError::PathEscape(path.to_string()));
}
Ok(canonical)
}
}
pub async fn view_git_diff(workspace: &std::path::Path, file_filter: Option<&str>) -> String {
let mut cmd = tokio::process::Command::new("git");
cmd.arg("diff").arg("HEAD");
if let Some(f) = file_filter {
cmd.arg("--").arg(f);
}
cmd.current_dir(workspace);
match cmd.output().await {
Ok(out) => {
let diff = String::from_utf8_lossy(&out.stdout).to_string();
if diff.trim().is_empty() {
let fallback = tokio::process::Command::new("git")
.args(["diff"])
.current_dir(workspace)
.output()
.await
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
if fallback.trim().is_empty() {
"(no changes from last commit)".to_string()
} else {
fallback
}
} else {
diff
}
}
Err(e) => format!("(git not available: {})", e),
}
}
fn str_arg(args: &Value, key: &str) -> Result<String> {
args.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| RalphError::MalformedToolCall(format!("missing argument: {}", key)))
}
fn ok_result(call: &ToolCall, output: String) -> ToolResult {
ToolResult {
call_id: call.id.clone(),
tool_name: call.name.clone(),
output,
is_error: false,
}
}
pub fn is_read_only(name: &str) -> bool {
matches!(
name,
"read_file"
| "read_file_outline"
| "load_files"
| "list_dir"
| "glob"
| "explain_code"
| "search_codebase"
| "search_in_file"
| "search_web"
| "find_symbol"
| "read_symbol"
| "go_to_definition"
| "find_references"
| "hover"
| "recall"
)
}
pub struct ReadOnlyContext {
pub workspace: PathBuf,
pub config: crate::config::Config,
pub printer: Arc<Printer>,
pub symbol_index: Arc<tokio::sync::Mutex<Option<SymbolIndex>>>,
pub memory: Arc<Mutex<MemoryStore>>,
}
pub async fn execute_read_only(
ctx: Arc<ReadOnlyContext>,
call: ToolCall,
) -> crate::errors::Result<ToolResult> {
let resolve = |path: &str| -> crate::errors::Result<PathBuf> {
let joined = if Path::new(path).is_absolute() {
PathBuf::from(path)
} else {
ctx.workspace.join(path)
};
let canonical = joined.canonicalize().unwrap_or_else(|_| joined.clone());
let ws = ctx
.workspace
.canonicalize()
.unwrap_or_else(|_| ctx.workspace.clone());
if !canonical.starts_with(&ws) {
return Err(crate::errors::RalphError::PathEscape(path.to_string()));
}
Ok(joined)
};
let output: String = match call.name.as_str() {
"read_file" => {
let path = str_arg(&call.arguments, "path")?;
let offset = call.arguments["offset"].as_u64().map(|n| n as usize);
let limit = call.arguments["limit"].as_u64().map(|n| n as usize);
file_tools::read_file_ranged(&resolve(&path)?, offset, limit)?
}
"read_file_outline" => {
let path = str_arg(&call.arguments, "path")?;
file_tools::read_file_outline(&resolve(&path)?)?
}
"load_files" => {
let pattern = str_arg(&call.arguments, "pattern")?;
let root = str_arg(&call.arguments, "path")
.ok()
.map(|p| ctx.workspace.join(p))
.unwrap_or_else(|| ctx.workspace.clone());
file_tools::load_files(&pattern, &root)?
}
"list_dir" => {
let path = str_arg(&call.arguments, "path").unwrap_or_else(|_| ".".to_string());
file_tools::list_dir(&resolve(&path)?)?
}
"glob" => {
let pattern = str_arg(&call.arguments, "pattern")?;
let root = str_arg(&call.arguments, "path")
.ok()
.map(|p| ctx.workspace.join(p))
.unwrap_or_else(|| ctx.workspace.clone());
search_tools::glob_files(&pattern, &root)?
}
"explain_code" => {
let root = str_arg(&call.arguments, "path")
.ok()
.map(|p| ctx.workspace.join(p))
.unwrap_or_else(|| ctx.workspace.clone());
file_tools::explain_code(&root)?
}
"search_codebase" => {
let pattern = str_arg(&call.arguments, "pattern")?;
let path = str_arg(&call.arguments, "path")
.ok()
.map(|p| ctx.workspace.join(p))
.unwrap_or_else(|| ctx.workspace.clone());
let glob = call.arguments["glob"].as_str();
let context = call.arguments["context"].as_u64().unwrap_or(0) as usize;
search_tools::search_codebase_filtered(&pattern, &path, glob, context)?
}
"search_in_file" => {
let pattern = str_arg(&call.arguments, "pattern")?;
let path = str_arg(&call.arguments, "path")?;
let context = call.arguments["context"].as_u64().unwrap_or(3) as usize;
search_tools::search_in_file(&pattern, &resolve(&path)?, context)?
}
"search_web" => {
let query = str_arg(&call.arguments, "query")?;
let brave = std::env::var(&ctx.config.search.brave_api_key_env).ok();
let serp = std::env::var(&ctx.config.search.serp_api_key_env).ok();
search_tools::search_web(&query, brave.as_deref(), serp.as_deref()).await?
}
"find_symbol" => {
let query = str_arg(&call.arguments, "query")?;
let mut guard = ctx.symbol_index.lock().await;
if guard.is_none() {
*guard = Some(SymbolIndex::build(&ctx.workspace));
}
symbol_tools::find_symbol(guard.as_ref().unwrap(), &query)
}
"read_symbol" => {
let name = str_arg(&call.arguments, "name")?;
let mut guard = ctx.symbol_index.lock().await;
if guard.is_none() {
*guard = Some(SymbolIndex::build(&ctx.workspace));
}
symbol_tools::read_symbol(guard.as_ref().unwrap(), &name)
}
"go_to_definition" => {
let file = str_arg(&call.arguments, "file")?;
let line = call.arguments["line"].as_u64().unwrap_or(1) as u32;
let col = call.arguments["col"].as_u64().unwrap_or(1) as u32;
lsp_tools::go_to_definition_grep(&ctx.workspace, Path::new(&file), line, col)
}
"find_references" => {
let file = str_arg(&call.arguments, "file")?;
let line = call.arguments["line"].as_u64().unwrap_or(1) as u32;
let col = call.arguments["col"].as_u64().unwrap_or(1) as u32;
lsp_tools::find_references_grep(&ctx.workspace, Path::new(&file), line, col)
}
"hover" => {
let file = str_arg(&call.arguments, "file")?;
let line = call.arguments["line"].as_u64().unwrap_or(1) as u32;
let col = call.arguments["col"].as_u64().unwrap_or(1) as u32;
lsp_tools::hover_grep(&ctx.workspace, Path::new(&file), line, col)
}
"recall" => {
let query = str_arg(&call.arguments, "query").unwrap_or_default();
memory_tools::recall(
&ctx.memory.lock().unwrap_or_else(|e| e.into_inner()),
&query,
)
}
other => {
return Err(crate::errors::RalphError::ToolFailed {
tool: other.to_string(),
message: "Not a read-only tool".to_string(),
})
}
};
Ok(ToolResult {
call_id: call.id,
tool_name: call.name,
output,
is_error: false,
})
}
pub fn tool_defs(search_enabled: bool, pr_enabled: bool) -> Vec<ToolDef> {
let mut defs = vec![
ToolDef {
name: "read_file".to_string(),
description: "Read a file in the workspace. Use offset/limit to page through large files.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Relative path to the file." },
"offset": { "type": "integer", "description": "0-based line index to start reading from (default: 0)." },
"limit": { "type": "integer", "description": "Maximum number of lines to return (default: 150)." }
},
"required": ["path"]
}),
},
ToolDef {
name: "list_dir".to_string(),
description: "List the contents of a directory, respecting .gitignore.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Relative path to directory. Defaults to workspace root." }
},
"required": []
}),
},
ToolDef {
name: "write_file".to_string(),
description: "Create or overwrite a file in the workspace.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Relative path for the file." },
"content": { "type": "string", "description": "Full content to write." }
},
"required": ["path", "content"]
}),
},
ToolDef {
name: "edit_file".to_string(),
description: "Replace an exact string in a file. Fails if old_string is not found.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string" },
"old_string": { "type": "string", "description": "Exact string to find (must be unique)." },
"new_string": { "type": "string", "description": "Replacement string." }
},
"required": ["path", "old_string", "new_string"]
}),
},
ToolDef {
name: "delete_file".to_string(),
description: "Delete a file from the workspace.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string" }
},
"required": ["path"]
}),
},
ToolDef {
name: "run_command".to_string(),
description: "Execute a shell command. Requires user confirmation on first use.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"cmd": { "type": "string", "description": "Shell command to run." },
"cwd": { "type": "string", "description": "Working directory relative to workspace (optional)." }
},
"required": ["cmd"]
}),
},
ToolDef {
name: "run_test".to_string(),
description: "Run the project test suite (whitelisted, no confirmation needed).".to_string(),
parameters: json!({
"type": "object",
"properties": {
"cmd": { "type": "string", "description": "Test command (e.g. 'cargo test')." }
},
"required": ["cmd"]
}),
},
ToolDef {
name: "run_build".to_string(),
description: "Run the project build (whitelisted, no confirmation needed).".to_string(),
parameters: json!({
"type": "object",
"properties": {
"cmd": { "type": "string", "description": "Build command (e.g. 'cargo build')." }
},
"required": ["cmd"]
}),
},
ToolDef {
name: "load_files".to_string(),
description: "Load all files matching a glob pattern. Returns each file with its path as a header.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"pattern": { "type": "string", "description": "Glob pattern relative to root, e.g. 'src/**/*.rs' or '**/*.md'." },
"path": { "type": "string", "description": "Sub-path to restrict the search root (optional)." }
},
"required": ["pattern"]
}),
},
ToolDef {
name: "explain_code".to_string(),
description: "Analyze code structure at a path and return project type, directory tree, and entry points.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Sub-path to analyze (optional, defaults to workspace root)." }
},
"required": []
}),
},
ToolDef {
name: "search_codebase".to_string(),
description: "Search the workspace codebase using a regex pattern. Supports file-glob filter and context lines.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"pattern": { "type": "string", "description": "Regex pattern to search for." },
"path": { "type": "string", "description": "Sub-path to restrict search (optional)." },
"glob": { "type": "string", "description": "File glob filter, e.g. '*.py' or 'src/**/*.rs' (optional)." },
"context": { "type": "integer", "description": "Lines of context around each match (default: 0)." }
},
"required": ["pattern"]
}),
},
ToolDef {
name: "search_in_file".to_string(),
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(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Relative file path." },
"pattern": { "type": "string", "description": "Regex pattern to search for." },
"context": { "type": "integer", "description": "Lines of context before and after each match (default: 3)." }
},
"required": ["path", "pattern"]
}),
},
ToolDef {
name: "glob".to_string(),
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(),
parameters: json!({
"type": "object",
"properties": {
"pattern": { "type": "string", "description": "Glob pattern, e.g. '**/*.py' or 'tests/test_*.py'." },
"path": { "type": "string", "description": "Sub-directory to restrict to (optional)." }
},
"required": ["pattern"]
}),
},
ToolDef {
name: "read_file_outline".to_string(),
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(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Relative file path." }
},
"required": ["path"]
}),
},
ToolDef {
name: "edit_file_multi".to_string(),
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(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Relative file path." },
"edits": {
"type": "array",
"description": "Ordered list of replacements.",
"items": {
"type": "object",
"properties": {
"old_string": { "type": "string", "description": "Exact text to find (must be unique in file)." },
"new_string": { "type": "string", "description": "Replacement text." }
},
"required": ["old_string", "new_string"]
}
}
},
"required": ["path", "edits"]
}),
},
ToolDef {
name: "view_diff".to_string(),
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(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Restrict diff to a specific file or directory (optional)." }
},
"required": []
}),
},
ToolDef {
name: "ask_user".to_string(),
description: "Pause and ask the user a clarifying question.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"question": { "type": "string" }
},
"required": ["question"]
}),
},
ToolDef {
name: "declare_done".to_string(),
description: "Signal that the task is complete.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"summary": { "type": "string", "description": "Brief summary of what was accomplished." }
},
"required": ["summary"]
}),
},
ToolDef {
name: "declare_failed".to_string(),
description: "Signal that the task cannot be completed, with a reason.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"reason": { "type": "string" }
},
"required": ["reason"]
}),
},
ToolDef {
name: "go_to_definition".to_string(),
description: "Jump to the definition of a symbol at a file position. Uses LSP when available.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"file": { "type": "string", "description": "Relative file path." },
"line": { "type": "integer", "description": "1-based line number." },
"col": { "type": "integer", "description": "1-based column number." }
},
"required": ["file", "line", "col"]
}),
},
ToolDef {
name: "find_references".to_string(),
description: "Find all usages of a symbol at a file position. Uses LSP when available.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"file": { "type": "string", "description": "Relative file path." },
"line": { "type": "integer", "description": "1-based line number." },
"col": { "type": "integer", "description": "1-based column number." }
},
"required": ["file", "line", "col"]
}),
},
ToolDef {
name: "hover".to_string(),
description: "Get type info and docs for a symbol at a file position. Uses LSP when available.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"file": { "type": "string", "description": "Relative file path." },
"line": { "type": "integer", "description": "1-based line number." },
"col": { "type": "integer", "description": "1-based column number." }
},
"required": ["file", "line", "col"]
}),
},
ToolDef {
name: "find_symbol".to_string(),
description: "Search the symbol index by name. Returns matching functions, structs, classes with file and line.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Partial or full symbol name to search for." }
},
"required": ["query"]
}),
},
ToolDef {
name: "read_symbol".to_string(),
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(),
parameters: json!({
"type": "object",
"properties": {
"name": { "type": "string", "description": "Exact symbol name." }
},
"required": ["name"]
}),
},
ToolDef {
name: "remember".to_string(),
description: "Store a persistent key/value fact about this project, included in future sessions.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"key": { "type": "string", "description": "Short descriptive key." },
"value": { "type": "string", "description": "The fact to remember." }
},
"required": ["key", "value"]
}),
},
ToolDef {
name: "recall".to_string(),
description: "Look up stored project facts. Leave query empty to see all.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Search term (empty = return all facts)." }
},
"required": []
}),
},
];
if search_enabled {
defs.push(ToolDef {
name: "search_web".to_string(),
description: "Search the web. Use this before making any assumptions about APIs, library versions, or recent changes.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Search query." }
},
"required": ["query"]
}),
});
}
if pr_enabled {
defs.push(ToolDef {
name: "create_pr".to_string(),
description: "Create a GitHub pull request for the current branch using the gh CLI.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"title": { "type": "string", "description": "PR title." },
"body": { "type": "string", "description": "PR description (markdown)." },
"draft": { "type": "boolean", "description": "Create as draft PR (default false)." },
"base": { "type": "string", "description": "Target branch (default: repo default)." }
},
"required": ["title", "body"]
}),
});
defs.push(ToolDef {
name: "get_ci_status".to_string(),
description: "Get the status of recent CI runs for the current (or specified) branch.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"branch": { "type": "string", "description": "Branch name (default: current branch)." }
},
"required": []
}),
});
}
defs
}