use crate::ai::types::ToolSpec;
use serde_json::{Value, json};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Risk {
Read,
Write,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ToolRequest {
ReadFile {
path: String,
start_line: Option<usize>,
end_line: Option<usize>,
},
ListDir {
path: String,
},
Grep {
pattern: String,
path: Option<String>,
},
Diagnostics {
path: Option<String>,
},
EditFile {
path: String,
edits: Vec<crate::ai::edits::Edit>,
},
WriteFile {
path: String,
content: String,
},
FindFiles {
pattern: String,
path: Option<String>,
},
Terminal {
command: String,
},
Task {
agent: String,
prompt: String,
},
Memory {
input: Value,
},
Skill {
name: String,
},
Computer {
action: crate::ai::computer::ComputerAction,
},
Browser {
action: crate::ai::browser::BrowserAction,
},
Mcp {
name: String,
input: Value,
},
}
impl ToolRequest {
pub fn risk(&self) -> Risk {
match self {
ToolRequest::EditFile { .. }
| ToolRequest::WriteFile { .. }
| ToolRequest::Terminal { .. }
| ToolRequest::Computer { .. }
| ToolRequest::Mcp { .. } => Risk::Write,
_ => Risk::Read,
}
}
pub fn name(&self) -> &'static str {
match self {
ToolRequest::ReadFile { .. } => "read_file",
ToolRequest::ListDir { .. } => "list_dir",
ToolRequest::Grep { .. } => "grep",
ToolRequest::Diagnostics { .. } => "diagnostics",
ToolRequest::EditFile { .. } => "edit_file",
ToolRequest::WriteFile { .. } => "write_file",
ToolRequest::FindFiles { .. } => "find_files",
ToolRequest::Terminal { .. } => "terminal",
ToolRequest::Task { .. } => "task",
ToolRequest::Memory { .. } => "code_memory",
ToolRequest::Computer { .. } => "computer",
ToolRequest::Browser { .. } => "browser",
ToolRequest::Skill { .. } => "skill",
ToolRequest::Mcp { .. } => "mcp",
}
}
pub fn summary(&self) -> String {
match self {
ToolRequest::ReadFile { path, .. } => format!("Leer {path}"),
ToolRequest::ListDir { path } => format!("Listar {path}"),
ToolRequest::Grep { pattern, .. } => format!("Buscar «{pattern}»"),
ToolRequest::Diagnostics { path } => {
format!("Diagnósticos de {}", path.as_deref().unwrap_or("todo el proyecto"))
}
ToolRequest::EditFile { path, edits } if edits.len() > 1 => {
format!("Editar {path} ({} cambios)", edits.len())
}
ToolRequest::EditFile { path, .. } => format!("Editar {path}"),
ToolRequest::WriteFile { path, .. } => format!("Escribir {path}"),
ToolRequest::FindFiles { pattern, .. } => format!("Buscar archivos «{pattern}»"),
ToolRequest::Task { agent, prompt } => {
let line = prompt.lines().next().unwrap_or_default();
let short: String = line.chars().take(60).collect();
format!("Subagente {agent}: {short}")
}
ToolRequest::Terminal { command } => format!("Ejecutar `{command}`"),
ToolRequest::Memory { input } => format!("Memoria: {}", input["action"].as_str().unwrap_or("?")),
ToolRequest::Computer { action } => action.summary(),
ToolRequest::Browser { action } => action.summary(),
ToolRequest::Skill { name } => format!("Usar la skill {name}"),
ToolRequest::Mcp { name, .. } => {
let (server, tool) = crate::ai::mcp_hub::split_name(name).unwrap_or(("?", name));
format!("MCP {server}: {tool}")
}
}
}
pub fn parse(name: &str, input: &Value) -> Result<Self, String> {
let text = |key: &str| input[key].as_str().map(str::to_owned);
let required = |key: &str| text(key).ok_or_else(|| format!("{name}: falta el argumento «{key}»"));
let line = |key: &str| input[key].as_u64().map(|value| value as usize);
if let Some(raw) = input[crate::ai::wire::INVALID_ARGUMENTS].as_str() {
return Err(format!(
"{name}: los argumentos no son JSON válido ({raw}); vuelve a llamarla con JSON correcto"
));
}
if name.starts_with("mcp__") {
return Ok(Self::Mcp { name: name.to_owned(), input: input.clone() });
}
match name {
"read_file" => Ok(Self::ReadFile {
path: required("path")?,
start_line: line("start_line"),
end_line: line("end_line"),
}),
"list_dir" => Ok(Self::ListDir { path: text("path").unwrap_or_else(|| ".".into()) }),
"grep" => Ok(Self::Grep { pattern: required("pattern")?, path: text("path") }),
"diagnostics" => Ok(Self::Diagnostics { path: text("path") }),
"edit_file" => Ok(Self::EditFile { path: required("path")?, edits: crate::ai::edits::parse(input)? }),
"write_file" => Ok(Self::WriteFile { path: required("path")?, content: required("content")? }),
"find_files" => Ok(Self::FindFiles { pattern: required("pattern")?, path: text("path") }),
"terminal" => Ok(Self::Terminal { command: required("command")? }),
"task" => Ok(Self::Task { agent: required("agent")?, prompt: required("prompt")? }),
"code_memory" => Ok(Self::Memory { input: input.clone() }),
"skill" => Ok(Self::Skill { name: required("name")?.trim_start_matches('/').to_owned() }),
"computer" => Ok(Self::Computer { action: crate::ai::computer::ComputerAction::parse(input)? }),
"browser" => Ok(Self::Browser { action: crate::ai::browser::BrowserAction::parse(input)? }),
other => Err(format!("herramienta desconocida: {other}")),
}
}
}
fn spec(name: &str, description: &str, properties: Value, required: &[&str]) -> ToolSpec {
ToolSpec {
name: name.into(),
description: description.into(),
schema: json!({ "type": "object", "properties": properties, "required": required }),
}
}
pub fn specs() -> Vec<ToolSpec> {
let path = json!({ "type": "string", "description": "Ruta relativa a la raíz del proyecto" });
vec![
spec(
"read_file",
"Lee un archivo del proyecto, opcionalmente solo un rango de líneas (desde 1).",
json!({ "path": path, "start_line": { "type": "integer" }, "end_line": { "type": "integer" } }),
&["path"],
),
spec("list_dir", "Lista los archivos y carpetas de un directorio.", json!({ "path": path }), &[]),
spec(
"grep",
"Busca un texto o expresión regular en el proyecto.",
json!({ "pattern": { "type": "string" }, "path": path }),
&["pattern"],
),
spec("diagnostics", "Errores y avisos del servidor de lenguaje.", json!({ "path": path }), &[]),
crate::ai::edits::spec(&path),
spec(
"write_file",
"Crea un archivo o lo sobrescribe entero con `content`. Para cambios parciales usa edit_file.",
json!({ "path": path, "content": { "type": "string" } }),
&["path", "content"],
),
spec(
"find_files",
"Busca archivos por glob (`**/*.tsx`) o por un trozo del nombre; respeta .gitignore.",
json!({ "pattern": { "type": "string" }, "path": path }),
&["pattern"],
),
spec(
"terminal",
"Ejecuta un comando de shell en la raíz del proyecto.",
json!({ "command": { "type": "string" } }),
&["command"],
),
crate::ai::browser::spec(),
]
}
pub fn task_spec(agents: &[(String, String)]) -> Option<ToolSpec> {
if agents.is_empty() {
return None;
}
let names: Vec<&str> = agents.iter().map(|(name, _)| name.as_str()).collect();
let listed: Vec<String> = agents.iter().map(|(name, about)| format!("- {name}: {about}")).collect();
let description = format!(
"Delega una investigación de solo lectura en un subagente (otro modelo) y devuelve su informe. Úsalo para \
explorar el código o revisar algo en paralelo; varias llamadas en el mismo turno corren a la vez.\n{}",
listed.join("\n")
);
let properties = json!({
"agent": { "type": "string", "enum": names },
"prompt": { "type": "string", "description": "Qué debe averiguar y qué devolver, con todo el contexto" },
});
Some(spec("task", &description, properties, &["agent", "prompt"]))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn analiza_argumentos_y_clasifica_riesgo() {
let read = ToolRequest::parse("read_file", &json!({"path": "a.rs", "start_line": 3})).unwrap();
assert_eq!(read, ToolRequest::ReadFile { path: "a.rs".into(), start_line: Some(3), end_line: None });
assert_eq!(read.risk(), Risk::Read);
let edit = ToolRequest::parse("edit_file", &json!({"path": "a.rs", "old": "x", "new": "y"})).unwrap();
assert_eq!(edit.risk(), Risk::Write);
assert_eq!(ToolRequest::parse("terminal", &json!({"command": "ls"})).unwrap().risk(), Risk::Write);
}
#[test]
fn errores_explican_el_argumento() {
assert!(ToolRequest::parse("grep", &json!({})).unwrap_err().contains("pattern"));
assert!(ToolRequest::parse("rm_rf", &json!({})).unwrap_err().contains("desconocida"));
let mcp = ToolRequest::parse("mcp__git__log", &json!({"n": 3})).unwrap();
assert_eq!(mcp.risk(), Risk::Write);
assert_eq!(mcp.summary(), "MCP git: log");
}
#[test]
fn toda_especificacion_tiene_su_analizador() {
let task = task_spec(&[("explorador".into(), "lee código".into())]);
assert!(task_spec(&[]).is_none());
for tool in specs().into_iter().chain(task) {
let input = json!({"path": "a", "pattern": "p", "new": "n", "command": "c", "content": "x", "agent": "e", "prompt": "p", "action": "read", "url": "https://example.com"});
let parsed = ToolRequest::parse(&tool.name, &input).unwrap();
assert_eq!(parsed.name(), tool.name);
}
}
}