use super::{
MAX_FILE_BYTES, MAX_SEARCH_RESULTS, Tool, ToolContext, ToolError, ToolResult,
resolve_workspace_path_with_allowed, walk_workspace_files, wildcard_match,
workspace_root_from_ctx,
};
use async_trait::async_trait;
use roboticus_core::RiskLevel;
use serde_json::Value;
pub struct ReadFileTool;
#[async_trait]
impl Tool for ReadFileTool {
fn name(&self) -> &str {
"read_file"
}
fn description(&self) -> &str {
"Read a UTF-8 text file from the workspace"
}
fn risk_level(&self) -> RiskLevel {
RiskLevel::Caution
}
fn parameters_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
})
}
async fn execute(
&self,
params: Value,
ctx: &ToolContext,
) -> std::result::Result<ToolResult, ToolError> {
let rel = params
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError {
message: "missing 'path' parameter".into(),
})?;
let root = workspace_root_from_ctx(ctx)?;
let path = resolve_workspace_path_with_allowed(&root, rel, false, &ctx.tool_allowed_paths)?;
let meta = std::fs::metadata(&path).map_err(|e| ToolError {
message: format!("failed to stat '{}': {e}", path.display()),
})?;
if meta.len() as usize > MAX_FILE_BYTES {
return Err(ToolError {
message: format!(
"file too large (>{MAX_FILE_BYTES} bytes): {}",
path.display()
),
});
}
let content = std::fs::read_to_string(&path).map_err(|e| ToolError {
message: format!("failed to read '{}': {e}", path.display()),
})?;
Ok(ToolResult {
output: content,
metadata: Some(
serde_json::json!({ "path": path.display().to_string(), "bytes": meta.len() }),
),
})
}
}
pub struct WriteFileTool;
#[async_trait]
impl Tool for WriteFileTool {
fn name(&self) -> &str {
"write_file"
}
fn description(&self) -> &str {
"Write text content to a workspace file"
}
fn risk_level(&self) -> RiskLevel {
RiskLevel::Caution
}
fn parameters_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string" },
"content": { "type": "string" },
"append": { "type": "boolean", "default": false }
},
"required": ["path", "content"]
})
}
async fn execute(
&self,
params: Value,
ctx: &ToolContext,
) -> std::result::Result<ToolResult, ToolError> {
let rel = params
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError {
message: "missing 'path' parameter".into(),
})?;
let content = params
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError {
message: "missing 'content' parameter".into(),
})?;
let append = params
.get("append")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let root = workspace_root_from_ctx(ctx)?;
let path = resolve_workspace_path_with_allowed(&root, rel, true, &ctx.tool_allowed_paths)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| ToolError {
message: format!("failed to create parent dirs '{}': {e}", parent.display()),
})?;
}
if append {
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| ToolError {
message: format!("failed to open '{}': {e}", path.display()),
})?;
f.write_all(content.as_bytes()).map_err(|e| ToolError {
message: format!("failed to append '{}': {e}", path.display()),
})?;
} else {
std::fs::write(&path, content).map_err(|e| ToolError {
message: format!("failed to write '{}': {e}", path.display()),
})?;
}
Ok(ToolResult {
output: "ok".into(),
metadata: Some(
serde_json::json!({ "path": path.display().to_string(), "append": append }),
),
})
}
}
pub struct EditFileTool;
#[async_trait]
impl Tool for EditFileTool {
fn name(&self) -> &str {
"edit_file"
}
fn description(&self) -> &str {
"Replace text in an existing workspace file"
}
fn risk_level(&self) -> RiskLevel {
RiskLevel::Caution
}
fn parameters_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string" },
"old_text": { "type": "string" },
"new_text": { "type": "string" },
"replace_all": { "type": "boolean", "default": false }
},
"required": ["path", "old_text", "new_text"]
})
}
async fn execute(
&self,
params: Value,
ctx: &ToolContext,
) -> std::result::Result<ToolResult, ToolError> {
let rel = params
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError {
message: "missing 'path' parameter".into(),
})?;
let old_text = params
.get("old_text")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError {
message: "missing 'old_text' parameter".into(),
})?;
let new_text = params
.get("new_text")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError {
message: "missing 'new_text' parameter".into(),
})?;
let replace_all = params
.get("replace_all")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let root = workspace_root_from_ctx(ctx)?;
let path = resolve_workspace_path_with_allowed(&root, rel, false, &ctx.tool_allowed_paths)?;
let content = std::fs::read_to_string(&path).map_err(|e| ToolError {
message: format!("failed to read '{}': {e}", path.display()),
})?;
if !content.contains(old_text) {
return Err(ToolError {
message: "old_text not found in file".into(),
});
}
let updated = if replace_all {
content.replace(old_text, new_text)
} else {
content.replacen(old_text, new_text, 1)
};
std::fs::write(&path, updated).map_err(|e| ToolError {
message: format!("failed to write '{}': {e}", path.display()),
})?;
Ok(ToolResult {
output: "ok".into(),
metadata: Some(
serde_json::json!({ "path": path.display().to_string(), "replace_all": replace_all }),
),
})
}
}
pub struct ListDirectoryTool;
#[async_trait]
impl Tool for ListDirectoryTool {
fn name(&self) -> &str {
"list_directory"
}
fn description(&self) -> &str {
"List files and folders in a WORKSPACE directory. Paths are resolved relative \
to the agent's workspace root. For paths outside the workspace (e.g., \
~/Downloads, /tmp, user home directories), use the `bash` tool with `ls` instead."
}
fn risk_level(&self) -> RiskLevel {
RiskLevel::Caution
}
fn parameters_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "default": "." }
}
})
}
async fn execute(
&self,
params: Value,
ctx: &ToolContext,
) -> std::result::Result<ToolResult, ToolError> {
let rel = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let root = workspace_root_from_ctx(ctx)?;
let path = resolve_workspace_path_with_allowed(&root, rel, false, &ctx.tool_allowed_paths)?;
let mut entries = Vec::new();
for entry in std::fs::read_dir(&path).map_err(|e| ToolError {
message: format!("failed to read directory '{}': {e}", path.display()),
})? {
let entry = entry.map_err(|e| ToolError {
message: format!("failed to read entry: {e}"),
})?;
let p = entry.path();
let kind = if p.is_dir() { "dir" } else { "file" };
let name = p
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
entries.push(serde_json::json!({ "name": name, "kind": kind }));
}
entries.sort_by(|a, b| {
a["name"]
.as_str()
.unwrap_or_default()
.cmp(b["name"].as_str().unwrap_or_default())
});
Ok(ToolResult {
output: serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string()),
metadata: Some(
serde_json::json!({ "path": path.display().to_string(), "count": entries.len() }),
),
})
}
}
pub struct GlobFilesTool;
#[async_trait]
impl Tool for GlobFilesTool {
fn name(&self) -> &str {
"glob_files"
}
fn description(&self) -> &str {
"Find files matching a wildcard pattern under the workspace"
}
fn risk_level(&self) -> RiskLevel {
RiskLevel::Caution
}
fn parameters_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"pattern": { "type": "string" },
"path": { "type": "string", "default": "." },
"limit": { "type": "integer", "default": 50, "minimum": 1, "maximum": 500 }
},
"required": ["pattern"]
})
}
async fn execute(
&self,
params: Value,
ctx: &ToolContext,
) -> std::result::Result<ToolResult, ToolError> {
let pattern = params
.get("pattern")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError {
message: "missing 'pattern' parameter".into(),
})?;
let rel = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let limit = params
.get("limit")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.unwrap_or(50)
.min(500);
let root = workspace_root_from_ctx(ctx)?;
let base = resolve_workspace_path_with_allowed(&root, rel, false, &ctx.tool_allowed_paths)?;
let mut files = Vec::new();
let mut count = 0usize;
walk_workspace_files(&base, &mut files, &mut count)?;
let mut matches = Vec::new();
for p in files {
let rel = p.strip_prefix(&root).unwrap_or(&p);
let rel_norm = rel.to_string_lossy().replace('\\', "/");
if wildcard_match(pattern, &rel_norm) {
matches.push(rel_norm);
if matches.len() >= limit {
break;
}
}
}
Ok(ToolResult {
output: serde_json::to_string_pretty(&matches).unwrap_or_else(|_| "[]".to_string()),
metadata: Some(serde_json::json!({ "count": matches.len(), "pattern": pattern })),
})
}
}
pub struct SearchFilesTool;
#[async_trait]
impl Tool for SearchFilesTool {
fn name(&self) -> &str {
"search_files"
}
fn description(&self) -> &str {
"Search for text content across workspace files"
}
fn risk_level(&self) -> RiskLevel {
RiskLevel::Caution
}
fn parameters_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string" },
"path": { "type": "string", "default": "." },
"limit": { "type": "integer", "default": 20, "minimum": 1, "maximum": 100 },
"case_sensitive": { "type": "boolean", "default": false }
},
"required": ["query"]
})
}
async fn execute(
&self,
params: Value,
ctx: &ToolContext,
) -> std::result::Result<ToolResult, ToolError> {
let query = params
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError {
message: "missing 'query' parameter".into(),
})?;
let rel = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let limit = params
.get("limit")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.unwrap_or(20)
.min(MAX_SEARCH_RESULTS);
let case_sensitive = params
.get("case_sensitive")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let root = workspace_root_from_ctx(ctx)?;
let base = resolve_workspace_path_with_allowed(&root, rel, false, &ctx.tool_allowed_paths)?;
let mut files = Vec::new();
let mut count = 0usize;
walk_workspace_files(&base, &mut files, &mut count)?;
let mut hits = Vec::new();
let mut unreadable_files = 0usize;
let mut skipped_large_files = 0usize;
let query_cmp = if case_sensitive {
query.to_string()
} else {
query.to_lowercase()
};
for p in files {
if hits.len() >= limit {
break;
}
let file_size = match std::fs::metadata(&p) {
Ok(meta) => meta.len(),
Err(_) => {
unreadable_files += 1;
continue;
}
};
if file_size > MAX_FILE_BYTES as u64 {
skipped_large_files += 1;
continue;
}
let content = match std::fs::read_to_string(&p) {
Ok(c) => c,
Err(_) => {
unreadable_files += 1;
continue;
}
};
for (idx, line) in content.lines().enumerate() {
let cmp = if case_sensitive {
line.to_string()
} else {
line.to_lowercase()
};
if cmp.contains(&query_cmp) {
let relp = p
.strip_prefix(&root)
.unwrap_or(&p)
.to_string_lossy()
.replace('\\', "/");
hits.push(serde_json::json!({
"path": relp,
"line": idx + 1,
"preview": line
}));
if hits.len() >= limit {
break;
}
}
}
}
Ok(ToolResult {
output: serde_json::to_string_pretty(&hits).unwrap_or_else(|_| "[]".to_string()),
metadata: Some(serde_json::json!({
"count": hits.len(),
"query": query,
"unreadable_files": unreadable_files,
"skipped_large_files": skipped_large_files
})),
})
}
}